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}
256
257/// The request half of a [`SimpleTrafficCase`].
258#[derive(Debug, Clone, Deserialize, Serialize)]
259pub struct SimpleTrafficRequest {
260    /// HTTP method. Defaults to GET, matching the WAFBench input default.
261    #[serde(default = "default_method")]
262    pub method: String,
263    /// Request URI, including any payload in the query string.
264    pub uri: Option<String>,
265    /// Request headers.
266    #[serde(default)]
267    pub headers: HashMap<String, String>,
268    /// Request body. Named `body` here rather than WAFBench's `data`, because
269    /// `body` is what generators emit.
270    pub body: Option<String>,
271}
272
273/// Pull expected status codes out of the permissive `expected` field.
274///
275/// Accepts a bare integer (`403`), a sequence (`[403, 406]`), or a mapping with
276/// a `status` key holding either. Anything else yields no expectation rather
277/// than an error: an unparsable expectation should not cost the user the case,
278/// since the request itself is still perfectly valid traffic to send.
279fn extract_expected_statuses(value: Option<&serde_yaml::Value>) -> Vec<u16> {
280    fn as_status(v: &serde_yaml::Value) -> Option<u16> {
281        v.as_u64().and_then(|n| u16::try_from(n).ok())
282    }
283
284    let Some(value) = value else {
285        return Vec::new();
286    };
287
288    if let Some(one) = as_status(value) {
289        return vec![one];
290    }
291    if let Some(seq) = value.as_sequence() {
292        return seq.iter().filter_map(as_status).collect();
293    }
294    if let Some(map) = value.as_mapping() {
295        if let Some(status) = map.get(serde_yaml::Value::String("status".to_string())) {
296            if let Some(one) = as_status(status) {
297                return vec![one];
298            }
299            if let Some(seq) = status.as_sequence() {
300                return seq.iter().filter_map(as_status).collect();
301            }
302        }
303    }
304    Vec::new()
305}
306
307impl SimpleTrafficCase {
308    /// Convert into the internal WAFBench representation so the rest of the
309    /// security-test pipeline is untouched. The mapping is 1:1 except for
310    /// `body` -> `data` and the synthesised title.
311    fn into_wafbench_test(self, index: usize) -> WafBenchTest {
312        let statuses = extract_expected_statuses(self.expected.as_ref());
313        let title = self.title.unwrap_or_else(|| format!("case-{}", index + 1));
314
315        WafBenchTest {
316            desc: Some(title.clone()),
317            test_title: title,
318            stages: vec![WafBenchStage {
319                input: Some(WafBenchInput {
320                    dest_addr: None,
321                    headers: self.request.headers,
322                    method: self.request.method,
323                    port: default_port(),
324                    uri: self.request.uri,
325                    data: self.request.body,
326                    version: None,
327                }),
328                output: Some(WafBenchOutput {
329                    status: statuses,
330                    response_headers: HashMap::new(),
331                    log_contains: Vec::new(),
332                    no_log_contains: Vec::new(),
333                }),
334                stage: None,
335            }],
336        }
337    }
338}
339
340/// Parse a traffic file in either supported shape.
341///
342/// Tries the WAFBench document first, then the simple sequence. On failure the
343/// error names BOTH accepted shapes: the previous message reported only
344/// `invalid type: sequence, expected struct WafBenchFile`, which is accurate
345/// about what failed but silent about what would have worked.
346pub fn parse_traffic_file(content: &str, source: &str) -> Result<WafBenchFile> {
347    match serde_yaml::from_str::<WafBenchFile>(content) {
348        Ok(file) => Ok(file),
349        Err(wafbench_err) => match serde_yaml::from_str::<Vec<SimpleTrafficCase>>(content) {
350            Ok(cases) => {
351                tracing::info!(
352                    "{source}: parsed {} case(s) in the simple request/expected format",
353                    cases.len()
354                );
355                Ok(WafBenchFile {
356                    meta: WafBenchMeta {
357                        author: None,
358                        description: Some(format!("simple traffic file: {source}")),
359                        enabled: true,
360                        name: Some(source.to_string()),
361                    },
362                    tests: cases
363                        .into_iter()
364                        .enumerate()
365                        .map(|(i, c)| c.into_wafbench_test(i))
366                        .collect(),
367                })
368            }
369            Err(simple_err) => Err(BenchError::Other(format!(
370                "Failed to parse traffic file {source}. Two shapes are accepted:\n  \
371                 (1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n  \
372                 (2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
373            ))),
374        },
375    }
376}
377
378/// Complete WAFBench test file structure
379#[derive(Debug, Clone, Deserialize, Serialize)]
380pub struct WafBenchFile {
381    /// Test file metadata
382    pub meta: WafBenchMeta,
383    /// Test cases
384    #[serde(default)]
385    pub tests: Vec<WafBenchTest>,
386}
387
388/// A parsed WAFBench test case ready for use in security testing
389#[derive(Debug, Clone)]
390pub struct WafBenchTestCase {
391    /// Test identifier
392    pub test_id: String,
393    /// Description
394    pub description: String,
395    /// CRS rule ID (e.g., 941100)
396    pub rule_id: String,
397    /// Security category
398    pub category: SecurityCategory,
399    /// HTTP method
400    pub method: String,
401    /// Attack payloads extracted from the test
402    pub payloads: Vec<WafBenchPayload>,
403    /// Expected to be blocked (403)
404    pub expects_block: bool,
405}
406
407/// A specific payload from a WAFBench test
408#[derive(Debug, Clone)]
409pub struct WafBenchPayload {
410    /// The payload location (uri, header, body)
411    pub location: PayloadLocation,
412    /// The actual payload string
413    pub value: String,
414    /// Header name if location is Header
415    pub header_name: Option<String>,
416}
417
418/// Where the payload is injected
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum PayloadLocation {
421    /// Payload in URI/query string
422    Uri,
423    /// Payload in HTTP header
424    Header,
425    /// Payload in request body
426    Body,
427}
428
429impl std::fmt::Display for PayloadLocation {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        match self {
432            Self::Uri => write!(f, "uri"),
433            Self::Header => write!(f, "header"),
434            Self::Body => write!(f, "body"),
435        }
436    }
437}
438
439/// WAFBench loader and parser
440pub struct WafBenchLoader {
441    /// Loaded test cases
442    test_cases: Vec<WafBenchTestCase>,
443    /// Statistics
444    stats: WafBenchStats,
445}
446
447/// Statistics about loaded WAFBench tests
448#[derive(Debug, Clone, Default)]
449pub struct WafBenchStats {
450    /// Number of files processed
451    pub files_processed: usize,
452    /// Number of test cases loaded
453    pub test_cases_loaded: usize,
454    /// Number of payloads extracted
455    pub payloads_extracted: usize,
456    /// Tests by category
457    pub by_category: HashMap<SecurityCategory, usize>,
458    /// Files that failed to parse
459    pub parse_errors: Vec<String>,
460}
461
462impl WafBenchLoader {
463    /// Create a new empty loader
464    pub fn new() -> Self {
465        Self {
466            test_cases: Vec::new(),
467            stats: WafBenchStats::default(),
468        }
469    }
470
471    /// Load WAFBench tests from a directory pattern (supports glob)
472    ///
473    /// # Arguments
474    /// * `pattern` - Glob pattern like `./wafbench/REQUEST-941-*` or a direct path
475    ///
476    /// # Example
477    /// ```ignore
478    /// let loader = WafBenchLoader::new();
479    /// loader.load_from_pattern("./wafbench/REQUEST-941-APPLICATION-ATTACK-XSS/**/*.yaml")?;
480    /// ```
481    pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
482        // If pattern doesn't contain wildcards, check if it's a file or directory
483        if !pattern.contains('*') && !pattern.contains('?') {
484            let path = Path::new(pattern);
485            if path.is_file() {
486                // Load single file directly
487                return self.load_file(path);
488            } else if path.is_dir() {
489                return self.load_from_directory(path);
490            } else {
491                return Err(BenchError::Other(format!(
492                    "WAFBench path does not exist: {}",
493                    pattern
494                )));
495            }
496        }
497
498        // Use glob to find matching files
499        let entries = glob(pattern).map_err(|e| {
500            BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
501        })?;
502
503        for entry in entries {
504            match entry {
505                Ok(path) => {
506                    if path.is_file()
507                        && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
508                    {
509                        if let Err(e) = self.load_file(&path) {
510                            self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
511                        }
512                    } else if path.is_dir() {
513                        if let Err(e) = self.load_from_directory(&path) {
514                            self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
515                        }
516                    }
517                }
518                Err(e) => {
519                    self.stats.parse_errors.push(format!("Glob error: {}", e));
520                }
521            }
522        }
523
524        Ok(())
525    }
526
527    /// Load WAFBench tests from a directory (recursive)
528    pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
529        if !dir.is_dir() {
530            return Err(BenchError::Other(format!(
531                "WAFBench path is not a directory: {}",
532                dir.display()
533            )));
534        }
535
536        self.load_directory_recursive(dir)?;
537        Ok(())
538    }
539
540    fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
541        let entries = std::fs::read_dir(dir)
542            .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
543
544        for entry in entries.flatten() {
545            let path = entry.path();
546            if path.is_dir() {
547                // Recurse into subdirectories
548                self.load_directory_recursive(&path)?;
549            } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
550                if let Err(e) = self.load_file(&path) {
551                    self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
552                }
553            }
554        }
555
556        Ok(())
557    }
558
559    /// Load a single WAFBench YAML file
560    pub fn load_file(&mut self, path: &Path) -> Result<()> {
561        let content = std::fs::read_to_string(path).map_err(|e| {
562            BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
563        })?;
564
565        let wafbench_file = parse_traffic_file(&content, &path.display().to_string())?;
566
567        // Skip disabled test files
568        if !wafbench_file.meta.enabled {
569            return Ok(());
570        }
571
572        self.stats.files_processed += 1;
573
574        // Determine the rule category from the file path or name
575        let category = self.detect_category(path, &wafbench_file.meta);
576
577        // Parse each test case
578        for test in wafbench_file.tests {
579            if let Some(test_case) = self.parse_test_case(&test, category) {
580                self.stats.payloads_extracted += test_case.payloads.len();
581                *self.stats.by_category.entry(category).or_insert(0) += 1;
582                self.test_cases.push(test_case);
583                self.stats.test_cases_loaded += 1;
584            }
585        }
586
587        Ok(())
588    }
589
590    /// Detect the security category from the file path
591    fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
592        let path_str = path.to_string_lossy().to_uppercase();
593
594        if path_str.contains("XSS") || path_str.contains("941") {
595            SecurityCategory::Xss
596        } else if path_str.contains("SQLI") || path_str.contains("942") {
597            SecurityCategory::SqlInjection
598        } else if path_str.contains("RCE") || path_str.contains("932") {
599            SecurityCategory::CommandInjection
600        } else if path_str.contains("LFI") || path_str.contains("930") {
601            SecurityCategory::PathTraversal
602        } else if path_str.contains("LDAP") {
603            SecurityCategory::LdapInjection
604        } else if path_str.contains("XXE") || path_str.contains("XML") {
605            SecurityCategory::Xxe
606        } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
607            SecurityCategory::Ssti
608        } else {
609            // Default to XSS as it's the most common in WAFBench
610            SecurityCategory::Xss
611        }
612    }
613
614    /// Parse a single test case into our format
615    fn parse_test_case(
616        &self,
617        test: &WafBenchTest,
618        category: SecurityCategory,
619    ) -> Option<WafBenchTestCase> {
620        // Extract rule ID from test_title (e.g., "941100-1" -> "941100")
621        let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
622
623        let mut payloads = Vec::new();
624        let mut method = "GET".to_string();
625        let mut expects_block = false;
626
627        for stage in &test.stages {
628            // Get input from either direct or nested format (CRS v3.3 compatibility)
629            let Some(input) = stage.get_input() else {
630                continue;
631            };
632
633            method = input.method.clone();
634
635            // Check if this test expects a block (403)
636            if let Some(output) = stage.get_output() {
637                if output.status.contains(&403) {
638                    expects_block = true;
639                }
640            }
641
642            // Extract payload from URI — CRS test files are attack payloads by
643            // definition, so we accept all values without filtering. Previously
644            // a narrow looks_like_attack() check discarded exotic payloads like
645            // VML, VBScript, UTF-7, JSFuck, and bracket-notation XSS.
646            if let Some(uri) = &input.uri {
647                if !uri.is_empty() {
648                    payloads.push(WafBenchPayload {
649                        location: PayloadLocation::Uri,
650                        value: uri.clone(),
651                        header_name: None,
652                    });
653                }
654            }
655
656            // Extract payloads from headers
657            for (header_name, header_value) in &input.headers {
658                if !header_value.is_empty() {
659                    payloads.push(WafBenchPayload {
660                        location: PayloadLocation::Header,
661                        value: header_value.clone(),
662                        header_name: Some(header_name.clone()),
663                    });
664                }
665            }
666
667            // Extract payload from body
668            if let Some(data) = &input.data {
669                if !data.is_empty() {
670                    payloads.push(WafBenchPayload {
671                        location: PayloadLocation::Body,
672                        value: data.clone(),
673                        header_name: None,
674                    });
675                }
676            }
677        }
678
679        // If no payloads found, still include the test but with full URI as payload
680        if payloads.is_empty() {
681            if let Some(stage) = test.stages.first() {
682                if let Some(input) = stage.get_input() {
683                    if let Some(uri) = &input.uri {
684                        payloads.push(WafBenchPayload {
685                            location: PayloadLocation::Uri,
686                            value: uri.clone(),
687                            header_name: None,
688                        });
689                    }
690                }
691            }
692        }
693
694        if payloads.is_empty() {
695            return None;
696        }
697
698        let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
699
700        Some(WafBenchTestCase {
701            test_id: test.test_title.clone(),
702            description,
703            rule_id,
704            category,
705            method,
706            payloads,
707            expects_block,
708        })
709    }
710
711    /// Check if a string looks like an attack payload (used in tests)
712    #[cfg(test)]
713    fn looks_like_attack(&self, s: &str) -> bool {
714        // Common attack patterns
715        let attack_patterns = [
716            "<script",
717            "javascript:",
718            "onerror=",
719            "onload=",
720            "onclick=",
721            "onfocus=",
722            "onmouseover=",
723            "eval(",
724            "alert(",
725            "document.",
726            "window.",
727            "'--",
728            "' OR ",
729            "' AND ",
730            "1=1",
731            "UNION SELECT",
732            "CONCAT(",
733            "CHAR(",
734            "../",
735            "..\\",
736            "/etc/passwd",
737            "cmd.exe",
738            "powershell",
739            "; ls",
740            "| cat",
741            "${",
742            "{{",
743            "<%",
744            "<?",
745            "<!ENTITY",
746            "SYSTEM \"",
747        ];
748
749        let lower = s.to_lowercase();
750        attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
751    }
752
753    /// Get all loaded test cases
754    pub fn test_cases(&self) -> &[WafBenchTestCase] {
755        &self.test_cases
756    }
757
758    /// Get statistics about loaded tests
759    pub fn stats(&self) -> &WafBenchStats {
760        &self.stats
761    }
762
763    /// Decode a form-URL-encoded body payload.
764    /// Replaces `+` with space (form-encoding convention), then decodes `%XX` sequences.
765    /// Strips form field name prefix (e.g., `var=;;dd foo bar` → `;;dd foo bar`)
766    /// since JSON injection puts the value in a field, not the form key.
767    fn decode_form_encoded_body(value: &str) -> String {
768        // Replace + with space first (form-encoding convention)
769        let plus_decoded = value.replace('+', " ");
770        // Then decode %XX sequences
771        let decoded = urlencoding::decode(&plus_decoded)
772            .map(|s| s.into_owned())
773            .unwrap_or(plus_decoded);
774        // Strip form field name prefix (e.g., "var=value" → "value")
775        // CRS test data like "var=;;dd foo bar" has the form key included,
776        // but we inject only the value into a JSON field.
777        Self::strip_form_key(&decoded)
778    }
779
780    /// Strip a single leading form key from a form-encoded value.
781    /// `"var=;;dd foo bar"` → `";;dd foo bar"`
782    /// `"pay=exec (@\n"` → `"exec (@\n"`
783    /// Values without `=` or starting with special chars are returned as-is.
784    fn strip_form_key(value: &str) -> String {
785        // Only strip if the prefix before the first = looks like a form field name
786        // (alphanumeric/underscore chars). Don't strip if the = is part of the attack.
787        if let Some(eq_pos) = value.find('=') {
788            let key = &value[..eq_pos];
789            // Form field names are alphanumeric with underscores
790            if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
791                return value[eq_pos + 1..].to_string();
792            }
793        }
794        value.to_string()
795    }
796
797    /// Normalize a form body value to valid `application/x-www-form-urlencoded` format.
798    ///
799    /// CRS YAML `data` fields may be pre-encoded (`var=%3B%3Bdd+foo+bar`) or decoded
800    /// (`var=;;dd foo bar`). This function ensures the output is always properly encoded
801    /// so WAFs can parse it into ARGS and fire rules like 942432.
802    ///
803    /// Strategy: decode fully first (handling `+` as space and `%XX` sequences), then
804    /// re-encode. Pre-encoded input round-trips correctly; decoded input gets encoded.
805    fn ensure_form_encoded(value: &str) -> String {
806        value
807            .split('&')
808            .map(|pair| {
809                if let Some(eq_pos) = pair.find('=') {
810                    let key = &pair[..eq_pos];
811                    let val = &pair[eq_pos + 1..];
812                    // Decode: + → space, then %XX → chars
813                    let key_plus = key.replace('+', " ");
814                    let val_plus = val.replace('+', " ");
815                    let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
816                    let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
817                    // Re-encode with form-encoding (spaces as +)
818                    let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
819                    let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
820                    format!("{enc_key}={enc_val}")
821                } else {
822                    // No key=value structure — encode the whole thing
823                    let pair_plus = pair.replace('+', " ");
824                    let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
825                    urlencoding::encode(&decoded).replace("%20", "+").to_string()
826                }
827            })
828            .collect::<Vec<_>>()
829            .join("&")
830    }
831
832    /// Convert loaded tests to SecurityPayload format for use with existing security testing
833    pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
834        let mut payloads = Vec::new();
835
836        for test_case in &self.test_cases {
837            // Assign group_id when a test case has multiple payloads
838            let group_id = if test_case.payloads.len() > 1 {
839                Some(test_case.test_id.clone())
840            } else {
841                None
842            };
843
844            for payload in &test_case.payloads {
845                // Extract just the attack payload part if possible
846                let payload_str = match payload.location {
847                    PayloadLocation::Body => {
848                        // Form-URL-decode body payloads so WAFs see the real characters
849                        Self::decode_form_encoded_body(&payload.value)
850                    }
851                    PayloadLocation::Uri => {
852                        // Extract attack payload from URI, URL-decode, strip path prefix
853                        self.extract_uri_payload(&payload.value)
854                    }
855                    PayloadLocation::Header => {
856                        // Headers are used as-is (Cookie values, User-Agent, etc.)
857                        payload.value.clone()
858                    }
859                };
860
861                // Convert local PayloadLocation to SecurityPayloadLocation
862                let location = match payload.location {
863                    PayloadLocation::Uri => SecurityPayloadLocation::Uri,
864                    PayloadLocation::Header => SecurityPayloadLocation::Header,
865                    PayloadLocation::Body => SecurityPayloadLocation::Body,
866                };
867
868                let mut sec_payload = SecurityPayload::new(
869                    payload_str,
870                    test_case.category,
871                    format!(
872                        "[WAFBench {}] {} ({})",
873                        test_case.rule_id, test_case.description, payload.location
874                    ),
875                )
876                .high_risk()
877                .with_location(location);
878
879                // Add header name for header payloads
880                if let Some(header_name) = &payload.header_name {
881                    sec_payload = sec_payload.with_header_name(header_name.clone());
882                }
883
884                // Add group ID for multi-part test cases
885                if let Some(gid) = &group_id {
886                    sec_payload = sec_payload.with_group_id(gid.clone());
887                }
888
889                // URI payloads without '?' are path-only attacks (e.g., 942101: POST /1234%20OR%201=1)
890                // These need to replace the request path so WAF inspects via REQUEST_FILENAME
891                if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
892                    sec_payload = sec_payload.with_inject_as_path();
893                }
894
895                // Body payloads: normalize to valid form-encoded format for WAF ARGS parsing
896                // (e.g., 942432: data "var=%3B%3Bdd+foo+bar" or decoded "var=;;dd foo bar")
897                if payload.location == PayloadLocation::Body {
898                    sec_payload = sec_payload
899                        .with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
900                }
901
902                payloads.push(sec_payload);
903            }
904        }
905
906        payloads
907    }
908
909    /// Extract the actual attack payload from a URI.
910    ///
911    /// For URIs with query parameters (e.g., `/?var=EXECUTE%20IMMEDIATE%20%22`),
912    /// extracts and URL-decodes the first parameter value.
913    ///
914    /// For path-only URIs (e.g., `/1234%20OR%201=1`), URL-decodes the path and
915    /// strips the leading `/` which is a URI artifact, not part of the attack.
916    fn extract_uri_payload(&self, value: &str) -> String {
917        // If it's a URI with query params, extract the first parameter value
918        // (URL-decoded). CRS test files put the attack in query params.
919        if value.contains('?') {
920            if let Some(query) = value.split('?').nth(1) {
921                for param in query.split('&') {
922                    if let Some(val) = param.split('=').nth(1) {
923                        let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
924                        if !decoded.is_empty() {
925                            return decoded.to_string();
926                        }
927                    }
928                }
929            }
930        }
931
932        // For path-only URIs, URL-decode and strip leading /
933        // e.g., /1234%20OR%201=1 → 1234 OR 1=1
934        let decoded = urlencoding::decode(value)
935            .map(|s| s.into_owned())
936            .unwrap_or_else(|_| value.to_string());
937        let trimmed = decoded.trim_start_matches('/');
938        if trimmed.is_empty() {
939            // Don't return empty string for bare "/" paths
940            return decoded;
941        }
942        trimmed.to_string()
943    }
944}
945
946impl Default for WafBenchLoader {
947    fn default() -> Self {
948        Self::new()
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    #[test]
957    fn test_parse_wafbench_yaml() {
958        let yaml = r#"
959meta:
960  author: test
961  description: Test XSS rules
962  enabled: true
963  name: test.yaml
964
965tests:
966  - desc: "XSS in URI parameter"
967    test_title: "941100-1"
968    stages:
969      - input:
970          dest_addr: "127.0.0.1"
971          headers:
972            Host: "localhost"
973            User-Agent: "Mozilla/5.0"
974          method: "GET"
975          port: 80
976          uri: "/test?param=<script>alert(1)</script>"
977        output:
978          status: [403]
979"#;
980
981        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
982        assert!(file.meta.enabled);
983        assert_eq!(file.tests.len(), 1);
984        assert_eq!(file.tests[0].test_title, "941100-1");
985    }
986
987    #[test]
988    fn test_detect_category() {
989        let loader = WafBenchLoader::new();
990        let meta = WafBenchMeta {
991            author: None,
992            description: None,
993            enabled: true,
994            name: None,
995        };
996
997        assert_eq!(
998            loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
999            SecurityCategory::Xss
1000        );
1001
1002        assert_eq!(
1003            loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
1004            SecurityCategory::SqlInjection
1005        );
1006    }
1007
1008    #[test]
1009    fn test_looks_like_attack() {
1010        let loader = WafBenchLoader::new();
1011
1012        assert!(loader.looks_like_attack("<script>alert(1)</script>"));
1013        assert!(loader.looks_like_attack("' OR '1'='1"));
1014        assert!(loader.looks_like_attack("../../../etc/passwd"));
1015        assert!(loader.looks_like_attack("; ls -la"));
1016        assert!(!loader.looks_like_attack("normal text"));
1017        assert!(!loader.looks_like_attack("hello world"));
1018    }
1019
1020    #[test]
1021    fn test_extract_uri_payload_with_query_params() {
1022        let loader = WafBenchLoader::new();
1023
1024        // URI with query params: extracts and decodes the parameter value
1025        let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
1026        let payload = loader.extract_uri_payload(uri);
1027        assert_eq!(payload, "<script>alert(1)</script>");
1028    }
1029
1030    #[test]
1031    fn test_extract_uri_payload_path_only() {
1032        let loader = WafBenchLoader::new();
1033
1034        // Path-only URI: URL-decodes and strips leading /
1035        let uri = "/1234%20OR%201=1";
1036        let payload = loader.extract_uri_payload(uri);
1037        assert_eq!(payload, "1234 OR 1=1");
1038
1039        // Path with quotes and special chars
1040        let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
1041        let payload2 = loader.extract_uri_payload(uri2);
1042        assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
1043
1044        // Bare slash returns "/" (not empty)
1045        let uri3 = "/";
1046        let payload3 = loader.extract_uri_payload(uri3);
1047        assert_eq!(payload3, "/");
1048    }
1049
1050    #[test]
1051    fn test_group_id_assigned_for_multi_part_test_cases() {
1052        let yaml = r#"
1053meta:
1054  author: test
1055  description: Multi-part test
1056  enabled: true
1057  name: test.yaml
1058
1059tests:
1060  - desc: "Multi-part attack with URI and header"
1061    test_title: "942290-1"
1062    stages:
1063      - input:
1064          dest_addr: "127.0.0.1"
1065          headers:
1066            Host: "localhost"
1067            User-Agent: "ModSecurity CRS 3 Tests"
1068          method: "GET"
1069          port: 80
1070          uri: "/test?param=attack"
1071        output:
1072          status: [403]
1073"#;
1074
1075        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1076        let mut loader = WafBenchLoader::new();
1077        loader.stats.files_processed += 1;
1078
1079        let category = SecurityCategory::SqlInjection;
1080        for test in &file.tests {
1081            if let Some(test_case) = loader.parse_test_case(test, category) {
1082                loader.test_cases.push(test_case);
1083            }
1084        }
1085
1086        let payloads = loader.to_security_payloads();
1087        // This test has URI + 2 headers = 3 payloads, all should share a group_id
1088        assert!(payloads.len() >= 2, "Should have at least 2 payloads");
1089        let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
1090        assert!(
1091            group_ids.iter().all(|g| g.is_some()),
1092            "All payloads in multi-part test should have group_id"
1093        );
1094        assert!(
1095            group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
1096            "All payloads should share the same group_id"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_single_payload_no_group_id() {
1102        let yaml = r#"
1103meta:
1104  author: test
1105  description: Single payload test
1106  enabled: true
1107  name: test.yaml
1108
1109tests:
1110  - desc: "Simple XSS"
1111    test_title: "941100-1"
1112    stages:
1113      - input:
1114          dest_addr: "127.0.0.1"
1115          headers: {}
1116          method: "GET"
1117          port: 80
1118          uri: "/test?param=<script>alert(1)</script>"
1119        output:
1120          status: [403]
1121"#;
1122
1123        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1124        let mut loader = WafBenchLoader::new();
1125        loader.stats.files_processed += 1;
1126
1127        let category = SecurityCategory::Xss;
1128        for test in &file.tests {
1129            if let Some(test_case) = loader.parse_test_case(test, category) {
1130                loader.test_cases.push(test_case);
1131            }
1132        }
1133
1134        let payloads = loader.to_security_payloads();
1135        assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
1136        assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
1137    }
1138
1139    #[test]
1140    fn test_body_payload_form_url_decoded() {
1141        let yaml = r#"
1142meta:
1143  author: test
1144  description: Body payload test
1145  enabled: true
1146  name: test.yaml
1147
1148tests:
1149  - desc: "SQL injection in body"
1150    test_title: "942240-1"
1151    stages:
1152      - stage:
1153          input:
1154            dest_addr: 127.0.0.1
1155            headers:
1156              Host: localhost
1157            method: POST
1158            port: 80
1159            uri: "/"
1160            data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
1161          output:
1162            log_contains: id "942240"
1163"#;
1164
1165        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1166        let mut loader = WafBenchLoader::new();
1167        loader.stats.files_processed += 1;
1168
1169        let category = SecurityCategory::SqlInjection;
1170        for test in &file.tests {
1171            if let Some(test_case) = loader.parse_test_case(test, category) {
1172                loader.test_cases.push(test_case);
1173            }
1174        }
1175
1176        let payloads = loader.to_security_payloads();
1177        // Find the body payload
1178        let body_payload = payloads
1179            .iter()
1180            .find(|p| p.location == SecurityPayloadLocation::Body)
1181            .expect("Should have a body payload");
1182
1183        // The body payload should be form-URL-decoded
1184        assert!(
1185            body_payload.payload.contains('"'),
1186            "Body payload should have decoded %22 to double-quote: {}",
1187            body_payload.payload
1188        );
1189        assert!(
1190            body_payload.payload.contains(' '),
1191            "Body payload should have decoded + to space: {}",
1192            body_payload.payload
1193        );
1194        assert!(
1195            !body_payload.payload.contains("%22"),
1196            "Body payload should NOT contain literal %22: {}",
1197            body_payload.payload
1198        );
1199    }
1200
1201    #[test]
1202    fn test_decode_form_encoded_body() {
1203        // Basic decoding
1204        assert_eq!(
1205            WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
1206            "\" WAITFOR DELAY '\n"
1207        );
1208        assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
1209        assert_eq!(
1210            WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
1211            "no encoding needed"
1212        );
1213        // Form key stripping: var=value → value
1214        assert_eq!(
1215            WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
1216            ";;dd foo bar"
1217        );
1218        // Form key stripping: pay=exec → exec
1219        assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
1220        // No form key: starts with special char → returned as-is
1221        assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
1222    }
1223
1224    #[test]
1225    fn test_strip_form_key() {
1226        // Standard form key=value
1227        assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
1228        assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
1229        assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
1230        // No form key (starts with special char)
1231        assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
1232        // = inside attack payload, key is not alphanumeric
1233        assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
1234        // Empty input
1235        assert_eq!(WafBenchLoader::strip_form_key(""), "");
1236        // Only key, no value
1237        assert_eq!(WafBenchLoader::strip_form_key("var="), "");
1238    }
1239
1240    #[test]
1241    fn test_ensure_form_encoded() {
1242        // Pre-encoded input round-trips correctly
1243        assert_eq!(
1244            WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
1245            "var=%3B%3Bdd+foo+bar"
1246        );
1247        // Decoded input gets properly encoded
1248        assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
1249        // Multi-field form
1250        assert_eq!(
1251            WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
1252            "var=-------------------&var2=whatever"
1253        );
1254        // Already-encoded multi-field
1255        assert_eq!(
1256            WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
1257            "key=%22value%22&other=test+data"
1258        );
1259        // Decoded multi-field
1260        assert_eq!(
1261            WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
1262            "key=%22value%22&other=test+data"
1263        );
1264        // No key=value structure
1265        assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
1266        // Empty string
1267        assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
1268    }
1269
1270    #[test]
1271    fn test_uri_path_only_gets_inject_as_path() {
1272        let yaml = r#"
1273meta:
1274  author: test
1275  description: Path injection test
1276  enabled: true
1277  name: test.yaml
1278
1279tests:
1280  - desc: "Path-based SQL injection"
1281    test_title: "942101-1"
1282    stages:
1283      - stage:
1284          input:
1285            dest_addr: 127.0.0.1
1286            headers:
1287              Host: localhost
1288            method: POST
1289            port: 80
1290            uri: "/1234%20OR%201=1"
1291          output:
1292            log_contains: id "942101"
1293"#;
1294
1295        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1296        let mut loader = WafBenchLoader::new();
1297        loader.stats.files_processed += 1;
1298
1299        let category = SecurityCategory::SqlInjection;
1300        for test in &file.tests {
1301            if let Some(test_case) = loader.parse_test_case(test, category) {
1302                loader.test_cases.push(test_case);
1303            }
1304        }
1305
1306        let payloads = loader.to_security_payloads();
1307        let uri_payload = payloads
1308            .iter()
1309            .find(|p| p.location == SecurityPayloadLocation::Uri)
1310            .expect("Should have URI payload");
1311
1312        assert_eq!(
1313            uri_payload.inject_as_path,
1314            Some(true),
1315            "Path-only URI should have inject_as_path=true"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_uri_with_query_no_inject_as_path() {
1321        let yaml = r#"
1322meta:
1323  author: test
1324  description: Query param test
1325  enabled: true
1326  name: test.yaml
1327
1328tests:
1329  - desc: "Query-param SQL injection"
1330    test_title: "942100-1"
1331    stages:
1332      - stage:
1333          input:
1334            dest_addr: 127.0.0.1
1335            headers: {}
1336            method: GET
1337            port: 80
1338            uri: "/test?param=1+OR+1%3D1"
1339          output:
1340            log_contains: id "942100"
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        let uri_payload = payloads
1356            .iter()
1357            .find(|p| p.location == SecurityPayloadLocation::Uri)
1358            .expect("Should have URI payload");
1359
1360        assert!(
1361            uri_payload.inject_as_path.is_none(),
1362            "URI with query params should NOT have inject_as_path"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_body_payload_gets_form_encoded_body() {
1368        let yaml = r#"
1369meta:
1370  author: test
1371  description: Form body test
1372  enabled: true
1373  name: test.yaml
1374
1375tests:
1376  - desc: "Form-encoded body attack"
1377    test_title: "942432-1"
1378    stages:
1379      - stage:
1380          input:
1381            dest_addr: 127.0.0.1
1382            headers:
1383              Host: localhost
1384            method: POST
1385            port: 80
1386            uri: "/"
1387            data: "var=%3B%3Bdd+foo+bar"
1388          output:
1389            log_contains: id "942432"
1390"#;
1391
1392        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1393        let mut loader = WafBenchLoader::new();
1394        loader.stats.files_processed += 1;
1395
1396        let category = SecurityCategory::SqlInjection;
1397        for test in &file.tests {
1398            if let Some(test_case) = loader.parse_test_case(test, category) {
1399                loader.test_cases.push(test_case);
1400            }
1401        }
1402
1403        let payloads = loader.to_security_payloads();
1404        let body_payload = payloads
1405            .iter()
1406            .find(|p| p.location == SecurityPayloadLocation::Body)
1407            .expect("Should have body payload");
1408
1409        assert!(
1410            body_payload.form_encoded_body.is_some(),
1411            "Body payload should have form_encoded_body set"
1412        );
1413        // Pre-encoded CRS YAML value round-trips through ensure_form_encoded
1414        assert_eq!(
1415            body_payload.form_encoded_body.as_deref().unwrap(),
1416            "var=%3B%3Bdd+foo+bar",
1417            "form_encoded_body should be properly URL-encoded"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_body_payload_decoded_yaml_gets_encoded() {
1423        // CRS YAML with already-decoded data value (some CRS distributions)
1424        let yaml = r#"
1425meta:
1426  author: test
1427  description: Form body test (decoded)
1428  enabled: true
1429  name: test.yaml
1430
1431tests:
1432  - desc: "Form-encoded body attack (decoded)"
1433    test_title: "942432-2"
1434    stages:
1435      - stage:
1436          input:
1437            dest_addr: 127.0.0.1
1438            headers:
1439              Host: localhost
1440            method: POST
1441            port: 80
1442            uri: "/"
1443            data: "var=;;dd foo bar"
1444          output:
1445            log_contains: id "942432"
1446"#;
1447
1448        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1449        let mut loader = WafBenchLoader::new();
1450        loader.stats.files_processed += 1;
1451
1452        let category = SecurityCategory::SqlInjection;
1453        for test in &file.tests {
1454            if let Some(test_case) = loader.parse_test_case(test, category) {
1455                loader.test_cases.push(test_case);
1456            }
1457        }
1458
1459        let payloads = loader.to_security_payloads();
1460        let body_payload = payloads
1461            .iter()
1462            .find(|p| p.location == SecurityPayloadLocation::Body)
1463            .expect("Should have body payload");
1464
1465        assert!(
1466            body_payload.form_encoded_body.is_some(),
1467            "Body payload should have form_encoded_body set"
1468        );
1469        // Decoded input must be re-encoded for WAF ARGS parsing
1470        let encoded = body_payload.form_encoded_body.as_deref().unwrap();
1471        assert!(
1472            encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
1473            "Semicolons must be URL-encoded: {encoded}"
1474        );
1475        assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
1476        assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
1477    }
1478
1479    #[test]
1480    fn test_parse_crs_v33_format() {
1481        // CRS v3.3/master uses a nested stage: wrapper
1482        let yaml = r#"
1483meta:
1484  author: "Christian Folini"
1485  description: Various SQL injection tests
1486  enabled: true
1487  name: 942100.yaml
1488
1489tests:
1490  - test_title: 942100-1
1491    desc: "Simple SQL Injection"
1492    stages:
1493      - stage:
1494          input:
1495            dest_addr: 127.0.0.1
1496            headers:
1497              Host: localhost
1498            method: POST
1499            port: 80
1500            uri: "/"
1501            data: "var=1234 OR 1=1"
1502            version: HTTP/1.0
1503          output:
1504            log_contains: id "942100"
1505"#;
1506
1507        let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1508        assert!(file.meta.enabled);
1509        assert_eq!(file.tests.len(), 1);
1510        assert_eq!(file.tests[0].test_title, "942100-1");
1511
1512        // Verify we can get the input from nested format
1513        let stage = &file.tests[0].stages[0];
1514        let input = stage.get_input().expect("Should have input");
1515        assert_eq!(input.method, "POST");
1516        assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
1517    }
1518}
1519
1520#[cfg(test)]
1521mod simple_traffic_tests {
1522    use super::*;
1523
1524    /// Srikanth's actual file shape from #79 (r65 item b). Before #987 this
1525    /// failed with `invalid type: sequence, expected struct WafBenchFile`.
1526    const SRIKANTH_YAML: &str = r#"
1527- title: utf-7 charset blocked
1528  request:
1529    method: POST
1530    uri: /graphql
1531    headers:
1532      Content-Type: application/json; charset=utf-7
1533    body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
1534  expected: 403
1535- title: utf-8 charset allowed
1536  request:
1537    method: POST
1538    uri: /graphql
1539    headers:
1540      Content-Type: application/json; charset=utf-8
1541    body: '{"query":"{__typename}"}'
1542  expected: 200
1543"#;
1544
1545    #[test]
1546    fn simple_sequence_parses_and_maps_every_field() {
1547        let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
1548        assert_eq!(file.tests.len(), 2);
1549        assert!(file.meta.enabled, "synthesised meta must not disable the file");
1550
1551        let first = &file.tests[0];
1552        assert_eq!(first.test_title, "utf-7 charset blocked");
1553        let stage = &first.stages[0];
1554        let input = stage.input.as_ref().expect("input mapped");
1555        assert_eq!(input.method, "POST");
1556        assert_eq!(input.uri.as_deref(), Some("/graphql"));
1557        assert_eq!(
1558            input.headers.get("Content-Type").map(String::as_str),
1559            Some("application/json; charset=utf-7")
1560        );
1561        assert!(
1562            input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
1563            "`body` must map onto WAFBench's `data`, payload intact"
1564        );
1565        assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
1566    }
1567
1568    /// The WAFBench shape must keep working unchanged.
1569    #[test]
1570    fn wafbench_document_still_parses() {
1571        let yaml = r#"
1572meta:
1573  author: crs
1574  enabled: true
1575  name: 941100
1576tests:
1577  - test_title: 941100-1
1578    stages:
1579      - stage:
1580          input:
1581            method: GET
1582            uri: /?x=<script>alert(1)</script>
1583          output:
1584            status: [403]
1585"#;
1586        let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
1587        assert_eq!(file.tests.len(), 1);
1588        assert_eq!(file.meta.author.as_deref(), Some("crs"));
1589    }
1590
1591    /// `expected` is permissive because generators are inconsistent about it.
1592    #[test]
1593    fn expected_accepts_scalar_sequence_and_mapping() {
1594        let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
1595        assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
1596
1597        let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
1598        assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
1599
1600        let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
1601        assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
1602
1603        let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
1604        assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
1605    }
1606
1607    /// An unparsable expectation must not cost the case: the request is still
1608    /// valid traffic to send.
1609    #[test]
1610    fn unusable_expected_yields_no_statuses_not_an_error() {
1611        let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
1612        assert!(extract_expected_statuses(Some(&junk)).is_empty());
1613        assert!(extract_expected_statuses(None).is_empty());
1614
1615        let yaml = "- request:\n    uri: /a\n";
1616        let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
1617        assert_eq!(file.tests.len(), 1);
1618        assert_eq!(
1619            file.tests[0].stages[0].input.as_ref().unwrap().method,
1620            "GET",
1621            "method defaults"
1622        );
1623        assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
1624    }
1625
1626    /// The error must name BOTH accepted shapes. The old message reported only
1627    /// the struct that failed, which told the user nothing about what to write.
1628    #[test]
1629    fn parse_error_names_both_accepted_shapes() {
1630        let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
1631        assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
1632        assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
1633        assert!(err.contains("expected"), "must mention the simple shape: {err}");
1634        assert!(err.contains("request"), "must mention the simple shape: {err}");
1635    }
1636}