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)]
78pub struct WafBenchStage {
79 pub input: WafBenchInput,
81 pub output: Option<WafBenchOutput>,
83}
84
85#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct WafBenchInput {
88 pub dest_addr: Option<String>,
90 #[serde(default)]
92 pub headers: HashMap<String, String>,
93 #[serde(default = "default_method")]
95 pub method: String,
96 #[serde(default = "default_port")]
98 pub port: u16,
99 pub uri: Option<String>,
101 pub data: Option<String>,
103 pub version: Option<String>,
105}
106
107fn default_method() -> String {
108 "GET".to_string()
109}
110
111fn default_port() -> u16 {
112 80
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
117pub struct WafBenchOutput {
118 #[serde(default)]
120 pub status: Vec<u16>,
121 #[serde(default)]
123 pub response_headers: HashMap<String, String>,
124 #[serde(default)]
126 pub log_contains: Vec<String>,
127 #[serde(default)]
129 pub no_log_contains: Vec<String>,
130}
131
132#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct WafBenchFile {
135 pub meta: WafBenchMeta,
137 #[serde(default)]
139 pub tests: Vec<WafBenchTest>,
140}
141
142#[derive(Debug, Clone)]
144pub struct WafBenchTestCase {
145 pub test_id: String,
147 pub description: String,
149 pub rule_id: String,
151 pub category: SecurityCategory,
153 pub method: String,
155 pub payloads: Vec<WafBenchPayload>,
157 pub expects_block: bool,
159}
160
161#[derive(Debug, Clone)]
163pub struct WafBenchPayload {
164 pub location: PayloadLocation,
166 pub value: String,
168 pub header_name: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum PayloadLocation {
175 Uri,
177 Header,
179 Body,
181}
182
183impl std::fmt::Display for PayloadLocation {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 match self {
186 Self::Uri => write!(f, "uri"),
187 Self::Header => write!(f, "header"),
188 Self::Body => write!(f, "body"),
189 }
190 }
191}
192
193pub struct WafBenchLoader {
195 test_cases: Vec<WafBenchTestCase>,
197 stats: WafBenchStats,
199}
200
201#[derive(Debug, Clone, Default)]
203pub struct WafBenchStats {
204 pub files_processed: usize,
206 pub test_cases_loaded: usize,
208 pub payloads_extracted: usize,
210 pub by_category: HashMap<SecurityCategory, usize>,
212 pub parse_errors: Vec<String>,
214}
215
216impl WafBenchLoader {
217 pub fn new() -> Self {
219 Self {
220 test_cases: Vec::new(),
221 stats: WafBenchStats::default(),
222 }
223 }
224
225 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
236 if !pattern.contains('*') && !pattern.contains('?') {
238 return self.load_from_directory(Path::new(pattern));
239 }
240
241 let entries = glob(pattern).map_err(|e| {
243 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
244 })?;
245
246 for entry in entries {
247 match entry {
248 Ok(path) => {
249 if path.is_file()
250 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
251 {
252 if let Err(e) = self.load_file(&path) {
253 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
254 }
255 } else if path.is_dir() {
256 if let Err(e) = self.load_from_directory(&path) {
257 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
258 }
259 }
260 }
261 Err(e) => {
262 self.stats.parse_errors.push(format!("Glob error: {}", e));
263 }
264 }
265 }
266
267 Ok(())
268 }
269
270 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
272 if !dir.is_dir() {
273 return Err(BenchError::Other(format!(
274 "WAFBench path is not a directory: {}",
275 dir.display()
276 )));
277 }
278
279 self.load_directory_recursive(dir)?;
280 Ok(())
281 }
282
283 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
284 let entries = std::fs::read_dir(dir)
285 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
286
287 for entry in entries.flatten() {
288 let path = entry.path();
289 if path.is_dir() {
290 self.load_directory_recursive(&path)?;
292 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
293 if let Err(e) = self.load_file(&path) {
294 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
295 }
296 }
297 }
298
299 Ok(())
300 }
301
302 pub fn load_file(&mut self, path: &Path) -> Result<()> {
304 let content = std::fs::read_to_string(path).map_err(|e| {
305 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
306 })?;
307
308 let wafbench_file: WafBenchFile = serde_yaml::from_str(&content).map_err(|e| {
309 BenchError::Other(format!("Failed to parse WAFBench YAML {}: {}", path.display(), e))
310 })?;
311
312 if !wafbench_file.meta.enabled {
314 return Ok(());
315 }
316
317 self.stats.files_processed += 1;
318
319 let category = self.detect_category(path, &wafbench_file.meta);
321
322 for test in wafbench_file.tests {
324 if let Some(test_case) = self.parse_test_case(&test, category) {
325 self.stats.payloads_extracted += test_case.payloads.len();
326 *self.stats.by_category.entry(category).or_insert(0) += 1;
327 self.test_cases.push(test_case);
328 self.stats.test_cases_loaded += 1;
329 }
330 }
331
332 Ok(())
333 }
334
335 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
337 let path_str = path.to_string_lossy().to_uppercase();
338
339 if path_str.contains("XSS") || path_str.contains("941") {
340 SecurityCategory::Xss
341 } else if path_str.contains("SQLI") || path_str.contains("942") {
342 SecurityCategory::SqlInjection
343 } else if path_str.contains("RCE") || path_str.contains("932") {
344 SecurityCategory::CommandInjection
345 } else if path_str.contains("LFI") || path_str.contains("930") {
346 SecurityCategory::PathTraversal
347 } else if path_str.contains("LDAP") {
348 SecurityCategory::LdapInjection
349 } else if path_str.contains("XXE") || path_str.contains("XML") {
350 SecurityCategory::Xxe
351 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
352 SecurityCategory::Ssti
353 } else {
354 SecurityCategory::Xss
356 }
357 }
358
359 fn parse_test_case(
361 &self,
362 test: &WafBenchTest,
363 category: SecurityCategory,
364 ) -> Option<WafBenchTestCase> {
365 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
367
368 let mut payloads = Vec::new();
369 let mut method = "GET".to_string();
370 let mut expects_block = false;
371
372 for stage in &test.stages {
373 method = stage.input.method.clone();
374
375 if let Some(output) = &stage.output {
377 if output.status.contains(&403) {
378 expects_block = true;
379 }
380 }
381
382 if let Some(uri) = &stage.input.uri {
384 if self.looks_like_attack(uri) {
386 payloads.push(WafBenchPayload {
387 location: PayloadLocation::Uri,
388 value: uri.clone(),
389 header_name: None,
390 });
391 }
392 }
393
394 for (header_name, header_value) in &stage.input.headers {
396 if self.looks_like_attack(header_value) {
397 payloads.push(WafBenchPayload {
398 location: PayloadLocation::Header,
399 value: header_value.clone(),
400 header_name: Some(header_name.clone()),
401 });
402 }
403 }
404
405 if let Some(data) = &stage.input.data {
407 if self.looks_like_attack(data) {
408 payloads.push(WafBenchPayload {
409 location: PayloadLocation::Body,
410 value: data.clone(),
411 header_name: None,
412 });
413 }
414 }
415 }
416
417 if payloads.is_empty() {
419 if let Some(stage) = test.stages.first() {
420 if let Some(uri) = &stage.input.uri {
421 payloads.push(WafBenchPayload {
422 location: PayloadLocation::Uri,
423 value: uri.clone(),
424 header_name: None,
425 });
426 }
427 }
428 }
429
430 if payloads.is_empty() {
431 return None;
432 }
433
434 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
435
436 Some(WafBenchTestCase {
437 test_id: test.test_title.clone(),
438 description,
439 rule_id,
440 category,
441 method,
442 payloads,
443 expects_block,
444 })
445 }
446
447 fn looks_like_attack(&self, s: &str) -> bool {
449 let attack_patterns = [
451 "<script",
452 "javascript:",
453 "onerror=",
454 "onload=",
455 "onclick=",
456 "onfocus=",
457 "onmouseover=",
458 "eval(",
459 "alert(",
460 "document.",
461 "window.",
462 "'--",
463 "' OR ",
464 "' AND ",
465 "1=1",
466 "UNION SELECT",
467 "CONCAT(",
468 "CHAR(",
469 "../",
470 "..\\",
471 "/etc/passwd",
472 "cmd.exe",
473 "powershell",
474 "; ls",
475 "| cat",
476 "${",
477 "{{",
478 "<%",
479 "<?",
480 "<!ENTITY",
481 "SYSTEM \"",
482 ];
483
484 let lower = s.to_lowercase();
485 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
486 }
487
488 pub fn test_cases(&self) -> &[WafBenchTestCase] {
490 &self.test_cases
491 }
492
493 pub fn stats(&self) -> &WafBenchStats {
495 &self.stats
496 }
497
498 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
500 let mut payloads = Vec::new();
501
502 for test_case in &self.test_cases {
503 for payload in &test_case.payloads {
504 let payload_str = self.extract_payload_value(&payload.value);
506
507 payloads.push(
508 SecurityPayload::new(
509 payload_str,
510 test_case.category,
511 format!(
512 "[WAFBench {}] {} ({})",
513 test_case.rule_id, test_case.description, payload.location
514 ),
515 )
516 .high_risk(),
517 );
518 }
519 }
520
521 payloads
522 }
523
524 fn extract_payload_value(&self, value: &str) -> String {
526 if value.contains('?') {
528 if let Some(query) = value.split('?').nth(1) {
529 for param in query.split('&') {
531 if let Some(val) = param.split('=').nth(1) {
532 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
533 if self.looks_like_attack(&decoded) {
534 return decoded.to_string();
535 }
536 }
537 }
538 }
539 }
540
541 value.to_string()
543 }
544}
545
546impl Default for WafBenchLoader {
547 fn default() -> Self {
548 Self::new()
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_parse_wafbench_yaml() {
558 let yaml = r#"
559meta:
560 author: test
561 description: Test XSS rules
562 enabled: true
563 name: test.yaml
564
565tests:
566 - desc: "XSS in URI parameter"
567 test_title: "941100-1"
568 stages:
569 - input:
570 dest_addr: "127.0.0.1"
571 headers:
572 Host: "localhost"
573 User-Agent: "Mozilla/5.0"
574 method: "GET"
575 port: 80
576 uri: "/test?param=<script>alert(1)</script>"
577 output:
578 status: [403]
579"#;
580
581 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
582 assert!(file.meta.enabled);
583 assert_eq!(file.tests.len(), 1);
584 assert_eq!(file.tests[0].test_title, "941100-1");
585 }
586
587 #[test]
588 fn test_detect_category() {
589 let loader = WafBenchLoader::new();
590 let meta = WafBenchMeta {
591 author: None,
592 description: None,
593 enabled: true,
594 name: None,
595 };
596
597 assert_eq!(
598 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
599 SecurityCategory::Xss
600 );
601
602 assert_eq!(
603 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
604 SecurityCategory::SqlInjection
605 );
606 }
607
608 #[test]
609 fn test_looks_like_attack() {
610 let loader = WafBenchLoader::new();
611
612 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
613 assert!(loader.looks_like_attack("' OR '1'='1"));
614 assert!(loader.looks_like_attack("../../../etc/passwd"));
615 assert!(loader.looks_like_attack("; ls -la"));
616 assert!(!loader.looks_like_attack("normal text"));
617 assert!(!loader.looks_like_attack("hello world"));
618 }
619
620 #[test]
621 fn test_extract_payload_value() {
622 let loader = WafBenchLoader::new();
623
624 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
625 let payload = loader.extract_payload_value(uri);
626 assert!(payload.contains("<script>") || payload.contains("script"));
627 }
628}