1#![warn(clippy::pedantic)]
2#![cfg_attr(
3 not(test),
4 deny(
5 clippy::unwrap_used,
6 clippy::expect_used,
7 clippy::todo,
8 clippy::unimplemented,
9 clippy::panic
10 )
11)]
12#![allow(clippy::module_name_repetitions)]
13#![forbid(unsafe_code)]
78#![warn(missing_docs)]
79
80pub mod config;
82mod encoding;
83pub mod grammar;
84mod loader;
85mod mutate;
86#[cfg(feature = "exploits")]
87pub mod ports;
88pub mod validate;
90
91pub use config::{parse_marker_position, PayloadConfigFile};
92pub use encoding::{apply_encoding, BuiltinEncoding, CustomEncoder, Encoder, EncodingError};
93pub use grammar::{
94 depluralize, expand, expand_template, Context, Encoding, Grammar, GrammarMeta, Technique,
95 TemplateExpansionError, Variable,
96};
97pub use loader::PayloadDb;
98pub use mutate::{
99 mutate_all, mutate_case, mutate_encoding_mix, mutate_html, mutate_null_bytes,
100 mutate_sql_comments, mutate_unicode, mutate_whitespace,
101};
102use serde::{Deserialize, Serialize};
103use std::collections::BTreeMap;
104use std::hash::{Hash, Hasher};
105pub use validate::{validate, GrammarIssue, IssueLevel};
106
107pub trait PayloadSource {
126 fn payloads(&mut self, category: &str) -> &[Payload];
130
131 fn categories(&self) -> Vec<&str>;
133
134 fn payload_count(&self) -> usize;
136}
137
138#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct StaticPayloads {
171 payloads: Vec<Payload>,
172 #[serde(skip)]
173 category_ranges: BTreeMap<String, std::ops::Range<usize>>,
174}
175
176impl StaticPayloads {
177 pub fn new(mut payloads: Vec<Payload>) -> Self {
200 sort_payloads_by_category(&mut payloads);
201 Self {
202 category_ranges: build_category_ranges(&payloads),
203 payloads,
204 }
205 }
206
207 pub fn add(&mut self, payload: Payload) {
229 self.payloads.push(payload);
230 sort_payloads_by_category(&mut self.payloads);
231 self.category_ranges = build_category_ranges(&self.payloads);
232 }
233
234 pub fn all_payloads(&self) -> &[Payload] {
244 &self.payloads
245 }
246
247 pub fn iter(&self) -> impl Iterator<Item = &Payload> {
257 self.payloads.iter()
258 }
259
260 pub fn iter_category<'a>(
281 &'a self,
282 category: &'a str,
283 ) -> impl Iterator<Item = &'a Payload> + 'a {
284 self.payloads
285 .iter()
286 .filter(move |payload| payload.category == category)
287 }
288}
289
290impl std::fmt::Display for StaticPayloads {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 write!(f, "StaticPayloads(count={})", self.payloads.len())
293 }
294}
295
296impl From<Vec<Payload>> for StaticPayloads {
297 fn from(payloads: Vec<Payload>) -> Self {
298 Self::new(payloads)
299 }
300}
301
302impl PayloadSource for StaticPayloads {
303 fn payloads(&mut self, category: &str) -> &[Payload] {
304 if self.category_ranges.is_empty() && !self.payloads.is_empty() {
305 sort_payloads_by_category(&mut self.payloads);
306 self.category_ranges = build_category_ranges(&self.payloads);
307 }
308 self.category_ranges
309 .get(category)
310 .map_or(&[], |range| &self.payloads[range.clone()])
311 }
312
313 fn categories(&self) -> Vec<&str> {
314 use std::collections::HashSet;
315 let mut seen = HashSet::new();
316 self.payloads
317 .iter()
318 .filter_map(|p| {
319 if seen.insert(p.category.clone()) {
320 Some(p.category.as_str())
321 } else {
322 None
323 }
324 })
325 .collect()
326 }
327
328 fn payload_count(&self) -> usize {
329 self.payloads.len()
330 }
331}
332
333fn build_category_ranges(payloads: &[Payload]) -> BTreeMap<String, std::ops::Range<usize>> {
334 let mut ranges = BTreeMap::new();
335 let mut start = 0;
336 while start < payloads.len() {
337 let category = payloads[start].category.clone();
338 let mut end = start + 1;
339 while end < payloads.len() && payloads[end].category == category {
340 end += 1;
341 }
342 ranges.insert(category, start..end);
343 start = end;
344 }
345 ranges
346}
347
348#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
353pub struct PayloadConfig {
354 pub max_per_category: usize,
356 pub deduplicate: bool,
358 pub marker_prefix: String,
360 pub exclude_categories: Vec<String>,
362 pub include_categories: Vec<String>,
364 pub target_runtime: Option<Vec<String>>,
366 pub marker_position: MarkerPosition,
368 pub max_payload_length: usize,
370}
371
372impl PayloadConfig {
373 pub fn builder() -> PayloadConfigBuilder {
383 PayloadConfigBuilder::default()
384 }
385
386 pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, PayloadError> {
403 PayloadConfigFile::load(path)?.into_config()
404 }
405
406 pub fn from_toml(toml_str: &str, source: impl Into<String>) -> Result<Self, PayloadError> {
419 PayloadConfigFile::from_toml(toml_str, source.into())?.into_config()
420 }
421}
422
423impl std::fmt::Display for PayloadConfig {
424 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425 write!(
426 f,
427 "PayloadConfig(max_per_category={}, deduplicate={}, marker_position={})",
428 self.max_per_category, self.deduplicate, self.marker_position
429 )
430 }
431}
432
433impl Default for PayloadConfig {
434 fn default() -> Self {
435 Self {
436 max_per_category: 0,
437 deduplicate: true,
438 marker_prefix: "SLN".into(),
439 exclude_categories: Vec::new(),
440 include_categories: Vec::new(),
441 target_runtime: None,
442 marker_position: MarkerPosition::Prefix,
443 max_payload_length: 100_000,
444 }
445 }
446}
447
448#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
453#[non_exhaustive]
454pub enum MarkerPosition {
455 Prefix,
457 Suffix,
459 Inline,
461 Replace(String),
463}
464
465impl std::fmt::Display for MarkerPosition {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 match self {
468 Self::Prefix => f.write_str("prefix"),
469 Self::Suffix => f.write_str("suffix"),
470 Self::Inline => f.write_str("inline"),
471 Self::Replace(value) => write!(f, "replace:{value}"),
472 }
473 }
474}
475
476#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
481pub struct PayloadConfigBuilder {
482 config: PayloadConfig,
483}
484
485impl std::fmt::Display for PayloadConfigBuilder {
486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 write!(f, "PayloadConfigBuilder({})", self.config)
488 }
489}
490
491impl PayloadConfigBuilder {
492 pub fn max_per_category(mut self, max_per_category: usize) -> Self {
494 self.config.max_per_category = max_per_category;
495 self
496 }
497
498 pub fn deduplicate(mut self, deduplicate: bool) -> Self {
500 self.config.deduplicate = deduplicate;
501 self
502 }
503
504 pub fn marker_prefix(mut self, marker_prefix: impl Into<String>) -> Self {
506 self.config.marker_prefix = marker_prefix.into();
507 self
508 }
509
510 pub fn exclude_categories(mut self, exclude_categories: Vec<String>) -> Self {
512 self.config.exclude_categories = exclude_categories;
513 self
514 }
515
516 pub fn include_categories(mut self, include_categories: Vec<String>) -> Self {
518 self.config.include_categories = include_categories;
519 self
520 }
521
522 pub fn target_runtime(mut self, target_runtime: Option<Vec<String>>) -> Self {
524 self.config.target_runtime = target_runtime;
525 self
526 }
527
528 pub fn marker_position(mut self, marker_position: MarkerPosition) -> Self {
530 self.config.marker_position = marker_position;
531 self
532 }
533
534 pub fn max_payload_length(mut self, max_payload_length: usize) -> Self {
536 self.config.max_payload_length = max_payload_length;
537 self
538 }
539
540 pub fn build(self) -> PayloadConfig {
550 self.config
551 }
552}
553
554fn sort_payloads_by_category(payloads: &mut [Payload]) {
555 payloads.sort_by(|left, right| {
556 left.category
557 .cmp(&right.category)
558 .then_with(|| left.technique.cmp(&right.technique))
559 .then_with(|| left.context.cmp(&right.context))
560 .then_with(|| left.encoding.cmp(&right.encoding))
561 .then_with(|| left.text.cmp(&right.text))
562 });
563}
564
565#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct Payload {
571 pub text: String,
573 pub category: String,
575 pub technique: String,
577 pub context: String,
579 pub encoding: String,
581 pub cwe: Option<String>,
583 pub severity: Option<String>,
585 pub confidence: f64,
587 pub expected_pattern: Option<String>,
589 #[serde(default)]
591 pub target_media_type: Option<String>,
592}
593
594impl PartialEq for Payload {
595 fn eq(&self, other: &Self) -> bool {
596 self.text == other.text
597 && self.category == other.category
598 && self.technique == other.technique
599 && self.context == other.context
600 && self.encoding == other.encoding
601 && self.cwe == other.cwe
602 && self.severity == other.severity
603 && self.confidence.to_bits() == other.confidence.to_bits()
604 && self.expected_pattern == other.expected_pattern
605 && self.target_media_type == other.target_media_type
606 }
607}
608
609impl Default for Payload {
610 fn default() -> Self {
611 Self {
612 text: String::new(),
613 category: String::new(),
614 technique: String::new(),
615 context: String::new(),
616 encoding: "raw".to_string(),
617 cwe: None,
618 severity: None,
619 confidence: 1.0,
620 expected_pattern: None,
621 target_media_type: None,
622 }
623 }
624}
625
626impl Eq for Payload {}
627
628impl Hash for Payload {
629 fn hash<H: Hasher>(&self, state: &mut H) {
630 self.text.hash(state);
631 self.category.hash(state);
632 self.technique.hash(state);
633 self.context.hash(state);
634 self.encoding.hash(state);
635 self.cwe.hash(state);
636 self.severity.hash(state);
637 self.confidence.to_bits().hash(state);
638 self.expected_pattern.hash(state);
639 self.target_media_type.hash(state);
640 }
641}
642
643impl std::fmt::Display for Payload {
644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645 write!(
646 f,
647 "{}:{}:{}:{}",
648 self.category, self.technique, self.context, self.text
649 )
650 }
651}
652
653#[derive(Debug, thiserror::Error)]
658#[non_exhaustive]
659pub enum PayloadError {
660 #[error("{0}. Fix: verify the file or directory exists and that the current process has permission to read it.")]
662 Io(#[from] std::io::Error),
663 #[error("{message}", message = Self::config_parse_message(file, source))]
665 ConfigParse {
666 file: String,
668 source: Box<toml::de::Error>,
670 },
671 #[error("{message}", message = Self::grammar_parse_message(file, source))]
673 GrammarParse {
674 file: String,
676 source: Box<toml::de::Error>,
678 },
679 #[error("{message}", message = Self::grammar_validation_message(file, issues))]
681 GrammarValidation {
682 file: String,
684 issues: Vec<GrammarIssue>,
686 },
687 #[error("{message}", message = Self::template_expansion_message(file, source))]
689 TemplateExpansion {
690 file: String,
692 source: TemplateExpansionError,
694 },
695 #[error("path '{0}' is not a directory. Fix: pass a directory that contains `.toml` grammar files or update `grammar_dirs` in your config.")]
697 NotADirectory(String),
698 #[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`.")]
700 ConcurrentLoad,
701 #[error("invalid configuration value: {0}. Fix: check your config file against the supported options.")]
703 InvalidConfig(String),
704}
705
706impl Serialize for PayloadError {
707 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
708 where
709 S: serde::Serializer,
710 {
711 use serde::ser::SerializeMap;
712
713 let mut map = serializer.serialize_map(Some(2))?;
714 map.serialize_entry("kind", self.kind())?;
715 map.serialize_entry("message", &self.to_string())?;
716 map.end()
717 }
718}
719
720impl<'de> Deserialize<'de> for PayloadError {
721 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
722 where
723 D: serde::Deserializer<'de>,
724 {
725 #[derive(Deserialize)]
726 struct PayloadErrorWire {
727 kind: String,
728 message: String,
729 }
730
731 let wire = PayloadErrorWire::deserialize(deserializer)?;
732 Ok(Self::Io(std::io::Error::other(format!(
733 "[{}] {}",
734 wire.kind, wire.message
735 ))))
736 }
737}
738
739impl PayloadError {
740 fn kind(&self) -> &'static str {
741 match self {
742 Self::Io(_) => "io",
743 Self::ConfigParse { .. } => "config_parse",
744 Self::GrammarParse { .. } => "grammar_parse",
745 Self::GrammarValidation { .. } => "grammar_validation",
746 Self::TemplateExpansion { .. } => "template_expansion",
747 Self::NotADirectory(_) => "not_a_directory",
748 Self::ConcurrentLoad => "concurrent_load",
749 Self::InvalidConfig(_) => "invalid_config",
750 }
751 }
752
753 fn config_parse_message(file: &str, source: &toml::de::Error) -> String {
754 format!(
755 "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\"]`."
756 )
757 }
758
759 fn grammar_parse_message(file: &str, source: &toml::de::Error) -> String {
760 let detail = source.to_string();
761 let fix = if detail.contains("missing field `grammar`") {
762 "Fix: add a `[grammar]` table with at least `name` and `sink_category`."
763 } else if detail.contains("missing field `name`")
764 || detail.contains("missing field `sink_category`")
765 {
766 "Fix: every grammar needs a `[grammar]` section with both `name` and `sink_category` fields."
767 } else if detail.contains("missing field `template`") {
768 "Fix: every `[[techniques]]` entry needs a `name` and `template`."
769 } else {
770 "Fix: make the file valid TOML and include a `[grammar]` section plus at least one `[[techniques]]` entry."
771 };
772
773 format!("grammar parse error in {file}: {detail}. {fix}")
774 }
775
776 fn template_expansion_message(file: &str, source: &TemplateExpansionError) -> String {
777 let fix = match source {
778 TemplateExpansionError::UnclosedBrace { .. } => {
779 "Fix: close every `{placeholder}` with a matching `}` and escape literal braces by leaving them outside placeholder syntax."
780 }
781 TemplateExpansionError::RecursionLimitExceeded { max_depth } => {
782 return format!(
783 "template expansion error in {file}: {source}. Fix: remove circular or self-referential variables so expansion stays below the recursion limit of {max_depth}."
784 );
785 }
786 TemplateExpansionError::PayloadLimitExceeded { limit } => {
787 return format!(
788 "template expansion error in {file}: {source}. Fix: reduce Cartesian product size (contexts x techniques x variables) to stay below the {limit} limit."
789 );
790 }
791 TemplateExpansionError::ExpansionLengthExceeded { max_len } => {
792 return format!(
793 "template expansion error in {file}: {source}. Fix: remove exponential variable growth so expansion stays below {max_len} bytes."
794 );
795 }
796 TemplateExpansionError::UnknownEncoding { transform } => {
797 return format!(
798 "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)."
799 );
800 }
801 };
802
803 format!("template expansion error in {file}: {source}. {fix}")
804 }
805
806 fn grammar_validation_message(file: &str, issues: &[GrammarIssue]) -> String {
807 let issue_count = issues.len();
808 let summary = issues.first().map_or_else(
809 || "unknown validation failure".to_string(),
810 |issue| format!("{}: {}", issue.level, issue.message),
811 );
812 format!(
813 "grammar validation error in {file}: {summary}. Fix: resolve the reported validation issue{plural} before loading the grammar.",
814 plural = if issue_count == 1 { "" } else { "s" }
815 )
816 }
817}
818
819#[doc = include_str!("../README.md")]
822mod readme_doctests {}