1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct WafBenchMeta {
51 pub author: Option<String>,
53 pub description: Option<String>,
55 #[serde(default = "default_enabled")]
57 pub enabled: bool,
58 pub name: Option<String>,
60}
61
62fn default_enabled() -> bool {
63 true
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68pub struct WafBenchTest {
69 pub desc: Option<String>,
71 pub test_title: String,
73 #[serde(default)]
75 pub stages: Vec<WafBenchStage>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct WafBenchStage {
82 pub input: Option<WafBenchInput>,
84 pub output: Option<WafBenchOutput>,
86 pub stage: Option<WafBenchStageInner>,
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct WafBenchStageInner {
93 pub input: WafBenchInput,
95 pub output: Option<WafBenchOutput>,
97}
98
99impl WafBenchStage {
100 pub fn get_input(&self) -> Option<&WafBenchInput> {
102 if let Some(stage) = &self.stage {
104 Some(&stage.input)
105 } else {
106 self.input.as_ref()
107 }
108 }
109
110 pub fn get_output(&self) -> Option<&WafBenchOutput> {
112 if let Some(stage) = &self.stage {
114 stage.output.as_ref()
115 } else {
116 self.output.as_ref()
117 }
118 }
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct WafBenchInput {
124 pub dest_addr: Option<String>,
126 #[serde(default)]
128 pub headers: HashMap<String, String>,
129 #[serde(default = "default_method")]
131 pub method: String,
132 #[serde(default = "default_port")]
134 pub port: u16,
135 pub uri: Option<String>,
137 pub data: Option<String>,
139 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#[derive(Debug, Clone, Deserialize, Serialize)]
153pub struct WafBenchOutput {
154 #[serde(default)]
156 pub status: Vec<u16>,
157 #[serde(default)]
159 pub response_headers: HashMap<String, String>,
160 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
162 pub log_contains: Vec<String>,
163 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
165 pub no_log_contains: Vec<String>,
166}
167
168fn 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#[derive(Debug, Clone, Deserialize, Serialize)]
229pub struct WafBenchFile {
230 pub meta: WafBenchMeta,
232 #[serde(default)]
234 pub tests: Vec<WafBenchTest>,
235}
236
237#[derive(Debug, Clone)]
239pub struct WafBenchTestCase {
240 pub test_id: String,
242 pub description: String,
244 pub rule_id: String,
246 pub category: SecurityCategory,
248 pub method: String,
250 pub payloads: Vec<WafBenchPayload>,
252 pub expects_block: bool,
254}
255
256#[derive(Debug, Clone)]
258pub struct WafBenchPayload {
259 pub location: PayloadLocation,
261 pub value: String,
263 pub header_name: Option<String>,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum PayloadLocation {
270 Uri,
272 Header,
274 Body,
276}
277
278impl std::fmt::Display for PayloadLocation {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match self {
281 Self::Uri => write!(f, "uri"),
282 Self::Header => write!(f, "header"),
283 Self::Body => write!(f, "body"),
284 }
285 }
286}
287
288pub struct WafBenchLoader {
290 test_cases: Vec<WafBenchTestCase>,
292 stats: WafBenchStats,
294}
295
296#[derive(Debug, Clone, Default)]
298pub struct WafBenchStats {
299 pub files_processed: usize,
301 pub test_cases_loaded: usize,
303 pub payloads_extracted: usize,
305 pub by_category: HashMap<SecurityCategory, usize>,
307 pub parse_errors: Vec<String>,
309}
310
311impl WafBenchLoader {
312 pub fn new() -> Self {
314 Self {
315 test_cases: Vec::new(),
316 stats: WafBenchStats::default(),
317 }
318 }
319
320 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
331 if !pattern.contains('*') && !pattern.contains('?') {
333 let path = Path::new(pattern);
334 if path.is_file() {
335 return self.load_file(path);
337 } else if path.is_dir() {
338 return self.load_from_directory(path);
339 } else {
340 return Err(BenchError::Other(format!(
341 "WAFBench path does not exist: {}",
342 pattern
343 )));
344 }
345 }
346
347 let entries = glob(pattern).map_err(|e| {
349 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
350 })?;
351
352 for entry in entries {
353 match entry {
354 Ok(path) => {
355 if path.is_file()
356 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
357 {
358 if let Err(e) = self.load_file(&path) {
359 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
360 }
361 } else if path.is_dir() {
362 if let Err(e) = self.load_from_directory(&path) {
363 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
364 }
365 }
366 }
367 Err(e) => {
368 self.stats.parse_errors.push(format!("Glob error: {}", e));
369 }
370 }
371 }
372
373 Ok(())
374 }
375
376 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
378 if !dir.is_dir() {
379 return Err(BenchError::Other(format!(
380 "WAFBench path is not a directory: {}",
381 dir.display()
382 )));
383 }
384
385 self.load_directory_recursive(dir)?;
386 Ok(())
387 }
388
389 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
390 let entries = std::fs::read_dir(dir)
391 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
392
393 for entry in entries.flatten() {
394 let path = entry.path();
395 if path.is_dir() {
396 self.load_directory_recursive(&path)?;
398 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
399 if let Err(e) = self.load_file(&path) {
400 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
401 }
402 }
403 }
404
405 Ok(())
406 }
407
408 pub fn load_file(&mut self, path: &Path) -> Result<()> {
410 let content = std::fs::read_to_string(path).map_err(|e| {
411 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
412 })?;
413
414 let wafbench_file: WafBenchFile = serde_yaml::from_str(&content).map_err(|e| {
415 BenchError::Other(format!("Failed to parse WAFBench YAML {}: {}", path.display(), e))
416 })?;
417
418 if !wafbench_file.meta.enabled {
420 return Ok(());
421 }
422
423 self.stats.files_processed += 1;
424
425 let category = self.detect_category(path, &wafbench_file.meta);
427
428 for test in wafbench_file.tests {
430 if let Some(test_case) = self.parse_test_case(&test, category) {
431 self.stats.payloads_extracted += test_case.payloads.len();
432 *self.stats.by_category.entry(category).or_insert(0) += 1;
433 self.test_cases.push(test_case);
434 self.stats.test_cases_loaded += 1;
435 }
436 }
437
438 Ok(())
439 }
440
441 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
443 let path_str = path.to_string_lossy().to_uppercase();
444
445 if path_str.contains("XSS") || path_str.contains("941") {
446 SecurityCategory::Xss
447 } else if path_str.contains("SQLI") || path_str.contains("942") {
448 SecurityCategory::SqlInjection
449 } else if path_str.contains("RCE") || path_str.contains("932") {
450 SecurityCategory::CommandInjection
451 } else if path_str.contains("LFI") || path_str.contains("930") {
452 SecurityCategory::PathTraversal
453 } else if path_str.contains("LDAP") {
454 SecurityCategory::LdapInjection
455 } else if path_str.contains("XXE") || path_str.contains("XML") {
456 SecurityCategory::Xxe
457 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
458 SecurityCategory::Ssti
459 } else {
460 SecurityCategory::Xss
462 }
463 }
464
465 fn parse_test_case(
467 &self,
468 test: &WafBenchTest,
469 category: SecurityCategory,
470 ) -> Option<WafBenchTestCase> {
471 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
473
474 let mut payloads = Vec::new();
475 let mut method = "GET".to_string();
476 let mut expects_block = false;
477
478 for stage in &test.stages {
479 let Some(input) = stage.get_input() else {
481 continue;
482 };
483
484 method = input.method.clone();
485
486 if let Some(output) = stage.get_output() {
488 if output.status.contains(&403) {
489 expects_block = true;
490 }
491 }
492
493 if let Some(uri) = &input.uri {
495 if self.looks_like_attack(uri) {
497 payloads.push(WafBenchPayload {
498 location: PayloadLocation::Uri,
499 value: uri.clone(),
500 header_name: None,
501 });
502 }
503 }
504
505 for (header_name, header_value) in &input.headers {
507 if self.looks_like_attack(header_value) {
508 payloads.push(WafBenchPayload {
509 location: PayloadLocation::Header,
510 value: header_value.clone(),
511 header_name: Some(header_name.clone()),
512 });
513 }
514 }
515
516 if let Some(data) = &input.data {
518 if self.looks_like_attack(data) {
519 payloads.push(WafBenchPayload {
520 location: PayloadLocation::Body,
521 value: data.clone(),
522 header_name: None,
523 });
524 }
525 }
526 }
527
528 if payloads.is_empty() {
530 if let Some(stage) = test.stages.first() {
531 if let Some(input) = stage.get_input() {
532 if let Some(uri) = &input.uri {
533 payloads.push(WafBenchPayload {
534 location: PayloadLocation::Uri,
535 value: uri.clone(),
536 header_name: None,
537 });
538 }
539 }
540 }
541 }
542
543 if payloads.is_empty() {
544 return None;
545 }
546
547 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
548
549 Some(WafBenchTestCase {
550 test_id: test.test_title.clone(),
551 description,
552 rule_id,
553 category,
554 method,
555 payloads,
556 expects_block,
557 })
558 }
559
560 fn looks_like_attack(&self, s: &str) -> bool {
562 let attack_patterns = [
564 "<script",
565 "javascript:",
566 "onerror=",
567 "onload=",
568 "onclick=",
569 "onfocus=",
570 "onmouseover=",
571 "eval(",
572 "alert(",
573 "document.",
574 "window.",
575 "'--",
576 "' OR ",
577 "' AND ",
578 "1=1",
579 "UNION SELECT",
580 "CONCAT(",
581 "CHAR(",
582 "../",
583 "..\\",
584 "/etc/passwd",
585 "cmd.exe",
586 "powershell",
587 "; ls",
588 "| cat",
589 "${",
590 "{{",
591 "<%",
592 "<?",
593 "<!ENTITY",
594 "SYSTEM \"",
595 ];
596
597 let lower = s.to_lowercase();
598 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
599 }
600
601 pub fn test_cases(&self) -> &[WafBenchTestCase] {
603 &self.test_cases
604 }
605
606 pub fn stats(&self) -> &WafBenchStats {
608 &self.stats
609 }
610
611 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
613 let mut payloads = Vec::new();
614
615 for test_case in &self.test_cases {
616 for payload in &test_case.payloads {
617 let payload_str = self.extract_payload_value(&payload.value);
619
620 let location = match payload.location {
622 PayloadLocation::Uri => SecurityPayloadLocation::Uri,
623 PayloadLocation::Header => SecurityPayloadLocation::Header,
624 PayloadLocation::Body => SecurityPayloadLocation::Body,
625 };
626
627 let mut sec_payload = SecurityPayload::new(
628 payload_str,
629 test_case.category,
630 format!(
631 "[WAFBench {}] {} ({})",
632 test_case.rule_id, test_case.description, payload.location
633 ),
634 )
635 .high_risk()
636 .with_location(location);
637
638 if let Some(header_name) = &payload.header_name {
640 sec_payload = sec_payload.with_header_name(header_name.clone());
641 }
642
643 payloads.push(sec_payload);
644 }
645 }
646
647 payloads
648 }
649
650 fn extract_payload_value(&self, value: &str) -> String {
652 if value.contains('?') {
654 if let Some(query) = value.split('?').nth(1) {
655 for param in query.split('&') {
657 if let Some(val) = param.split('=').nth(1) {
658 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
659 if self.looks_like_attack(&decoded) {
660 return decoded.to_string();
661 }
662 }
663 }
664 }
665 }
666
667 value.to_string()
669 }
670}
671
672impl Default for WafBenchLoader {
673 fn default() -> Self {
674 Self::new()
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn test_parse_wafbench_yaml() {
684 let yaml = r#"
685meta:
686 author: test
687 description: Test XSS rules
688 enabled: true
689 name: test.yaml
690
691tests:
692 - desc: "XSS in URI parameter"
693 test_title: "941100-1"
694 stages:
695 - input:
696 dest_addr: "127.0.0.1"
697 headers:
698 Host: "localhost"
699 User-Agent: "Mozilla/5.0"
700 method: "GET"
701 port: 80
702 uri: "/test?param=<script>alert(1)</script>"
703 output:
704 status: [403]
705"#;
706
707 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
708 assert!(file.meta.enabled);
709 assert_eq!(file.tests.len(), 1);
710 assert_eq!(file.tests[0].test_title, "941100-1");
711 }
712
713 #[test]
714 fn test_detect_category() {
715 let loader = WafBenchLoader::new();
716 let meta = WafBenchMeta {
717 author: None,
718 description: None,
719 enabled: true,
720 name: None,
721 };
722
723 assert_eq!(
724 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
725 SecurityCategory::Xss
726 );
727
728 assert_eq!(
729 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
730 SecurityCategory::SqlInjection
731 );
732 }
733
734 #[test]
735 fn test_looks_like_attack() {
736 let loader = WafBenchLoader::new();
737
738 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
739 assert!(loader.looks_like_attack("' OR '1'='1"));
740 assert!(loader.looks_like_attack("../../../etc/passwd"));
741 assert!(loader.looks_like_attack("; ls -la"));
742 assert!(!loader.looks_like_attack("normal text"));
743 assert!(!loader.looks_like_attack("hello world"));
744 }
745
746 #[test]
747 fn test_extract_payload_value() {
748 let loader = WafBenchLoader::new();
749
750 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
751 let payload = loader.extract_payload_value(uri);
752 assert!(payload.contains("<script>") || payload.contains("script"));
753 }
754
755 #[test]
756 fn test_parse_crs_v33_format() {
757 let yaml = r#"
759meta:
760 author: "Christian Folini"
761 description: Various SQL injection tests
762 enabled: true
763 name: 942100.yaml
764
765tests:
766 - test_title: 942100-1
767 desc: "Simple SQL Injection"
768 stages:
769 - stage:
770 input:
771 dest_addr: 127.0.0.1
772 headers:
773 Host: localhost
774 method: POST
775 port: 80
776 uri: "/"
777 data: "var=1234 OR 1=1"
778 version: HTTP/1.0
779 output:
780 log_contains: id "942100"
781"#;
782
783 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
784 assert!(file.meta.enabled);
785 assert_eq!(file.tests.len(), 1);
786 assert_eq!(file.tests[0].test_title, "942100-1");
787
788 let stage = &file.tests[0].stages[0];
790 let input = stage.get_input().expect("Should have input");
791 assert_eq!(input.method, "POST");
792 assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
793 }
794}