1use crate::error::{BenchError, Result};
40use crate::security_payloads::{SecurityCategory, SecurityPayload};
41use glob::glob;
42use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44use std::path::Path;
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct WafBenchMeta {
49 pub author: Option<String>,
51 pub description: Option<String>,
53 #[serde(default = "default_enabled")]
55 pub enabled: bool,
56 pub name: Option<String>,
58}
59
60fn default_enabled() -> bool {
61 true
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct WafBenchTest {
67 pub desc: Option<String>,
69 pub test_title: String,
71 #[serde(default)]
73 pub stages: Vec<WafBenchStage>,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize)]
79pub struct WafBenchStage {
80 pub input: Option<WafBenchInput>,
82 pub output: Option<WafBenchOutput>,
84 pub stage: Option<WafBenchStageInner>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct WafBenchStageInner {
91 pub input: WafBenchInput,
93 pub output: Option<WafBenchOutput>,
95}
96
97impl WafBenchStage {
98 pub fn get_input(&self) -> Option<&WafBenchInput> {
100 if let Some(stage) = &self.stage {
102 Some(&stage.input)
103 } else {
104 self.input.as_ref()
105 }
106 }
107
108 pub fn get_output(&self) -> Option<&WafBenchOutput> {
110 if let Some(stage) = &self.stage {
112 stage.output.as_ref()
113 } else {
114 self.output.as_ref()
115 }
116 }
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct WafBenchInput {
122 pub dest_addr: Option<String>,
124 #[serde(default)]
126 pub headers: HashMap<String, String>,
127 #[serde(default = "default_method")]
129 pub method: String,
130 #[serde(default = "default_port")]
132 pub port: u16,
133 pub uri: Option<String>,
135 pub data: Option<String>,
137 pub version: Option<String>,
139}
140
141fn default_method() -> String {
142 "GET".to_string()
143}
144
145fn default_port() -> u16 {
146 80
147}
148
149#[derive(Debug, Clone, Deserialize, Serialize)]
151pub struct WafBenchOutput {
152 #[serde(default)]
154 pub status: Vec<u16>,
155 #[serde(default)]
157 pub response_headers: HashMap<String, String>,
158 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
160 pub log_contains: Vec<String>,
161 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
163 pub no_log_contains: Vec<String>,
164}
165
166fn deserialize_string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
168where
169 D: serde::Deserializer<'de>,
170{
171 use serde::de::{self, Visitor};
172
173 struct StringOrVec;
174
175 impl<'de> Visitor<'de> for StringOrVec {
176 type Value = Vec<String>;
177
178 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
179 formatter.write_str("string or array of strings")
180 }
181
182 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
183 where
184 E: de::Error,
185 {
186 Ok(vec![value.to_string()])
187 }
188
189 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
190 where
191 E: de::Error,
192 {
193 Ok(vec![value])
194 }
195
196 fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
197 where
198 A: de::SeqAccess<'de>,
199 {
200 let mut vec = Vec::new();
201 while let Some(value) = seq.next_element::<String>()? {
202 vec.push(value);
203 }
204 Ok(vec)
205 }
206
207 fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
208 where
209 E: de::Error,
210 {
211 Ok(Vec::new())
212 }
213
214 fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
215 where
216 E: de::Error,
217 {
218 Ok(Vec::new())
219 }
220 }
221
222 deserializer.deserialize_any(StringOrVec)
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize)]
227pub struct WafBenchFile {
228 pub meta: WafBenchMeta,
230 #[serde(default)]
232 pub tests: Vec<WafBenchTest>,
233}
234
235#[derive(Debug, Clone)]
237pub struct WafBenchTestCase {
238 pub test_id: String,
240 pub description: String,
242 pub rule_id: String,
244 pub category: SecurityCategory,
246 pub method: String,
248 pub payloads: Vec<WafBenchPayload>,
250 pub expects_block: bool,
252}
253
254#[derive(Debug, Clone)]
256pub struct WafBenchPayload {
257 pub location: PayloadLocation,
259 pub value: String,
261 pub header_name: Option<String>,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum PayloadLocation {
268 Uri,
270 Header,
272 Body,
274}
275
276impl std::fmt::Display for PayloadLocation {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 match self {
279 Self::Uri => write!(f, "uri"),
280 Self::Header => write!(f, "header"),
281 Self::Body => write!(f, "body"),
282 }
283 }
284}
285
286pub struct WafBenchLoader {
288 test_cases: Vec<WafBenchTestCase>,
290 stats: WafBenchStats,
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct WafBenchStats {
297 pub files_processed: usize,
299 pub test_cases_loaded: usize,
301 pub payloads_extracted: usize,
303 pub by_category: HashMap<SecurityCategory, usize>,
305 pub parse_errors: Vec<String>,
307}
308
309impl WafBenchLoader {
310 pub fn new() -> Self {
312 Self {
313 test_cases: Vec::new(),
314 stats: WafBenchStats::default(),
315 }
316 }
317
318 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
329 if !pattern.contains('*') && !pattern.contains('?') {
331 return self.load_from_directory(Path::new(pattern));
332 }
333
334 let entries = glob(pattern).map_err(|e| {
336 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
337 })?;
338
339 for entry in entries {
340 match entry {
341 Ok(path) => {
342 if path.is_file()
343 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
344 {
345 if let Err(e) = self.load_file(&path) {
346 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
347 }
348 } else if path.is_dir() {
349 if let Err(e) = self.load_from_directory(&path) {
350 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
351 }
352 }
353 }
354 Err(e) => {
355 self.stats.parse_errors.push(format!("Glob error: {}", e));
356 }
357 }
358 }
359
360 Ok(())
361 }
362
363 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
365 if !dir.is_dir() {
366 return Err(BenchError::Other(format!(
367 "WAFBench path is not a directory: {}",
368 dir.display()
369 )));
370 }
371
372 self.load_directory_recursive(dir)?;
373 Ok(())
374 }
375
376 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
377 let entries = std::fs::read_dir(dir)
378 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
379
380 for entry in entries.flatten() {
381 let path = entry.path();
382 if path.is_dir() {
383 self.load_directory_recursive(&path)?;
385 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
386 if let Err(e) = self.load_file(&path) {
387 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
388 }
389 }
390 }
391
392 Ok(())
393 }
394
395 pub fn load_file(&mut self, path: &Path) -> Result<()> {
397 let content = std::fs::read_to_string(path).map_err(|e| {
398 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
399 })?;
400
401 let wafbench_file: WafBenchFile = serde_yaml::from_str(&content).map_err(|e| {
402 BenchError::Other(format!("Failed to parse WAFBench YAML {}: {}", path.display(), e))
403 })?;
404
405 if !wafbench_file.meta.enabled {
407 return Ok(());
408 }
409
410 self.stats.files_processed += 1;
411
412 let category = self.detect_category(path, &wafbench_file.meta);
414
415 for test in wafbench_file.tests {
417 if let Some(test_case) = self.parse_test_case(&test, category) {
418 self.stats.payloads_extracted += test_case.payloads.len();
419 *self.stats.by_category.entry(category).or_insert(0) += 1;
420 self.test_cases.push(test_case);
421 self.stats.test_cases_loaded += 1;
422 }
423 }
424
425 Ok(())
426 }
427
428 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
430 let path_str = path.to_string_lossy().to_uppercase();
431
432 if path_str.contains("XSS") || path_str.contains("941") {
433 SecurityCategory::Xss
434 } else if path_str.contains("SQLI") || path_str.contains("942") {
435 SecurityCategory::SqlInjection
436 } else if path_str.contains("RCE") || path_str.contains("932") {
437 SecurityCategory::CommandInjection
438 } else if path_str.contains("LFI") || path_str.contains("930") {
439 SecurityCategory::PathTraversal
440 } else if path_str.contains("LDAP") {
441 SecurityCategory::LdapInjection
442 } else if path_str.contains("XXE") || path_str.contains("XML") {
443 SecurityCategory::Xxe
444 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
445 SecurityCategory::Ssti
446 } else {
447 SecurityCategory::Xss
449 }
450 }
451
452 fn parse_test_case(
454 &self,
455 test: &WafBenchTest,
456 category: SecurityCategory,
457 ) -> Option<WafBenchTestCase> {
458 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
460
461 let mut payloads = Vec::new();
462 let mut method = "GET".to_string();
463 let mut expects_block = false;
464
465 for stage in &test.stages {
466 let Some(input) = stage.get_input() else {
468 continue;
469 };
470
471 method = input.method.clone();
472
473 if let Some(output) = stage.get_output() {
475 if output.status.contains(&403) {
476 expects_block = true;
477 }
478 }
479
480 if let Some(uri) = &input.uri {
482 if self.looks_like_attack(uri) {
484 payloads.push(WafBenchPayload {
485 location: PayloadLocation::Uri,
486 value: uri.clone(),
487 header_name: None,
488 });
489 }
490 }
491
492 for (header_name, header_value) in &input.headers {
494 if self.looks_like_attack(header_value) {
495 payloads.push(WafBenchPayload {
496 location: PayloadLocation::Header,
497 value: header_value.clone(),
498 header_name: Some(header_name.clone()),
499 });
500 }
501 }
502
503 if let Some(data) = &input.data {
505 if self.looks_like_attack(data) {
506 payloads.push(WafBenchPayload {
507 location: PayloadLocation::Body,
508 value: data.clone(),
509 header_name: None,
510 });
511 }
512 }
513 }
514
515 if payloads.is_empty() {
517 if let Some(stage) = test.stages.first() {
518 if let Some(input) = stage.get_input() {
519 if let Some(uri) = &input.uri {
520 payloads.push(WafBenchPayload {
521 location: PayloadLocation::Uri,
522 value: uri.clone(),
523 header_name: None,
524 });
525 }
526 }
527 }
528 }
529
530 if payloads.is_empty() {
531 return None;
532 }
533
534 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
535
536 Some(WafBenchTestCase {
537 test_id: test.test_title.clone(),
538 description,
539 rule_id,
540 category,
541 method,
542 payloads,
543 expects_block,
544 })
545 }
546
547 fn looks_like_attack(&self, s: &str) -> bool {
549 let attack_patterns = [
551 "<script",
552 "javascript:",
553 "onerror=",
554 "onload=",
555 "onclick=",
556 "onfocus=",
557 "onmouseover=",
558 "eval(",
559 "alert(",
560 "document.",
561 "window.",
562 "'--",
563 "' OR ",
564 "' AND ",
565 "1=1",
566 "UNION SELECT",
567 "CONCAT(",
568 "CHAR(",
569 "../",
570 "..\\",
571 "/etc/passwd",
572 "cmd.exe",
573 "powershell",
574 "; ls",
575 "| cat",
576 "${",
577 "{{",
578 "<%",
579 "<?",
580 "<!ENTITY",
581 "SYSTEM \"",
582 ];
583
584 let lower = s.to_lowercase();
585 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
586 }
587
588 pub fn test_cases(&self) -> &[WafBenchTestCase] {
590 &self.test_cases
591 }
592
593 pub fn stats(&self) -> &WafBenchStats {
595 &self.stats
596 }
597
598 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
600 let mut payloads = Vec::new();
601
602 for test_case in &self.test_cases {
603 for payload in &test_case.payloads {
604 let payload_str = self.extract_payload_value(&payload.value);
606
607 payloads.push(
608 SecurityPayload::new(
609 payload_str,
610 test_case.category,
611 format!(
612 "[WAFBench {}] {} ({})",
613 test_case.rule_id, test_case.description, payload.location
614 ),
615 )
616 .high_risk(),
617 );
618 }
619 }
620
621 payloads
622 }
623
624 fn extract_payload_value(&self, value: &str) -> String {
626 if value.contains('?') {
628 if let Some(query) = value.split('?').nth(1) {
629 for param in query.split('&') {
631 if let Some(val) = param.split('=').nth(1) {
632 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
633 if self.looks_like_attack(&decoded) {
634 return decoded.to_string();
635 }
636 }
637 }
638 }
639 }
640
641 value.to_string()
643 }
644}
645
646impl Default for WafBenchLoader {
647 fn default() -> Self {
648 Self::new()
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_parse_wafbench_yaml() {
658 let yaml = r#"
659meta:
660 author: test
661 description: Test XSS rules
662 enabled: true
663 name: test.yaml
664
665tests:
666 - desc: "XSS in URI parameter"
667 test_title: "941100-1"
668 stages:
669 - input:
670 dest_addr: "127.0.0.1"
671 headers:
672 Host: "localhost"
673 User-Agent: "Mozilla/5.0"
674 method: "GET"
675 port: 80
676 uri: "/test?param=<script>alert(1)</script>"
677 output:
678 status: [403]
679"#;
680
681 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
682 assert!(file.meta.enabled);
683 assert_eq!(file.tests.len(), 1);
684 assert_eq!(file.tests[0].test_title, "941100-1");
685 }
686
687 #[test]
688 fn test_detect_category() {
689 let loader = WafBenchLoader::new();
690 let meta = WafBenchMeta {
691 author: None,
692 description: None,
693 enabled: true,
694 name: None,
695 };
696
697 assert_eq!(
698 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
699 SecurityCategory::Xss
700 );
701
702 assert_eq!(
703 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
704 SecurityCategory::SqlInjection
705 );
706 }
707
708 #[test]
709 fn test_looks_like_attack() {
710 let loader = WafBenchLoader::new();
711
712 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
713 assert!(loader.looks_like_attack("' OR '1'='1"));
714 assert!(loader.looks_like_attack("../../../etc/passwd"));
715 assert!(loader.looks_like_attack("; ls -la"));
716 assert!(!loader.looks_like_attack("normal text"));
717 assert!(!loader.looks_like_attack("hello world"));
718 }
719
720 #[test]
721 fn test_extract_payload_value() {
722 let loader = WafBenchLoader::new();
723
724 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
725 let payload = loader.extract_payload_value(uri);
726 assert!(payload.contains("<script>") || payload.contains("script"));
727 }
728
729 #[test]
730 fn test_parse_crs_v33_format() {
731 let yaml = r#"
733meta:
734 author: "Christian Folini"
735 description: Various SQL injection tests
736 enabled: true
737 name: 942100.yaml
738
739tests:
740 - test_title: 942100-1
741 desc: "Simple SQL Injection"
742 stages:
743 - stage:
744 input:
745 dest_addr: 127.0.0.1
746 headers:
747 Host: localhost
748 method: POST
749 port: 80
750 uri: "/"
751 data: "var=1234 OR 1=1"
752 version: HTTP/1.0
753 output:
754 log_contains: id "942100"
755"#;
756
757 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
758 assert!(file.meta.enabled);
759 assert_eq!(file.tests.len(), 1);
760 assert_eq!(file.tests[0].test_title, "942100-1");
761
762 let stage = &file.tests[0].stages[0];
764 let input = stage.get_input().expect("Should have input");
765 assert_eq!(input.method, "POST");
766 assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
767 }
768}