#![warn(clippy::pedantic)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::todo,
clippy::unimplemented,
clippy::panic
)
)]
#![allow(clippy::module_name_repetitions)]
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod config;
mod encoding;
pub mod grammar;
mod loader;
mod mutate;
#[cfg(feature = "exploits")]
pub mod ports;
pub mod validate;
pub use config::{parse_marker_position, PayloadConfigFile};
pub use encoding::{apply_encoding, BuiltinEncoding, CustomEncoder, Encoder, EncodingError};
pub use grammar::{
depluralize, expand, expand_template, Context, Encoding, Grammar, GrammarMeta, Technique,
TemplateExpansionError, Variable,
};
pub use loader::PayloadDb;
pub use mutate::{
mutate_all, mutate_case, mutate_encoding_mix, mutate_html, mutate_null_bytes,
mutate_sql_comments, mutate_unicode, mutate_whitespace,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
pub use validate::{validate, GrammarIssue, IssueLevel};
pub trait PayloadSource {
fn payloads(&mut self, category: &str) -> &[Payload];
fn categories(&self) -> Vec<&str>;
fn payload_count(&self) -> usize;
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StaticPayloads {
payloads: Vec<Payload>,
#[serde(skip)]
category_ranges: BTreeMap<String, std::ops::Range<usize>>,
}
impl StaticPayloads {
pub fn new(mut payloads: Vec<Payload>) -> Self {
sort_payloads_by_category(&mut payloads);
Self {
category_ranges: build_category_ranges(&payloads),
payloads,
}
}
pub fn add(&mut self, payload: Payload) {
self.payloads.push(payload);
sort_payloads_by_category(&mut self.payloads);
self.category_ranges = build_category_ranges(&self.payloads);
}
pub fn all_payloads(&self) -> &[Payload] {
&self.payloads
}
pub fn iter(&self) -> impl Iterator<Item = &Payload> {
self.payloads.iter()
}
pub fn iter_category<'a>(
&'a self,
category: &'a str,
) -> impl Iterator<Item = &'a Payload> + 'a {
self.payloads
.iter()
.filter(move |payload| payload.category == category)
}
}
impl std::fmt::Display for StaticPayloads {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "StaticPayloads(count={})", self.payloads.len())
}
}
impl From<Vec<Payload>> for StaticPayloads {
fn from(payloads: Vec<Payload>) -> Self {
Self::new(payloads)
}
}
impl PayloadSource for StaticPayloads {
fn payloads(&mut self, category: &str) -> &[Payload] {
if self.category_ranges.is_empty() && !self.payloads.is_empty() {
self.category_ranges = build_category_ranges(&self.payloads);
}
self.category_ranges
.get(category)
.map_or(&[], |range| &self.payloads[range.clone()])
}
fn categories(&self) -> Vec<&str> {
use std::collections::HashSet;
let mut seen = HashSet::new();
self.payloads
.iter()
.filter_map(|p| {
if seen.insert(p.category.clone()) {
Some(p.category.as_str())
} else {
None
}
})
.collect()
}
fn payload_count(&self) -> usize {
self.payloads.len()
}
}
fn build_category_ranges(payloads: &[Payload]) -> BTreeMap<String, std::ops::Range<usize>> {
let mut ranges = BTreeMap::new();
let mut start = 0;
while start < payloads.len() {
let category = payloads[start].category.clone();
let mut end = start + 1;
while end < payloads.len() && payloads[end].category == category {
end += 1;
}
ranges.insert(category, start..end);
start = end;
}
ranges
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PayloadConfig {
pub max_per_category: usize,
pub deduplicate: bool,
pub marker_prefix: String,
pub exclude_categories: Vec<String>,
pub include_categories: Vec<String>,
pub target_runtime: Option<Vec<String>>,
pub marker_position: MarkerPosition,
pub max_payload_length: usize,
}
impl PayloadConfig {
pub fn builder() -> PayloadConfigBuilder {
PayloadConfigBuilder::default()
}
pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, PayloadError> {
PayloadConfigFile::load(path)?.into_config()
}
pub fn from_toml(toml_str: &str, source: impl Into<String>) -> Result<Self, PayloadError> {
PayloadConfigFile::from_toml(toml_str, source.into())?.into_config()
}
}
impl std::fmt::Display for PayloadConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"PayloadConfig(max_per_category={}, deduplicate={}, marker_position={})",
self.max_per_category, self.deduplicate, self.marker_position
)
}
}
impl Default for PayloadConfig {
fn default() -> Self {
Self {
max_per_category: 0,
deduplicate: true,
marker_prefix: "SLN".into(),
exclude_categories: Vec::new(),
include_categories: Vec::new(),
target_runtime: None,
marker_position: MarkerPosition::Prefix,
max_payload_length: 100_000,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum MarkerPosition {
Prefix,
Suffix,
Inline,
Replace(String),
}
impl std::fmt::Display for MarkerPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Prefix => f.write_str("prefix"),
Self::Suffix => f.write_str("suffix"),
Self::Inline => f.write_str("inline"),
Self::Replace(value) => write!(f, "replace:{value}"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct PayloadConfigBuilder {
config: PayloadConfig,
}
impl std::fmt::Display for PayloadConfigBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PayloadConfigBuilder({})", self.config)
}
}
impl PayloadConfigBuilder {
pub fn max_per_category(mut self, max_per_category: usize) -> Self {
self.config.max_per_category = max_per_category;
self
}
pub fn deduplicate(mut self, deduplicate: bool) -> Self {
self.config.deduplicate = deduplicate;
self
}
pub fn marker_prefix(mut self, marker_prefix: impl Into<String>) -> Self {
self.config.marker_prefix = marker_prefix.into();
self
}
pub fn exclude_categories(mut self, exclude_categories: Vec<String>) -> Self {
self.config.exclude_categories = exclude_categories;
self
}
pub fn include_categories(mut self, include_categories: Vec<String>) -> Self {
self.config.include_categories = include_categories;
self
}
pub fn target_runtime(mut self, target_runtime: Option<Vec<String>>) -> Self {
self.config.target_runtime = target_runtime;
self
}
pub fn marker_position(mut self, marker_position: MarkerPosition) -> Self {
self.config.marker_position = marker_position;
self
}
pub fn max_payload_length(mut self, max_payload_length: usize) -> Self {
self.config.max_payload_length = max_payload_length;
self
}
pub fn build(self) -> PayloadConfig {
self.config
}
}
fn sort_payloads_by_category(payloads: &mut [Payload]) {
payloads.sort_by(|left, right| {
left.category
.cmp(&right.category)
.then_with(|| left.technique.cmp(&right.technique))
.then_with(|| left.context.cmp(&right.context))
.then_with(|| left.encoding.cmp(&right.encoding))
.then_with(|| left.text.cmp(&right.text))
});
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Payload {
pub text: String,
pub category: String,
pub technique: String,
pub context: String,
pub encoding: String,
pub cwe: Option<String>,
pub severity: Option<String>,
pub confidence: f64,
pub expected_pattern: Option<String>,
#[serde(default)]
pub target_media_type: Option<String>,
}
impl PartialEq for Payload {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.category == other.category
&& self.technique == other.technique
&& self.context == other.context
&& self.encoding == other.encoding
&& self.cwe == other.cwe
&& self.severity == other.severity
&& self.confidence.to_bits() == other.confidence.to_bits()
&& self.expected_pattern == other.expected_pattern
&& self.target_media_type == other.target_media_type
}
}
impl Default for Payload {
fn default() -> Self {
Self {
text: String::new(),
category: String::new(),
technique: String::new(),
context: String::new(),
encoding: "raw".to_string(),
cwe: None,
severity: None,
confidence: 1.0,
expected_pattern: None,
target_media_type: None,
}
}
}
impl Eq for Payload {}
impl Hash for Payload {
fn hash<H: Hasher>(&self, state: &mut H) {
self.text.hash(state);
self.category.hash(state);
self.technique.hash(state);
self.context.hash(state);
self.encoding.hash(state);
self.cwe.hash(state);
self.severity.hash(state);
self.confidence.to_bits().hash(state);
self.expected_pattern.hash(state);
self.target_media_type.hash(state);
}
}
impl std::fmt::Display for Payload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{}:{}:{}",
self.category, self.technique, self.context, self.text
)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PayloadError {
#[error("{0}. Fix: verify the file or directory exists and that the current process has permission to read it.")]
Io(#[from] std::io::Error),
#[error("{message}", message = Self::config_parse_message(file, source))]
ConfigParse {
file: String,
source: Box<toml::de::Error>,
},
#[error("{message}", message = Self::grammar_parse_message(file, source))]
GrammarParse {
file: String,
source: Box<toml::de::Error>,
},
#[error("{message}", message = Self::grammar_validation_message(file, issues))]
GrammarValidation {
file: String,
issues: Vec<GrammarIssue>,
},
#[error("{message}", message = Self::template_expansion_message(file, source))]
TemplateExpansion {
file: String,
source: TemplateExpansionError,
},
#[error("path '{0}' is not a directory. Fix: pass a directory that contains `.toml` grammar files or update `grammar_dirs` in your config.")]
NotADirectory(String),
#[error("payload database load is already in progress. Fix: wait for the current `load_dir` call to finish before starting another one on the same `PayloadDb`.")]
ConcurrentLoad,
#[error("invalid configuration value: {0}. Fix: check your config file against the supported options.")]
InvalidConfig(String),
}
impl Serialize for PayloadError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("kind", self.kind())?;
map.serialize_entry("message", &self.to_string())?;
map.end()
}
}
impl<'de> Deserialize<'de> for PayloadError {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct PayloadErrorWire {
kind: String,
message: String,
}
let wire = PayloadErrorWire::deserialize(deserializer)?;
Ok(Self::Io(std::io::Error::other(format!(
"[{}] {}",
wire.kind, wire.message
))))
}
}
impl PayloadError {
fn kind(&self) -> &'static str {
match self {
Self::Io(_) => "io",
Self::ConfigParse { .. } => "config_parse",
Self::GrammarParse { .. } => "grammar_parse",
Self::GrammarValidation { .. } => "grammar_validation",
Self::TemplateExpansion { .. } => "template_expansion",
Self::NotADirectory(_) => "not_a_directory",
Self::ConcurrentLoad => "concurrent_load",
Self::InvalidConfig(_) => "invalid_config",
}
}
fn config_parse_message(file: &str, source: &toml::de::Error) -> String {
format!(
"config parse error in {file}: {source}. Fix: make the file valid TOML and keep payload settings at the top level, for example `max_per_category = 100` and `grammar_dirs = [\"./grammars\"]`."
)
}
fn grammar_parse_message(file: &str, source: &toml::de::Error) -> String {
let detail = source.to_string();
let fix = if detail.contains("missing field `grammar`") {
"Fix: add a `[grammar]` table with at least `name` and `sink_category`."
} else if detail.contains("missing field `name`")
|| detail.contains("missing field `sink_category`")
{
"Fix: every grammar needs a `[grammar]` section with both `name` and `sink_category` fields."
} else if detail.contains("missing field `template`") {
"Fix: every `[[techniques]]` entry needs a `name` and `template`."
} else {
"Fix: make the file valid TOML and include a `[grammar]` section plus at least one `[[techniques]]` entry."
};
format!("grammar parse error in {file}: {detail}. {fix}")
}
fn template_expansion_message(file: &str, source: &TemplateExpansionError) -> String {
let fix = match source {
TemplateExpansionError::UnclosedBrace { .. } => {
"Fix: close every `{placeholder}` with a matching `}` and escape literal braces by leaving them outside placeholder syntax."
}
TemplateExpansionError::RecursionLimitExceeded { max_depth } => {
return format!(
"template expansion error in {file}: {source}. Fix: remove circular or self-referential variables so expansion stays below the recursion limit of {max_depth}."
);
}
TemplateExpansionError::PayloadLimitExceeded { limit } => {
return format!(
"template expansion error in {file}: {source}. Fix: reduce Cartesian product size (contexts x techniques x variables) to stay below the {limit} limit."
);
}
TemplateExpansionError::ExpansionLengthExceeded { max_len } => {
return format!(
"template expansion error in {file}: {source}. Fix: remove exponential variable growth so expansion stays below {max_len} bytes."
);
}
TemplateExpansionError::UnknownEncoding { transform } => {
return format!(
"template expansion error in {file}: {source}. Fix: register `{transform}` via `PayloadDb::register_encoding`, or replace the reference with a known built-in (raw, url_encode, html_encode, double_url_encode, hex_encode, unicode_escape, base64)."
);
}
};
format!("template expansion error in {file}: {source}. {fix}")
}
fn grammar_validation_message(file: &str, issues: &[GrammarIssue]) -> String {
let issue_count = issues.len();
let summary = issues.first().map_or_else(
|| "unknown validation failure".to_string(),
|issue| format!("{}: {}", issue.level, issue.message),
);
format!(
"grammar validation error in {file}: {summary}. Fix: resolve the reported validation issue{plural} before loading the grammar.",
plural = if issue_count == 1 { "" } else { "s" }
)
}
}
#[doc = include_str!("../README.md")]
mod readme_doctests {}