Skip to main content

mockforge_bench/
wafbench.rs

1//! WAFBench YAML parser for importing CRS (Core Rule Set) attack patterns
2//!
3//! This module parses WAFBench YAML test files from the Microsoft WAFBench project
4//! (<https://github.com/microsoft/WAFBench>) and converts them into security test payloads
5//! compatible with MockForge's security testing framework.
6//!
7//! # WAFBench YAML Format
8//!
9//! WAFBench test files follow this structure:
10//! ```yaml
11//! meta:
12//!   author: "author-name"
13//!   description: "Tests for rule XXXXXX"
14//!   enabled: true
15//!   name: "XXXXXX.yaml"
16//!
17//! tests:
18//!   - desc: "Attack scenario description"
19//!     test_title: "XXXXXX-N"
20//!     stages:
21//!       - input:
22//!           dest_addr: "127.0.0.1"
23//!           headers:
24//!             Host: "localhost"
25//!             User-Agent: "Mozilla/5.0"
26//!           method: "GET"
27//!           port: 80
28//!           uri: "/path?param=<script>alert(1)</script>"
29//!         output:
30//!           status: [200, 403, 404]
31//! ```
32//!
33//! # Usage
34//!
35//! ```bash
36//! mockforge bench spec.yaml --wafbench-dir ./wafbench/REQUEST-941-*
37//! ```
38
39use crate::error::{BenchError, Result};
40use crate::security_payloads::{
41    PayloadLocation as SecurityPayloadLocation, SecurityCategory, SecurityPayload,
42};
43use glob::glob;
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::path::Path;
47
48/// WAFBench test file metadata
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct WafBenchMeta {
51    /// Author of the test file
52    pub author: Option<String>,
53    /// Description of what the tests cover
54    pub description: Option<String>,
55    /// Whether the tests are enabled
56    #[serde(default = "default_enabled")]
57    pub enabled: bool,
58    /// Name of the test file
59    pub name: Option<String>,
60}
61
62fn default_enabled() -> bool {
63    true
64}
65
66/// A single WAFBench test case
67#[derive(Debug, Clone, Deserialize, Serialize)]
68pub struct WafBenchTest {
69    /// Description of the attack scenario
70    pub desc: Option<String>,
71    /// Unique test identifier (e.g., "941100-1")
72    pub test_title: String,
73    /// Test stages (request/response pairs)
74    #[serde(default)]
75    pub stages: Vec<WafBenchStage>,
76}
77
78/// A test stage containing input (request) and expected output (response)
79/// Supports both direct format and CRS v3.3 format with nested `stage:` wrapper
80#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct WafBenchStage {
82    /// The request configuration (direct format)
83    pub input: Option<WafBenchInput>,
84    /// Expected response (direct format)
85    pub output: Option<WafBenchOutput>,
86    /// Nested stage for CRS v3.3 format (stage: { input: ..., output: ... })
87    pub stage: Option<WafBenchStageInner>,
88}
89
90/// Inner stage structure for CRS v3.3 format
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct WafBenchStageInner {
93    /// The request configuration
94    pub input: WafBenchInput,
95    /// Expected response
96    pub output: Option<WafBenchOutput>,
97}
98
99impl WafBenchStage {
100    /// Get the input from either direct or nested format
101    pub fn get_input(&self) -> Option<&WafBenchInput> {
102        // Prefer nested stage format (CRS v3.3), fall back to direct format
103        if let Some(stage) = &self.stage {
104            Some(&stage.input)
105        } else {
106            self.input.as_ref()
107        }
108    }
109
110    /// Get the output from either direct or nested format
111    pub fn get_output(&self) -> Option<&WafBenchOutput> {
112        // Prefer nested stage format (CRS v3.3), fall back to direct format
113        if let Some(stage) = &self.stage {
114            stage.output.as_ref()
115        } else {
116            self.output.as_ref()
117        }
118    }
119}
120
121/// Request configuration for a WAFBench test
122#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct WafBenchInput {
124    /// Target address
125    pub dest_addr: Option<String>,
126    /// HTTP headers
127    #[serde(default)]
128    pub headers: HashMap<String, String>,
129    /// HTTP method
130    #[serde(default = "default_method")]
131    pub method: String,
132    /// Target port
133    #[serde(default = "default_port")]
134    pub port: u16,
135    /// Request URI (may contain attack payloads)
136    pub uri: Option<String>,
137    /// Request body data
138    pub data: Option<String>,
139    /// Protocol version
140    pub version: Option<String>,
141}
142
143fn default_method() -> String {
144    "GET".to_string()
145}
146
147fn default_port() -> u16 {
148    80
149}
150
151/// Expected response for a WAFBench test
152#[derive(Debug, Clone, Deserialize, Serialize)]
153pub struct WafBenchOutput {
154    /// Expected HTTP status codes (any match is valid)
155    #[serde(default)]
156    pub status: Vec<u16>,
157    /// Expected response headers
158    #[serde(default)]
159    pub response_headers: HashMap<String, String>,
160    /// Log contains patterns (can be string or array in different formats)
161    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
162    pub log_contains: Vec<String>,
163    /// Log does not contain patterns (can be string or array in different formats)
164    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
165    pub no_log_contains: Vec<String>,
166}
167
168/// Deserialize a field that can be either a single string or a Vec of strings
169fn deserialize_string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
170where
171    D: serde::Deserializer<'de>,
172{
173    use serde::de::{self, Visitor};
174
175    struct StringOrVec;
176
177    impl<'de> Visitor<'de> for StringOrVec {
178        type Value = Vec<String>;
179
180        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
181            formatter.write_str("string or array of strings")
182        }
183
184        fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
185        where
186            E: de::Error,
187        {
188            Ok(vec![value.to_string()])
189        }
190
191        fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
192        where
193            E: de::Error,
194        {
195            Ok(vec![value])
196        }
197
198        fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
199        where
200            A: de::SeqAccess<'de>,
201        {
202            let mut vec = Vec::new();
203            while let Some(value) = seq.next_element::<String>()? {
204                vec.push(value);
205            }
206            Ok(vec)
207        }
208
209        fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
210        where
211            E: de::Error,
212        {
213            Ok(Vec::new())
214        }
215
216        fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
217        where
218            E: de::Error,
219        {
220            Ok(Vec::new())
221        }
222    }
223
224    deserializer.deserialize_any(StringOrVec)
225}
226
227/// A single case in the SIMPLE traffic format (#987).
228///
229/// The WAFBench document shape (`meta` + `tests` + `stages` + `input`/`output`)
230/// exists to carry CRS rule IDs and provenance. A hand-written or LLM-generated
231/// traffic file has none of that, and asking authors to synthesise it buys
232/// nothing. This is the shape people actually produce:
233///
234/// ```yaml
235/// - title: utf-7 charset blocked
236///   request:
237///     method: POST
238///     uri: /graphql
239///     headers:
240///       Content-Type: application/json; charset=utf-7
241///     body: '...'
242///   expected: 403
243/// ```
244///
245/// Reported by Srikanth on #79: `--wafbench-dir` rejected exactly this with
246/// `invalid type: sequence, expected struct WafBenchFile`.
247#[derive(Debug, Clone, Deserialize, Serialize)]
248pub struct SimpleTrafficCase {
249    /// Human-readable name for the case.
250    pub title: Option<String>,
251    /// The request to send.
252    pub request: SimpleTrafficRequest,
253    /// Expected status. Accepts `403`, `[403, 406]`, or `{ status: 403 }`.
254    pub expected: Option<serde_yaml::Value>,
255    /// WAFBench's baseline marker: send this case with the rule under test
256    /// disabled. mockforge cannot honour it, because disabling a rule is
257    /// WAF-side configuration and not something a traffic generator controls.
258    /// Parsed only so the case can be reported and skipped instead of being
259    /// silently sent as an unfalsifiable duplicate (#997).
260    #[serde(default)]
261    pub omit_rule: Option<bool>,
262}
263
264/// The request half of a [`SimpleTrafficCase`].
265#[derive(Debug, Clone, Deserialize, Serialize)]
266pub struct SimpleTrafficRequest {
267    /// HTTP method. Defaults to GET, matching the WAFBench input default.
268    #[serde(default = "default_method")]
269    pub method: String,
270    /// Request URI, including any payload in the query string.
271    pub uri: Option<String>,
272    /// Request headers.
273    #[serde(default)]
274    pub headers: HashMap<String, String>,
275    /// Request body. Named `body` here rather than WAFBench's `data`, because
276    /// `body` is what generators emit.
277    pub body: Option<String>,
278}
279
280/// Pull expected status codes out of the permissive `expected` field.
281///
282/// Accepts a bare integer (`403`), a sequence (`[403, 406]`), or a mapping with
283/// a `status` key holding either. Anything else yields no expectation rather
284/// than an error: an unparsable expectation should not cost the user the case,
285/// since the request itself is still perfectly valid traffic to send.
286fn extract_expected_statuses(value: Option<&serde_yaml::Value>) -> Vec<u16> {
287    fn as_status(v: &serde_yaml::Value) -> Option<u16> {
288        v.as_u64().and_then(|n| u16::try_from(n).ok())
289    }
290
291    let Some(value) = value else {
292        return Vec::new();
293    };
294
295    if let Some(one) = as_status(value) {
296        return vec![one];
297    }
298    if let Some(seq) = value.as_sequence() {
299        return seq.iter().filter_map(as_status).collect();
300    }
301    if let Some(map) = value.as_mapping() {
302        if let Some(status) = map.get(serde_yaml::Value::String("status".to_string())) {
303            if let Some(one) = as_status(status) {
304                return vec![one];
305            }
306            if let Some(seq) = status.as_sequence() {
307                return seq.iter().filter_map(as_status).collect();
308            }
309        }
310    }
311    Vec::new()
312}
313
314impl SimpleTrafficCase {
315    /// Convert into the internal WAFBench representation so the rest of the
316    /// security-test pipeline is untouched. The mapping is 1:1 except for
317    /// `body` -> `data` and the synthesised title.
318    fn into_wafbench_test(self, index: usize) -> WafBenchTest {
319        let statuses = extract_expected_statuses(self.expected.as_ref());
320        let title = self.title.unwrap_or_else(|| format!("case-{}", index + 1));
321
322        WafBenchTest {
323            desc: Some(title.clone()),
324            test_title: title,
325            stages: vec![WafBenchStage {
326                input: Some(WafBenchInput {
327                    dest_addr: None,
328                    headers: self.request.headers,
329                    method: self.request.method,
330                    port: default_port(),
331                    uri: self.request.uri,
332                    data: self.request.body,
333                    version: None,
334                }),
335                output: Some(WafBenchOutput {
336                    status: statuses,
337                    response_headers: HashMap::new(),
338                    log_contains: Vec::new(),
339                    no_log_contains: Vec::new(),
340                }),
341                stage: None,
342            }],
343        }
344    }
345}
346
347/// Turn traffic cases into request templates that are sent EXACTLY as written
348/// (#994).
349///
350/// The normal WAFBench path treats a case's `uri` as "a CRS attack string
351/// hidden in a query parameter": `extract_uri_payload` keeps only the FIRST
352/// parameter's value, discards the path, and the survivor is re-attached to a
353/// spec-derived endpoint as `?test=<payload>`. That is right for CRS files,
354/// where one attack string is the whole test and fuzzing it across a real API
355/// is the point.
356///
357/// It is wrong when the RELATIONSHIP BETWEEN PARAMETERS is the test. Reported
358/// by Srikanth on #79: a rule chained on `ARGS:redirect_uri` and
359/// `ARGS:response_type` could never fire, because
360///
361///   /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil...
362///
363/// collapsed to the bare string `totally-unsupported`, sent to a spec endpoint
364/// under the parameter name `test`. `redirect_uri` never reached the wire, so a
365/// green run looked like the WAF passing when nothing had been exercised.
366///
367/// Verbatim mode does none of that. The raw `uri` becomes the request path with
368/// `query_params` left EMPTY on purpose: `RequestTemplate::generate_path`
369/// rejoins query params as `k=v`, which would re-encode values that are already
370/// percent-encoded. Keeping the URI whole in `path` preserves it byte for byte,
371/// which matters when the encoding IS the payload.
372pub fn traffic_cases_to_templates(
373    cases: &[WafBenchTestCase],
374) -> Vec<crate::request_gen::RequestTemplate> {
375    use crate::request_gen::RequestTemplate;
376    use crate::spec_parser::ApiOperation;
377
378    let mut templates = Vec::new();
379
380    for case in cases {
381        // The Uri payload carries the RAW uri: extraction into a bare attack
382        // string only happens later, when building SecurityPayloads (see
383        // `extract_uri_payload`). Verbatim mode reads it before that.
384        let Some(uri) = case
385            .payloads
386            .iter()
387            .find(|p| p.location == PayloadLocation::Uri)
388            .map(|p| p.value.clone())
389            .filter(|u| !u.is_empty())
390        else {
391            continue;
392        };
393
394        let headers: HashMap<String, String> = case
395            .payloads
396            .iter()
397            .filter(|p| p.location == PayloadLocation::Header)
398            .filter_map(|p| p.header_name.clone().map(|n| (n, p.value.clone())))
399            .collect();
400
401        let body = case.payloads.iter().find(|p| p.location == PayloadLocation::Body).map(|p| {
402            // Keep the body as written. Parse as JSON only so a JSON body
403            // renders as JSON rather than a quoted string; anything else
404            // (form data, raw XML, a deliberately malformed payload) passes
405            // through untouched.
406            serde_json::from_str::<serde_json::Value>(&p.value)
407                .unwrap_or_else(|_| serde_json::Value::String(p.value.clone()))
408        });
409
410        templates.push(RequestTemplate {
411            operation: ApiOperation {
412                method: case.method.clone(),
413                path: uri,
414                operation: Default::default(),
415                operation_id: Some(case.description.clone()),
416            },
417            path_params: HashMap::new(),
418            // Left EMPTY on purpose. `RequestTemplate::generate_path` rejoins
419            // query params as `k=v`, which would re-encode values that are
420            // already percent-encoded. Keeping the whole URI in `path`
421            // preserves it byte for byte, and the encoding is often the payload.
422            query_params: HashMap::new(),
423            headers,
424            body,
425        });
426    }
427
428    templates
429}
430
431/// Parse a traffic file in either supported shape.
432///
433/// Tries the WAFBench document first, then the simple sequence. On failure the
434/// error names BOTH accepted shapes: the previous message reported only
435/// `invalid type: sequence, expected struct WafBenchFile`, which is accurate
436/// about what failed but silent about what would have worked.
437pub fn parse_traffic_file(content: &str, source: &str) -> Result<WafBenchFile> {
438    match serde_yaml::from_str::<WafBenchFile>(content) {
439        Ok(file) => Ok(file),
440        Err(wafbench_err) => match serde_yaml::from_str::<Vec<SimpleTrafficCase>>(content) {
441            Ok(cases) => {
442                let parsed = cases.len();
443                let (omitted, cases): (Vec<_>, Vec<_>) =
444                    cases.into_iter().partition(|c| c.omit_rule == Some(true));
445
446                for case in &omitted {
447                    tracing::warn!(
448                        "{source}: skipping `{}` -- it sets `omit_rule: true`, which asks for the \
449                         request to be sent with the rule under test disabled. mockforge sends \
450                         traffic; it cannot toggle your WAF's rules. Sending it anyway would put \
451                         a byte-identical request on the wire as its non-omitted twin while \
452                         asserting the opposite status, so exactly one of the pair would always \
453                         fail regardless of whether the rule works. Run the same file against a \
454                         WAF configuration with that rule disabled to get the baseline.",
455                        case.title.as_deref().unwrap_or("<untitled>")
456                    );
457                }
458
459                tracing::info!(
460                    "{source}: parsed {parsed} case(s) in the simple request/expected format{}",
461                    if omitted.is_empty() {
462                        String::new()
463                    } else {
464                        format!(
465                            "; {} skipped for `omit_rule: true`, {} will be sent",
466                            omitted.len(),
467                            cases.len()
468                        )
469                    }
470                );
471                Ok(WafBenchFile {
472                    meta: WafBenchMeta {
473                        author: None,
474                        description: Some(format!("simple traffic file: {source}")),
475                        enabled: true,
476                        name: Some(source.to_string()),
477                    },
478                    tests: cases
479                        .into_iter()
480                        .enumerate()
481                        .map(|(i, c)| c.into_wafbench_test(i))
482                        .collect(),
483                    // #79: Srikanth wants omitted cases in the per-file
484                    // breakdown, not only in a tracing line he may miss.
485                    omitted_count: omitted.len(),
486                })
487            }
488            Err(simple_err) => Err(BenchError::Other(format!(
489                "Failed to parse traffic file {source}. Two shapes are accepted:\n  \
490                 (1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n  \
491                 (2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
492            ))),
493        },
494    }
495}
496
497/// Complete WAFBench test file structure
498#[derive(Debug, Clone, Deserialize, Serialize)]
499pub struct WafBenchFile {
500    /// Test file metadata
501    pub meta: WafBenchMeta,
502    /// Test cases
503    #[serde(default)]
504    pub tests: Vec<WafBenchTest>,
505    /// Cases dropped for `omit_rule: true`. Not in the YAML; filled by
506    /// `parse_traffic_file` so the per-file breakdown can report them.
507    #[serde(default, skip)]
508    pub omitted_count: usize,
509}
510
511/// A parsed WAFBench test case ready for use in security testing
512#[derive(Debug, Clone)]
513pub struct WafBenchTestCase {
514    /// Test identifier
515    pub test_id: String,
516    /// Description
517    pub description: String,
518    /// CRS rule ID (e.g., 941100)
519    pub rule_id: String,
520    /// Security category
521    pub category: SecurityCategory,
522    /// HTTP method
523    pub method: String,
524    /// Attack payloads extracted from the test
525    pub payloads: Vec<WafBenchPayload>,
526    /// Expected to be blocked (403)
527    pub expects_block: bool,
528}
529
530/// A specific payload from a WAFBench test
531#[derive(Debug, Clone)]
532pub struct WafBenchPayload {
533    /// The payload location (uri, header, body)
534    pub location: PayloadLocation,
535    /// The actual payload string
536    pub value: String,
537    /// Header name if location is Header
538    pub header_name: Option<String>,
539}
540
541/// Where the payload is injected
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum PayloadLocation {
544    /// Payload in URI/query string
545    Uri,
546    /// Payload in HTTP header
547    Header,
548    /// Payload in request body
549    Body,
550}
551
552impl std::fmt::Display for PayloadLocation {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        match self {
555            Self::Uri => write!(f, "uri"),
556            Self::Header => write!(f, "header"),
557            Self::Body => write!(f, "body"),
558        }
559    }
560}
561
562/// WAFBench loader and parser
563pub struct WafBenchLoader {
564    /// Loaded test cases
565    test_cases: Vec<WafBenchTestCase>,
566    /// Statistics
567    stats: WafBenchStats,
568}
569
570/// Per-file counts so a multi-YAML run can say how many attacks vs
571/// baseline vs omitted cases each file contributed (#79).
572#[derive(Debug, Clone, Default)]
573pub struct TrafficFileSummary {
574    /// Path as loaded (`--wafbench-dir` entry or glob match).
575    pub file: String,
576    /// Cases that will actually be sent.
577    pub sent: usize,
578    /// Sent cases whose expected status includes 403 (treated as attacks).
579    pub attack: usize,
580    /// Sent cases whose expected status includes 200 and not 403.
581    pub normal: usize,
582    /// Cases skipped for `omit_rule: true`.
583    pub omitted: usize,
584    /// Sent cases with neither 200 nor 403 in `expected`.
585    pub other: usize,
586}
587
588/// Statistics about loaded WAFBench tests
589#[derive(Debug, Clone, Default)]
590pub struct WafBenchStats {
591    /// Number of files processed
592    pub files_processed: usize,
593    /// Number of test cases loaded
594    pub test_cases_loaded: usize,
595    /// Number of payloads extracted
596    pub payloads_extracted: usize,
597    /// Tests by category
598    pub by_category: HashMap<SecurityCategory, usize>,
599    /// Files that failed to parse
600    pub parse_errors: Vec<String>,
601    /// One row per loaded traffic file.
602    pub per_file: Vec<TrafficFileSummary>,
603}
604
605/// Classify a parsed file into the attack / normal / omitted buckets
606/// Srikanth asked for: expected 403 = attack, expected 200 = normal.
607pub fn summarize_traffic_file(file: &WafBenchFile, source: &str) -> TrafficFileSummary {
608    let mut attack = 0;
609    let mut normal = 0;
610    let mut other = 0;
611    for test in &file.tests {
612        let mut has_403 = false;
613        let mut has_200 = false;
614        for stage in &test.stages {
615            if let Some(output) = stage.get_output() {
616                has_403 |= output.status.contains(&403);
617                has_200 |= output.status.contains(&200);
618            }
619        }
620        if has_403 {
621            attack += 1;
622        } else if has_200 {
623            normal += 1;
624        } else {
625            other += 1;
626        }
627    }
628    TrafficFileSummary {
629        file: source.to_string(),
630        sent: file.tests.len(),
631        attack,
632        normal,
633        omitted: file.omitted_count,
634        other,
635    }
636}
637
638impl WafBenchLoader {
639    /// Create a new empty loader
640    pub fn new() -> Self {
641        Self {
642            test_cases: Vec::new(),
643            stats: WafBenchStats::default(),
644        }
645    }
646
647    /// Load WAFBench tests from a directory pattern (supports glob)
648    ///
649    /// # Arguments
650    /// * `pattern` - Glob pattern like `./wafbench/REQUEST-941-*` or a direct path
651    ///
652    /// # Example
653    /// ```ignore
654    /// let loader = WafBenchLoader::new();
655    /// loader.load_from_pattern("./wafbench/REQUEST-941-APPLICATION-ATTACK-XSS/**/*.yaml")?;
656    /// ```
657    pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
658        // If pattern doesn't contain wildcards, check if it's a file or directory
659        if !pattern.contains('*') && !pattern.contains('?') {
660            let path = Path::new(pattern);
661            if path.is_file() {
662                // Load single file directly
663                return self.load_file(path);
664            } else if path.is_dir() {
665                return self.load_from_directory(path);
666            } else {
667                return Err(BenchError::Other(format!(
668                    "WAFBench path does not exist: {}",
669                    pattern
670                )));
671            }
672        }
673
674        // Use glob to find matching files
675        let entries = glob(pattern).map_err(|e| {
676            BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
677        })?;
678
679        for entry in entries {
680            match entry {
681                Ok(path) => {
682                    if path.is_file()
683                        && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
684                    {
685                        if let Err(e) = self.load_file(&path) {
686                            self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
687                        }
688                    } else if path.is_dir() {
689                        if let Err(e) = self.load_from_directory(&path) {
690                            self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
691                        }
692                    }
693                }
694                Err(e) => {
695                    self.stats.parse_errors.push(format!("Glob error: {}", e));
696                }
697            }
698        }
699
700        Ok(())
701    }
702
703    /// Load WAFBench tests from a directory (recursive)
704    pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
705        if !dir.is_dir() {
706            return Err(BenchError::Other(format!(
707                "WAFBench path is not a directory: {}",
708                dir.display()
709            )));
710        }
711
712        self.load_directory_recursive(dir)?;
713        Ok(())
714    }
715
716    fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
717        let entries = std::fs::read_dir(dir)
718            .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
719
720        for entry in entries.flatten() {
721            let path = entry.path();
722            if path.is_dir() {
723                // Recurse into subdirectories
724                self.load_directory_recursive(&path)?;
725            } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
726                if let Err(e) = self.load_file(&path) {
727                    self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
728                }
729            }
730        }
731
732        Ok(())
733    }
734
735    /// Load a single WAFBench YAML file
736    pub fn load_file(&mut self, path: &Path) -> Result<()> {
737        let content = std::fs::read_to_string(path).map_err(|e| {
738            BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
739        })?;
740
741        let source = path.display().to_string();
742        let wafbench_file = parse_traffic_file(&content, &source)?;
743
744        // Skip disabled test files
745        if !wafbench_file.meta.enabled {
746            return Ok(());
747        }
748
749        self.stats.per_file.push(summarize_traffic_file(&wafbench_file, &source));
750        self.stats.files_processed += 1;
751
752        // Determine the rule category from the file path or name
753        let category = self.detect_category(path, &wafbench_file.meta);
754
755        // Parse each test case
756        for test in wafbench_file.tests {
757            if let Some(test_case) = self.parse_test_case(&test, category) {
758                self.stats.payloads_extracted += test_case.payloads.len();
759                *self.stats.by_category.entry(category).or_insert(0) += 1;
760                self.test_cases.push(test_case);
761                self.stats.test_cases_loaded += 1;
762            }
763        }
764
765        Ok(())
766    }
767
768    /// Detect the security category from the file path
769    fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
770        let path_str = path.to_string_lossy().to_uppercase();
771
772        if path_str.contains("XSS") || path_str.contains("941") {
773            SecurityCategory::Xss
774        } else if path_str.contains("SQLI") || path_str.contains("942") {
775            SecurityCategory::SqlInjection
776        } else if path_str.contains("RCE") || path_str.contains("932") {
777            SecurityCategory::CommandInjection
778        } else if path_str.contains("LFI") || path_str.contains("930") {
779            SecurityCategory::PathTraversal
780        } else if path_str.contains("LDAP") {
781            SecurityCategory::LdapInjection
782        } else if path_str.contains("XXE") || path_str.contains("XML") {
783            SecurityCategory::Xxe
784        } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
785            SecurityCategory::Ssti
786        } else {
787            // Default to XSS as it's the most common in WAFBench
788            SecurityCategory::Xss
789        }
790    }
791
792    /// Parse a single test case into our format
793    fn parse_test_case(
794        &self,
795        test: &WafBenchTest,
796        category: SecurityCategory,
797    ) -> Option<WafBenchTestCase> {
798        // Extract rule ID from test_title (e.g., "941100-1" -> "941100")
799        let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
800
801        let mut payloads = Vec::new();
802        let mut method = "GET".to_string();
803        let mut expects_block = false;
804
805        for stage in &test.stages {
806            // Get input from either direct or nested format (CRS v3.3 compatibility)
807            let Some(input) = stage.get_input() else {
808                continue;
809            };
810
811            method = input.method.clone();
812
813            // Check if this test expects a block (403)
814            if let Some(output) = stage.get_output() {
815                if output.status.contains(&403) {
816                    expects_block = true;
817                }
818            }
819
820            // Extract payload from URI — CRS test files are attack payloads by
821            // definition, so we accept all values without filtering. Previously
822            // a narrow looks_like_attack() check discarded exotic payloads like
823            // VML, VBScript, UTF-7, JSFuck, and bracket-notation XSS.
824            if let Some(uri) = &input.uri {
825                if !uri.is_empty() {
826                    payloads.push(WafBenchPayload {
827                        location: PayloadLocation::Uri,
828                        value: uri.clone(),
829                        header_name: None,
830                    });
831                }
832            }
833
834            // Extract payloads from headers
835            for (header_name, header_value) in &input.headers {
836                if !header_value.is_empty() {
837                    payloads.push(WafBenchPayload {
838                        location: PayloadLocation::Header,
839                        value: header_value.clone(),
840                        header_name: Some(header_name.clone()),
841                    });
842                }
843            }
844
845            // Extract payload from body
846            if let Some(data) = &input.data {
847                if !data.is_empty() {
848                    payloads.push(WafBenchPayload {
849                        location: PayloadLocation::Body,
850                        value: data.clone(),
851                        header_name: None,
852                    });
853                }
854            }
855        }
856
857        // If no payloads found, still include the test but with full URI as payload
858        if payloads.is_empty() {
859            if let Some(stage) = test.stages.first() {
860                if let Some(input) = stage.get_input() {
861                    if let Some(uri) = &input.uri {
862                        payloads.push(WafBenchPayload {
863                            location: PayloadLocation::Uri,
864                            value: uri.clone(),
865                            header_name: None,
866                        });
867                    }
868                }
869            }
870        }
871
872        if payloads.is_empty() {
873            return None;
874        }
875
876        let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
877
878        Some(WafBenchTestCase {
879            test_id: test.test_title.clone(),
880            description,
881            rule_id,
882            category,
883            method,
884            payloads,
885            expects_block,
886        })
887    }
888
889    /// Check if a string looks like an attack payload (used in tests)
890    #[cfg(test)]
891    fn looks_like_attack(&self, s: &str) -> bool {
892        // Common attack patterns
893        let attack_patterns = [
894            "<script",
895            "javascript:",
896            "onerror=",
897            "onload=",
898            "onclick=",
899            "onfocus=",
900            "onmouseover=",
901            "eval(",
902            "alert(",
903            "document.",
904            "window.",
905            "'--",
906            "' OR ",
907            "' AND ",
908            "1=1",
909            "UNION SELECT",
910            "CONCAT(",
911            "CHAR(",
912            "../",
913            "..\\",
914            "/etc/passwd",
915            "cmd.exe",
916            "powershell",
917            "; ls",
918            "| cat",
919            "${",
920            "{{",
921            "<%",
922            "<?",
923            "<!ENTITY",
924            "SYSTEM \"",
925        ];
926
927        let lower = s.to_lowercase();
928        attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
929    }
930
931    /// Get all loaded test cases
932    pub fn test_cases(&self) -> &[WafBenchTestCase] {
933        &self.test_cases
934    }
935
936    /// Get statistics about loaded tests
937    pub fn stats(&self) -> &WafBenchStats {
938        &self.stats
939    }
940
941    /// Decode a form-URL-encoded body payload.
942    /// Replaces `+` with space (form-encoding convention), then decodes `%XX` sequences.
943    /// Strips form field name prefix (e.g., `var=;;dd foo bar` → `;;dd foo bar`)
944    /// since JSON injection puts the value in a field, not the form key.
945    fn decode_form_encoded_body(value: &str) -> String {
946        // Replace + with space first (form-encoding convention)
947        let plus_decoded = value.replace('+', " ");
948        // Then decode %XX sequences
949        let decoded = urlencoding::decode(&plus_decoded)
950            .map(|s| s.into_owned())
951            .unwrap_or(plus_decoded);
952        // Strip form field name prefix (e.g., "var=value" → "value")
953        // CRS test data like "var=;;dd foo bar" has the form key included,
954        // but we inject only the value into a JSON field.
955        Self::strip_form_key(&decoded)
956    }
957
958    /// Strip a single leading form key from a form-encoded value.
959    /// `"var=;;dd foo bar"` → `";;dd foo bar"`
960    /// `"pay=exec (@\n"` → `"exec (@\n"`
961    /// Values without `=` or starting with special chars are returned as-is.
962    fn strip_form_key(value: &str) -> String {
963        // Only strip if the prefix before the first = looks like a form field name
964        // (alphanumeric/underscore chars). Don't strip if the = is part of the attack.
965        if let Some(eq_pos) = value.find('=') {
966            let key = &value[..eq_pos];
967            // Form field names are alphanumeric with underscores
968            if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
969                return value[eq_pos + 1..].to_string();
970            }
971        }
972        value.to_string()
973    }
974
975    /// Normalize a form body value to valid `application/x-www-form-urlencoded` format.
976    ///
977    /// CRS YAML `data` fields may be pre-encoded (`var=%3B%3Bdd+foo+bar`) or decoded
978    /// (`var=;;dd foo bar`). This function ensures the output is always properly encoded
979    /// so WAFs can parse it into ARGS and fire rules like 942432.
980    ///
981    /// Strategy: decode fully first (handling `+` as space and `%XX` sequences), then
982    /// re-encode. Pre-encoded input round-trips correctly; decoded input gets encoded.
983    fn ensure_form_encoded(value: &str) -> String {
984        value
985            .split('&')
986            .map(|pair| {
987                if let Some(eq_pos) = pair.find('=') {
988                    let key = &pair[..eq_pos];
989                    let val = &pair[eq_pos + 1..];
990                    // Decode: + → space, then %XX → chars
991                    let key_plus = key.replace('+', " ");
992                    let val_plus = val.replace('+', " ");
993                    let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
994                    let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
995                    // Re-encode with form-encoding (spaces as +)
996                    let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
997                    let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
998                    format!("{enc_key}={enc_val}")
999                } else {
1000                    // No key=value structure — encode the whole thing
1001                    let pair_plus = pair.replace('+', " ");
1002                    let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
1003                    urlencoding::encode(&decoded).replace("%20", "+").to_string()
1004                }
1005            })
1006            .collect::<Vec<_>>()
1007            .join("&")
1008    }
1009
1010    /// Convert loaded tests to SecurityPayload format for use with existing security testing
1011    pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
1012        let mut payloads = Vec::new();
1013
1014        for test_case in &self.test_cases {
1015            // Assign group_id when a test case has multiple payloads
1016            let group_id = if test_case.payloads.len() > 1 {
1017                Some(test_case.test_id.clone())
1018            } else {
1019                None
1020            };
1021
1022            for payload in &test_case.payloads {
1023                // Extract just the attack payload part if possible
1024                let payload_str = match payload.location {
1025                    PayloadLocation::Body => {
1026                        // Form-URL-decode body payloads so WAFs see the real characters
1027                        Self::decode_form_encoded_body(&payload.value)
1028                    }
1029                    PayloadLocation::Uri => {
1030                        // Extract attack payload from URI, URL-decode, strip path prefix
1031                        self.extract_uri_payload(&payload.value)
1032                    }
1033                    PayloadLocation::Header => {
1034                        // Headers are used as-is (Cookie values, User-Agent, etc.)
1035                        payload.value.clone()
1036                    }
1037                };
1038
1039                // Convert local PayloadLocation to SecurityPayloadLocation
1040                let location = match payload.location {
1041                    PayloadLocation::Uri => SecurityPayloadLocation::Uri,
1042                    PayloadLocation::Header => SecurityPayloadLocation::Header,
1043                    PayloadLocation::Body => SecurityPayloadLocation::Body,
1044                };
1045
1046                let mut sec_payload = SecurityPayload::new(
1047                    payload_str,
1048                    test_case.category,
1049                    format!(
1050                        "[WAFBench {}] {} ({})",
1051                        test_case.rule_id, test_case.description, payload.location
1052                    ),
1053                )
1054                .high_risk()
1055                .with_location(location);
1056
1057                // Add header name for header payloads
1058                if let Some(header_name) = &payload.header_name {
1059                    sec_payload = sec_payload.with_header_name(header_name.clone());
1060                }
1061
1062                // Add group ID for multi-part test cases
1063                if let Some(gid) = &group_id {
1064                    sec_payload = sec_payload.with_group_id(gid.clone());
1065                }
1066
1067                // URI payloads without '?' are path-only attacks (e.g., 942101: POST /1234%20OR%201=1)
1068                // These need to replace the request path so WAF inspects via REQUEST_FILENAME
1069                if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
1070                    sec_payload = sec_payload.with_inject_as_path();
1071                }
1072
1073                // Body payloads: normalize to valid form-encoded format for WAF ARGS parsing
1074                // (e.g., 942432: data "var=%3B%3Bdd+foo+bar" or decoded "var=;;dd foo bar")
1075                if payload.location == PayloadLocation::Body {
1076                    sec_payload = sec_payload
1077                        .with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
1078                }
1079
1080                payloads.push(sec_payload);
1081            }
1082        }
1083
1084        payloads
1085    }
1086
1087    /// Extract the actual attack payload from a URI.
1088    ///
1089    /// For URIs with query parameters (e.g., `/?var=EXECUTE%20IMMEDIATE%20%22`),
1090    /// extracts and URL-decodes the first parameter value.
1091    ///
1092    /// For path-only URIs (e.g., `/1234%20OR%201=1`), URL-decodes the path and
1093    /// strips the leading `/` which is a URI artifact, not part of the attack.
1094    fn extract_uri_payload(&self, value: &str) -> String {
1095        // If it's a URI with query params, extract the first parameter value
1096        // (URL-decoded). CRS test files put the attack in query params.
1097        if value.contains('?') {
1098            if let Some(query) = value.split('?').nth(1) {
1099                for param in query.split('&') {
1100                    if let Some(val) = param.split('=').nth(1) {
1101                        let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
1102                        if !decoded.is_empty() {
1103                            return decoded.to_string();
1104                        }
1105                    }
1106                }
1107            }
1108        }
1109
1110        // For path-only URIs, URL-decode and strip leading /
1111        // e.g., /1234%20OR%201=1 → 1234 OR 1=1
1112        let decoded = urlencoding::decode(value)
1113            .map(|s| s.into_owned())
1114            .unwrap_or_else(|_| value.to_string());
1115        let trimmed = decoded.trim_start_matches('/');
1116        if trimmed.is_empty() {
1117            // Don't return empty string for bare "/" paths
1118            return decoded;
1119        }
1120        trimmed.to_string()
1121    }
1122}
1123
1124impl Default for WafBenchLoader {
1125    fn default() -> Self {
1126        Self::new()
1127    }
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    #[test]
1135    fn test_parse_wafbench_yaml() {
1136        let yaml = r#"
1137meta:
1138  author: test
1139  description: Test XSS rules
1140  enabled: true
1141  name: test.yaml
1142
1143tests:
1144  - desc: "XSS in URI parameter"
1145    test_title: "941100-1"
1146    stages:
1147      - input:
1148          dest_addr: "127.0.0.1"
1149          headers:
1150            Host: "localhost"
1151            User-Agent: "Mozilla/5.0"
1152          method: "GET"
1153          port: 80
1154          uri: "/test?param=<script>alert(1)</script>"
1155        output:
1156          status: [403]
1157"#;
1158
1159        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1160        assert!(file.meta.enabled);
1161        assert_eq!(file.tests.len(), 1);
1162        assert_eq!(file.tests[0].test_title, "941100-1");
1163    }
1164
1165    #[test]
1166    fn test_detect_category() {
1167        let loader = WafBenchLoader::new();
1168        let meta = WafBenchMeta {
1169            author: None,
1170            description: None,
1171            enabled: true,
1172            name: None,
1173        };
1174
1175        assert_eq!(
1176            loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
1177            SecurityCategory::Xss
1178        );
1179
1180        assert_eq!(
1181            loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
1182            SecurityCategory::SqlInjection
1183        );
1184    }
1185
1186    #[test]
1187    fn test_looks_like_attack() {
1188        let loader = WafBenchLoader::new();
1189
1190        assert!(loader.looks_like_attack("<script>alert(1)</script>"));
1191        assert!(loader.looks_like_attack("' OR '1'='1"));
1192        assert!(loader.looks_like_attack("../../../etc/passwd"));
1193        assert!(loader.looks_like_attack("; ls -la"));
1194        assert!(!loader.looks_like_attack("normal text"));
1195        assert!(!loader.looks_like_attack("hello world"));
1196    }
1197
1198    #[test]
1199    fn test_extract_uri_payload_with_query_params() {
1200        let loader = WafBenchLoader::new();
1201
1202        // URI with query params: extracts and decodes the parameter value
1203        let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
1204        let payload = loader.extract_uri_payload(uri);
1205        assert_eq!(payload, "<script>alert(1)</script>");
1206    }
1207
1208    #[test]
1209    fn test_extract_uri_payload_path_only() {
1210        let loader = WafBenchLoader::new();
1211
1212        // Path-only URI: URL-decodes and strips leading /
1213        let uri = "/1234%20OR%201=1";
1214        let payload = loader.extract_uri_payload(uri);
1215        assert_eq!(payload, "1234 OR 1=1");
1216
1217        // Path with quotes and special chars
1218        let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
1219        let payload2 = loader.extract_uri_payload(uri2);
1220        assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
1221
1222        // Bare slash returns "/" (not empty)
1223        let uri3 = "/";
1224        let payload3 = loader.extract_uri_payload(uri3);
1225        assert_eq!(payload3, "/");
1226    }
1227
1228    #[test]
1229    fn test_group_id_assigned_for_multi_part_test_cases() {
1230        let yaml = r#"
1231meta:
1232  author: test
1233  description: Multi-part test
1234  enabled: true
1235  name: test.yaml
1236
1237tests:
1238  - desc: "Multi-part attack with URI and header"
1239    test_title: "942290-1"
1240    stages:
1241      - input:
1242          dest_addr: "127.0.0.1"
1243          headers:
1244            Host: "localhost"
1245            User-Agent: "ModSecurity CRS 3 Tests"
1246          method: "GET"
1247          port: 80
1248          uri: "/test?param=attack"
1249        output:
1250          status: [403]
1251"#;
1252
1253        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1254        let mut loader = WafBenchLoader::new();
1255        loader.stats.files_processed += 1;
1256
1257        let category = SecurityCategory::SqlInjection;
1258        for test in &file.tests {
1259            if let Some(test_case) = loader.parse_test_case(test, category) {
1260                loader.test_cases.push(test_case);
1261            }
1262        }
1263
1264        let payloads = loader.to_security_payloads();
1265        // This test has URI + 2 headers = 3 payloads, all should share a group_id
1266        assert!(payloads.len() >= 2, "Should have at least 2 payloads");
1267        let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
1268        assert!(
1269            group_ids.iter().all(|g| g.is_some()),
1270            "All payloads in multi-part test should have group_id"
1271        );
1272        assert!(
1273            group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
1274            "All payloads should share the same group_id"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_single_payload_no_group_id() {
1280        let yaml = r#"
1281meta:
1282  author: test
1283  description: Single payload test
1284  enabled: true
1285  name: test.yaml
1286
1287tests:
1288  - desc: "Simple XSS"
1289    test_title: "941100-1"
1290    stages:
1291      - input:
1292          dest_addr: "127.0.0.1"
1293          headers: {}
1294          method: "GET"
1295          port: 80
1296          uri: "/test?param=<script>alert(1)</script>"
1297        output:
1298          status: [403]
1299"#;
1300
1301        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1302        let mut loader = WafBenchLoader::new();
1303        loader.stats.files_processed += 1;
1304
1305        let category = SecurityCategory::Xss;
1306        for test in &file.tests {
1307            if let Some(test_case) = loader.parse_test_case(test, category) {
1308                loader.test_cases.push(test_case);
1309            }
1310        }
1311
1312        let payloads = loader.to_security_payloads();
1313        assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
1314        assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
1315    }
1316
1317    #[test]
1318    fn test_body_payload_form_url_decoded() {
1319        let yaml = r#"
1320meta:
1321  author: test
1322  description: Body payload test
1323  enabled: true
1324  name: test.yaml
1325
1326tests:
1327  - desc: "SQL injection in body"
1328    test_title: "942240-1"
1329    stages:
1330      - stage:
1331          input:
1332            dest_addr: 127.0.0.1
1333            headers:
1334              Host: localhost
1335            method: POST
1336            port: 80
1337            uri: "/"
1338            data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
1339          output:
1340            log_contains: id "942240"
1341"#;
1342
1343        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1344        let mut loader = WafBenchLoader::new();
1345        loader.stats.files_processed += 1;
1346
1347        let category = SecurityCategory::SqlInjection;
1348        for test in &file.tests {
1349            if let Some(test_case) = loader.parse_test_case(test, category) {
1350                loader.test_cases.push(test_case);
1351            }
1352        }
1353
1354        let payloads = loader.to_security_payloads();
1355        // Find the body payload
1356        let body_payload = payloads
1357            .iter()
1358            .find(|p| p.location == SecurityPayloadLocation::Body)
1359            .expect("Should have a body payload");
1360
1361        // The body payload should be form-URL-decoded
1362        assert!(
1363            body_payload.payload.contains('"'),
1364            "Body payload should have decoded %22 to double-quote: {}",
1365            body_payload.payload
1366        );
1367        assert!(
1368            body_payload.payload.contains(' '),
1369            "Body payload should have decoded + to space: {}",
1370            body_payload.payload
1371        );
1372        assert!(
1373            !body_payload.payload.contains("%22"),
1374            "Body payload should NOT contain literal %22: {}",
1375            body_payload.payload
1376        );
1377    }
1378
1379    #[test]
1380    fn test_decode_form_encoded_body() {
1381        // Basic decoding
1382        assert_eq!(
1383            WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
1384            "\" WAITFOR DELAY '\n"
1385        );
1386        assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
1387        assert_eq!(
1388            WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
1389            "no encoding needed"
1390        );
1391        // Form key stripping: var=value → value
1392        assert_eq!(
1393            WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
1394            ";;dd foo bar"
1395        );
1396        // Form key stripping: pay=exec → exec
1397        assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
1398        // No form key: starts with special char → returned as-is
1399        assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
1400    }
1401
1402    #[test]
1403    fn test_strip_form_key() {
1404        // Standard form key=value
1405        assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
1406        assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
1407        assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
1408        // No form key (starts with special char)
1409        assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
1410        // = inside attack payload, key is not alphanumeric
1411        assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
1412        // Empty input
1413        assert_eq!(WafBenchLoader::strip_form_key(""), "");
1414        // Only key, no value
1415        assert_eq!(WafBenchLoader::strip_form_key("var="), "");
1416    }
1417
1418    #[test]
1419    fn test_ensure_form_encoded() {
1420        // Pre-encoded input round-trips correctly
1421        assert_eq!(
1422            WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
1423            "var=%3B%3Bdd+foo+bar"
1424        );
1425        // Decoded input gets properly encoded
1426        assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
1427        // Multi-field form
1428        assert_eq!(
1429            WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
1430            "var=-------------------&var2=whatever"
1431        );
1432        // Already-encoded multi-field
1433        assert_eq!(
1434            WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
1435            "key=%22value%22&other=test+data"
1436        );
1437        // Decoded multi-field
1438        assert_eq!(
1439            WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
1440            "key=%22value%22&other=test+data"
1441        );
1442        // No key=value structure
1443        assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
1444        // Empty string
1445        assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
1446    }
1447
1448    #[test]
1449    fn test_uri_path_only_gets_inject_as_path() {
1450        let yaml = r#"
1451meta:
1452  author: test
1453  description: Path injection test
1454  enabled: true
1455  name: test.yaml
1456
1457tests:
1458  - desc: "Path-based SQL injection"
1459    test_title: "942101-1"
1460    stages:
1461      - stage:
1462          input:
1463            dest_addr: 127.0.0.1
1464            headers:
1465              Host: localhost
1466            method: POST
1467            port: 80
1468            uri: "/1234%20OR%201=1"
1469          output:
1470            log_contains: id "942101"
1471"#;
1472
1473        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1474        let mut loader = WafBenchLoader::new();
1475        loader.stats.files_processed += 1;
1476
1477        let category = SecurityCategory::SqlInjection;
1478        for test in &file.tests {
1479            if let Some(test_case) = loader.parse_test_case(test, category) {
1480                loader.test_cases.push(test_case);
1481            }
1482        }
1483
1484        let payloads = loader.to_security_payloads();
1485        let uri_payload = payloads
1486            .iter()
1487            .find(|p| p.location == SecurityPayloadLocation::Uri)
1488            .expect("Should have URI payload");
1489
1490        assert_eq!(
1491            uri_payload.inject_as_path,
1492            Some(true),
1493            "Path-only URI should have inject_as_path=true"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_uri_with_query_no_inject_as_path() {
1499        let yaml = r#"
1500meta:
1501  author: test
1502  description: Query param test
1503  enabled: true
1504  name: test.yaml
1505
1506tests:
1507  - desc: "Query-param SQL injection"
1508    test_title: "942100-1"
1509    stages:
1510      - stage:
1511          input:
1512            dest_addr: 127.0.0.1
1513            headers: {}
1514            method: GET
1515            port: 80
1516            uri: "/test?param=1+OR+1%3D1"
1517          output:
1518            log_contains: id "942100"
1519"#;
1520
1521        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1522        let mut loader = WafBenchLoader::new();
1523        loader.stats.files_processed += 1;
1524
1525        let category = SecurityCategory::SqlInjection;
1526        for test in &file.tests {
1527            if let Some(test_case) = loader.parse_test_case(test, category) {
1528                loader.test_cases.push(test_case);
1529            }
1530        }
1531
1532        let payloads = loader.to_security_payloads();
1533        let uri_payload = payloads
1534            .iter()
1535            .find(|p| p.location == SecurityPayloadLocation::Uri)
1536            .expect("Should have URI payload");
1537
1538        assert!(
1539            uri_payload.inject_as_path.is_none(),
1540            "URI with query params should NOT have inject_as_path"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_body_payload_gets_form_encoded_body() {
1546        let yaml = r#"
1547meta:
1548  author: test
1549  description: Form body test
1550  enabled: true
1551  name: test.yaml
1552
1553tests:
1554  - desc: "Form-encoded body attack"
1555    test_title: "942432-1"
1556    stages:
1557      - stage:
1558          input:
1559            dest_addr: 127.0.0.1
1560            headers:
1561              Host: localhost
1562            method: POST
1563            port: 80
1564            uri: "/"
1565            data: "var=%3B%3Bdd+foo+bar"
1566          output:
1567            log_contains: id "942432"
1568"#;
1569
1570        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1571        let mut loader = WafBenchLoader::new();
1572        loader.stats.files_processed += 1;
1573
1574        let category = SecurityCategory::SqlInjection;
1575        for test in &file.tests {
1576            if let Some(test_case) = loader.parse_test_case(test, category) {
1577                loader.test_cases.push(test_case);
1578            }
1579        }
1580
1581        let payloads = loader.to_security_payloads();
1582        let body_payload = payloads
1583            .iter()
1584            .find(|p| p.location == SecurityPayloadLocation::Body)
1585            .expect("Should have body payload");
1586
1587        assert!(
1588            body_payload.form_encoded_body.is_some(),
1589            "Body payload should have form_encoded_body set"
1590        );
1591        // Pre-encoded CRS YAML value round-trips through ensure_form_encoded
1592        assert_eq!(
1593            body_payload.form_encoded_body.as_deref().unwrap(),
1594            "var=%3B%3Bdd+foo+bar",
1595            "form_encoded_body should be properly URL-encoded"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_body_payload_decoded_yaml_gets_encoded() {
1601        // CRS YAML with already-decoded data value (some CRS distributions)
1602        let yaml = r#"
1603meta:
1604  author: test
1605  description: Form body test (decoded)
1606  enabled: true
1607  name: test.yaml
1608
1609tests:
1610  - desc: "Form-encoded body attack (decoded)"
1611    test_title: "942432-2"
1612    stages:
1613      - stage:
1614          input:
1615            dest_addr: 127.0.0.1
1616            headers:
1617              Host: localhost
1618            method: POST
1619            port: 80
1620            uri: "/"
1621            data: "var=;;dd foo bar"
1622          output:
1623            log_contains: id "942432"
1624"#;
1625
1626        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1627        let mut loader = WafBenchLoader::new();
1628        loader.stats.files_processed += 1;
1629
1630        let category = SecurityCategory::SqlInjection;
1631        for test in &file.tests {
1632            if let Some(test_case) = loader.parse_test_case(test, category) {
1633                loader.test_cases.push(test_case);
1634            }
1635        }
1636
1637        let payloads = loader.to_security_payloads();
1638        let body_payload = payloads
1639            .iter()
1640            .find(|p| p.location == SecurityPayloadLocation::Body)
1641            .expect("Should have body payload");
1642
1643        assert!(
1644            body_payload.form_encoded_body.is_some(),
1645            "Body payload should have form_encoded_body set"
1646        );
1647        // Decoded input must be re-encoded for WAF ARGS parsing
1648        let encoded = body_payload.form_encoded_body.as_deref().unwrap();
1649        assert!(
1650            encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
1651            "Semicolons must be URL-encoded: {encoded}"
1652        );
1653        assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
1654        assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
1655    }
1656
1657    #[test]
1658    fn test_parse_crs_v33_format() {
1659        // CRS v3.3/master uses a nested stage: wrapper
1660        let yaml = r#"
1661meta:
1662  author: "Christian Folini"
1663  description: Various SQL injection tests
1664  enabled: true
1665  name: 942100.yaml
1666
1667tests:
1668  - test_title: 942100-1
1669    desc: "Simple SQL Injection"
1670    stages:
1671      - stage:
1672          input:
1673            dest_addr: 127.0.0.1
1674            headers:
1675              Host: localhost
1676            method: POST
1677            port: 80
1678            uri: "/"
1679            data: "var=1234 OR 1=1"
1680            version: HTTP/1.0
1681          output:
1682            log_contains: id "942100"
1683"#;
1684
1685        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1686        assert!(file.meta.enabled);
1687        assert_eq!(file.tests.len(), 1);
1688        assert_eq!(file.tests[0].test_title, "942100-1");
1689
1690        // Verify we can get the input from nested format
1691        let stage = &file.tests[0].stages[0];
1692        let input = stage.get_input().expect("Should have input");
1693        assert_eq!(input.method, "POST");
1694        assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
1695    }
1696}
1697
1698#[cfg(test)]
1699mod simple_traffic_tests {
1700    use super::*;
1701
1702    /// Srikanth's actual file shape from #79 (r65 item b). Before #987 this
1703    /// failed with `invalid type: sequence, expected struct WafBenchFile`.
1704    const SRIKANTH_YAML: &str = r#"
1705- title: utf-7 charset blocked
1706  request:
1707    method: POST
1708    uri: /graphql
1709    headers:
1710      Content-Type: application/json; charset=utf-7
1711    body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
1712  expected: 403
1713- title: utf-8 charset allowed
1714  request:
1715    method: POST
1716    uri: /graphql
1717    headers:
1718      Content-Type: application/json; charset=utf-8
1719    body: '{"query":"{__typename}"}'
1720  expected: 200
1721"#;
1722
1723    #[test]
1724    fn simple_sequence_parses_and_maps_every_field() {
1725        let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
1726        assert_eq!(file.tests.len(), 2);
1727        assert!(file.meta.enabled, "synthesised meta must not disable the file");
1728
1729        let first = &file.tests[0];
1730        assert_eq!(first.test_title, "utf-7 charset blocked");
1731        let stage = &first.stages[0];
1732        let input = stage.input.as_ref().expect("input mapped");
1733        assert_eq!(input.method, "POST");
1734        assert_eq!(input.uri.as_deref(), Some("/graphql"));
1735        assert_eq!(
1736            input.headers.get("Content-Type").map(String::as_str),
1737            Some("application/json; charset=utf-7")
1738        );
1739        assert!(
1740            input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
1741            "`body` must map onto WAFBench's `data`, payload intact"
1742        );
1743        assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
1744    }
1745
1746    /// The WAFBench shape must keep working unchanged.
1747    #[test]
1748    fn wafbench_document_still_parses() {
1749        let yaml = r#"
1750meta:
1751  author: crs
1752  enabled: true
1753  name: 941100
1754tests:
1755  - test_title: 941100-1
1756    stages:
1757      - stage:
1758          input:
1759            method: GET
1760            uri: /?x=<script>alert(1)</script>
1761          output:
1762            status: [403]
1763"#;
1764        let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
1765        assert_eq!(file.tests.len(), 1);
1766        assert_eq!(file.meta.author.as_deref(), Some("crs"));
1767    }
1768
1769    /// `expected` is permissive because generators are inconsistent about it.
1770    #[test]
1771    fn expected_accepts_scalar_sequence_and_mapping() {
1772        let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
1773        assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
1774
1775        let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
1776        assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
1777
1778        let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
1779        assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
1780
1781        let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
1782        assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
1783    }
1784
1785    /// An unparsable expectation must not cost the case: the request is still
1786    /// valid traffic to send.
1787    #[test]
1788    fn unusable_expected_yields_no_statuses_not_an_error() {
1789        let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
1790        assert!(extract_expected_statuses(Some(&junk)).is_empty());
1791        assert!(extract_expected_statuses(None).is_empty());
1792
1793        let yaml = "- request:\n    uri: /a\n";
1794        let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
1795        assert_eq!(file.tests.len(), 1);
1796        assert_eq!(
1797            file.tests[0].stages[0].input.as_ref().unwrap().method,
1798            "GET",
1799            "method defaults"
1800        );
1801        assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
1802    }
1803
1804    /// The error must name BOTH accepted shapes. The old message reported only
1805    /// the struct that failed, which told the user nothing about what to write.
1806    #[test]
1807    fn parse_error_names_both_accepted_shapes() {
1808        let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
1809        assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
1810        assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
1811        assert!(err.contains("expected"), "must mention the simple shape: {err}");
1812        assert!(err.contains("request"), "must mention the simple shape: {err}");
1813    }
1814}
1815
1816#[cfg(test)]
1817mod verbatim_tests {
1818    use super::*;
1819
1820    fn load(yaml: &str) -> Vec<crate::request_gen::RequestTemplate> {
1821        let file = parse_traffic_file(yaml, "t.yaml").expect("parses");
1822        let mut loader = WafBenchLoader::new();
1823        for test in &file.tests {
1824            if let Some(case) = loader.parse_test_case(test, SecurityCategory::Xss) {
1825                loader.test_cases.push(case);
1826            }
1827        }
1828        traffic_cases_to_templates(loader.test_cases())
1829    }
1830
1831    /// Srikanth's OAuth case from #79. The default path collapses this to the
1832    /// bare string `totally-unsupported` and sends it to a spec endpoint as
1833    /// `?test=`, so a rule chained on `ARGS:redirect_uri` can never fire.
1834    /// Verbatim mode must put every parameter on the wire.
1835    #[test]
1836    fn verbatim_preserves_every_query_parameter() {
1837        let t = load(
1838            "- title: oauth open redirect\n  request:\n    method: GET\n    uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding&state=s1\n  expected: 403\n",
1839        );
1840        assert_eq!(t.len(), 1);
1841        let path = t[0].generate_path();
1842        assert!(path.starts_with("/oauth/authorize"), "path must survive, got {path}");
1843        assert!(path.contains("redirect_uri="), "redirect_uri must reach the wire: {path}");
1844        assert!(path.contains("response_type="), "response_type must reach the wire: {path}");
1845        assert!(path.contains("state=s1"), "trailing params must survive: {path}");
1846        assert_eq!(t[0].operation.method, "GET");
1847    }
1848
1849    /// The encoding IS the payload for charset and traversal tests, so the URI
1850    /// must survive byte for byte. This is why `query_params` is left empty
1851    /// rather than parsed out and rejoined.
1852    #[test]
1853    fn verbatim_does_not_reencode_the_uri() {
1854        let t = load(
1855            "- title: encoded\n  request:\n    method: GET\n    uri: /a?u=https%3A%2F%2Fevil.example%2Fx&b=%2E%2E%2F\n",
1856        );
1857        let path = t[0].generate_path();
1858        assert!(path.contains("https%3A%2F%2Fevil.example%2Fx"), "must not decode: {path}");
1859        assert!(path.contains("%2E%2E%2F"), "must not normalise traversal: {path}");
1860        assert!(!path.contains("://evil.example"), "must not decode to a real URL: {path}");
1861    }
1862
1863    #[test]
1864    fn verbatim_carries_headers_and_body() {
1865        let t = load(
1866            "- title: post\n  request:\n    method: POST\n    uri: /graphql\n    headers:\n      Content-Type: application/json; charset=utf-7\n    body: '{\"query\":\"{__typename}\"}'\n",
1867        );
1868        assert_eq!(t[0].operation.method, "POST");
1869        assert_eq!(
1870            t[0].headers.get("Content-Type").map(String::as_str),
1871            Some("application/json; charset=utf-7"),
1872            "the charset is the attack; it must not be normalised"
1873        );
1874        assert!(t[0].body.is_some(), "body must be carried");
1875    }
1876
1877    /// A non-JSON body must pass through rather than being dropped or mangled.
1878    #[test]
1879    fn verbatim_passes_through_non_json_body() {
1880        let t = load("- title: form\n  request:\n    method: POST\n    uri: /f\n    body: 'a=1&b=<script>'\n");
1881        let body = t[0].body.as_ref().expect("body carried");
1882        assert_eq!(body.as_str(), Some("a=1&b=<script>"), "raw body preserved verbatim");
1883    }
1884
1885    /// Cases with no URI cannot be sent, and must be skipped rather than
1886    /// producing a request against the base URL.
1887    #[test]
1888    fn verbatim_skips_cases_without_a_uri() {
1889        let t = load("- title: no uri\n  request:\n    method: GET\n");
1890        assert!(t.is_empty(), "a case with no uri yields no request");
1891    }
1892
1893    /// #997: a case marked `omit_rule: true` asks for the request to be sent
1894    /// with the rule under test disabled. mockforge cannot do that, and sending
1895    /// it anyway produces a byte-identical twin of the non-omitted case with the
1896    /// opposite expectation, so one of the pair always fails no matter what the
1897    /// WAF does. It must be dropped, not silently sent.
1898    #[test]
1899    fn omit_rule_cases_are_skipped_not_silently_sent() {
1900        let yaml = r#"
1901- title: unsupported response_type blocked
1902  omit_rule: false
1903  request:
1904    method: GET
1905    uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1906  expected: 403
1907- title: "baseline: same payload with rule omitted"
1908  omit_rule: true
1909  request:
1910    method: GET
1911    uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1912  expected: 200
1913- title: legitimate flow allowed
1914  request:
1915    method: GET
1916    uri: /oauth/authorize?response_type=code&client_id=abc123
1917  expected: 200
1918"#;
1919        let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("simple format should parse");
1920
1921        let titles: Vec<_> = parsed.tests.iter().map(|t| t.test_title.clone()).collect();
1922        assert_eq!(
1923            parsed.tests.len(),
1924            2,
1925            "the omit_rule:true case must be dropped, got {titles:?}"
1926        );
1927        assert!(
1928            !titles.iter().any(|t| t.contains("rule omitted")),
1929            "baseline case leaked into the run: {titles:?}"
1930        );
1931        // omit_rule:false is an ordinary case and must survive.
1932        assert!(
1933            titles.iter().any(|t| t.contains("unsupported response_type blocked")),
1934            "omit_rule:false must not be treated as omitted: {titles:?}"
1935        );
1936        assert_eq!(parsed.omitted_count, 1, "omitted_count must match the dropped baseline");
1937    }
1938
1939    /// #79: per-file breakdown so a directory of YAML files reports
1940    /// attack (403) vs normal (200) vs omitted before k6 starts.
1941    #[test]
1942    fn traffic_file_summary_splits_attack_normal_omitted() {
1943        let yaml = r#"
1944- title: blocked
1945  request:
1946    method: GET
1947    uri: /oauth/authorize?response_type=totally-unsupported
1948  expected: 403
1949- title: allowed
1950  request:
1951    method: GET
1952    uri: /oauth/authorize?response_type=code
1953  expected: 200
1954- title: baseline
1955  omit_rule: true
1956  request:
1957    method: GET
1958    uri: /oauth/authorize?response_type=totally-unsupported
1959  expected: 200
1960"#;
1961        let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("parse");
1962        let summary = summarize_traffic_file(&parsed, "oauth.yaml");
1963        assert_eq!(summary.sent, 2);
1964        assert_eq!(summary.attack, 1);
1965        assert_eq!(summary.normal, 1);
1966        assert_eq!(summary.omitted, 1);
1967        assert_eq!(summary.other, 0);
1968    }
1969
1970    #[test]
1971    fn missing_wafbench_path_is_an_error() {
1972        let mut loader = WafBenchLoader::new();
1973        let err = loader
1974            .load_from_pattern("definitely-not-a-real-file-apisix.yaml")
1975            .expect_err("missing path must not look like an empty payload pool");
1976        let msg = err.to_string();
1977        assert!(msg.contains("does not exist"), "error must name the missing path, got: {msg}");
1978    }
1979}