1use serde::Deserialize;
4
5#[derive(Debug, Clone, Deserialize)]
7pub struct CustomRulesFile {
8 #[serde(default)]
10 pub waf: Vec<CustomWafRule>,
11}
12
13#[derive(Debug, Clone, Deserialize)]
15pub struct CustomWafRule {
16 pub name: String,
18 #[serde(default)]
20 pub vendor: String,
21 #[serde(default)]
23 pub header_signatures: Vec<HeaderSignature>,
24 #[serde(default)]
26 pub body_signatures: Vec<BodySignature>,
27 #[serde(default)]
29 pub block_status_codes: Vec<u16>,
30 #[serde(default)]
32 pub evasion_strategies: Vec<String>,
33}
34
35#[derive(Debug, Clone, Deserialize)]
37pub struct HeaderSignature {
38 pub name: String,
40 #[serde(default)]
42 pub value_contains: Option<String>,
43 #[serde(default = "default_confidence")]
45 pub confidence: f64,
46}
47
48#[derive(Debug, Clone, Deserialize)]
50pub struct BodySignature {
51 pub pattern: String,
53 #[serde(default = "default_confidence")]
55 pub confidence: f64,
56}
57
58fn default_confidence() -> f64 {
59 0.5
60}
61
62#[derive(Debug, Clone)]
64pub struct CustomDetection {
65 pub rule_name: String,
66 pub vendor: String,
67 pub confidence: f64,
68 pub evasion_strategies: Vec<String>,
69}
70
71fn valid_evasion_strategies() -> Vec<String> {
73 let pool = crate::evolution::GenePool::default_wafrift();
74 let mut values = Vec::new();
76 if let Some(encoding_values) = pool.values_for("encoding") {
77 for v in encoding_values {
78 if v != "None" {
79 values.push(v.clone());
80 }
81 }
82 }
83 if let Some(content_values) = pool.values_for("content_type") {
84 for v in content_values {
85 if v != "None" {
86 values.push(v.clone());
87 }
88 }
89 }
90 if let Some(header_values) = pool.values_for("header_obfuscation") {
91 for v in header_values {
92 if v != "None" {
93 values.push(v.clone());
94 }
95 }
96 }
97 if let Some(grammar_values) = pool.values_for("grammar_rule") {
98 for v in grammar_values {
99 if v != "None" {
100 values.push(v.clone());
101 }
102 }
103 }
104 values.push("Base64Encode".into());
106 values.push("HexEncode".into());
107 values.push("Utf7Encode".into());
108 values.push("Multipart".into());
109 values.push("JsonNested".into());
110 values.push("XmlCdata".into());
111 values
112}
113
114const MAX_CUSTOM_RULES_BYTES: usize = 1024 * 1024;
119
120pub fn load_rules(toml_str: &str) -> std::result::Result<CustomRulesFile, String> {
123 if toml_str.len() > MAX_CUSTOM_RULES_BYTES {
124 return Err(format!(
125 "custom rules TOML rejected: {} bytes exceeds maximum of {} bytes",
126 toml_str.len(),
127 MAX_CUSTOM_RULES_BYTES
128 ));
129 }
130 let rules: CustomRulesFile =
131 toml::from_str(toml_str).map_err(|e| format!("failed to parse custom rules TOML: {e}"))?;
132 validate_rules(&rules)?;
133 validate_evasion_strategies(&rules)?;
134 Ok(rules)
135}
136
137fn validate_rules(rules: &CustomRulesFile) -> std::result::Result<(), String> {
138 for (idx, waf) in rules.waf.iter().enumerate() {
139 if waf.name.trim().is_empty() {
140 return Err(format!(
141 "validation error: waf[{}] missing required field 'name'",
142 idx
143 ));
144 }
145 for (sig_idx, sig) in waf.header_signatures.iter().enumerate() {
146 if sig.name.trim().is_empty() {
147 return Err(format!(
148 "validation error: waf[{}].header_signatures[{}] missing required field 'name'",
149 idx, sig_idx
150 ));
151 }
152 if !(0.0..=1.0).contains(&sig.confidence) {
153 return Err(format!(
154 "validation error: waf[{}].header_signatures[{}] confidence must be between 0.0 and 1.0, got {}",
155 idx, sig_idx, sig.confidence
156 ));
157 }
158 }
159 for (sig_idx, sig) in waf.body_signatures.iter().enumerate() {
160 if sig.pattern.trim().is_empty() {
161 return Err(format!(
162 "validation error: waf[{}].body_signatures[{}] missing required field 'pattern'",
163 idx, sig_idx
164 ));
165 }
166 if !(0.0..=1.0).contains(&sig.confidence) {
167 return Err(format!(
168 "validation error: waf[{}].body_signatures[{}] confidence must be between 0.0 and 1.0, got {}",
169 idx, sig_idx, sig.confidence
170 ));
171 }
172 }
173 for code in &waf.block_status_codes {
174 if *code == 0 || *code > 999 {
175 return Err(format!(
176 "validation error: waf[{}] invalid status code {} (must be 1-999)",
177 idx, code
178 ));
179 }
180 }
181 }
182 Ok(())
183}
184
185fn validate_evasion_strategies(rules: &CustomRulesFile) -> std::result::Result<(), String> {
186 let valid = valid_evasion_strategies();
187 let mut unknown_strategies: Vec<(usize, String)> = Vec::new();
188 for (waf_idx, waf) in rules.waf.iter().enumerate() {
189 for strategy in &waf.evasion_strategies {
190 if !valid.contains(strategy) {
191 unknown_strategies.push((waf_idx, strategy.clone()));
192 }
193 }
194 }
195 if !unknown_strategies.is_empty() {
196 let errors: Vec<String> = unknown_strategies
197 .into_iter()
198 .map(|(idx, s)| format!("waf[{}]: unknown evasion_strategy '{}'", idx, s))
199 .collect();
200 return Err(format!(
201 "validation error: invalid evasion_strategies found:\n - {}",
202 errors.join("\n - ")
203 ));
204 }
205 Ok(())
206}
207
208pub fn load_rules_from_file(
210 path: &std::path::Path,
211) -> std::result::Result<CustomRulesFile, String> {
212 let content = std::fs::read_to_string(path)
213 .map_err(|e| format!("failed to read rules file {}: {}", path.display(), e))?;
214 load_rules(&content)
215}
216
217#[must_use]
219pub fn detect(
220 rules: &CustomRulesFile,
221 status: u16,
222 headers: &[(String, String)],
223 body: &[u8],
224) -> Option<CustomDetection> {
225 let body_str = String::from_utf8_lossy(&body[..body.len().min(4096)]).to_ascii_lowercase();
226 let mut best: Option<CustomDetection> = None;
227 for rule in &rules.waf {
228 let mut max_confidence: f64 = 0.0;
229 let mut matched = false;
230 if rule.block_status_codes.contains(&status) {
231 max_confidence = max_confidence.max(0.3);
232 matched = true;
233 }
234 for sig in &rule.header_signatures {
235 let header_match = headers.iter().any(|(name, value)| {
236 if !name.eq_ignore_ascii_case(&sig.name) {
237 return false;
238 }
239 match &sig.value_contains {
240 Some(substring) => value
241 .to_ascii_lowercase()
242 .contains(&substring.to_ascii_lowercase()),
243 None => true,
244 }
245 });
246 if header_match {
247 max_confidence = max_confidence.max(sig.confidence);
248 matched = true;
249 }
250 }
251 for sig in &rule.body_signatures {
252 if body_str.contains(&sig.pattern.to_ascii_lowercase()) {
253 max_confidence = max_confidence.max(sig.confidence);
254 matched = true;
255 }
256 }
257 if matched && max_confidence > best.as_ref().map_or(0.0, |b| b.confidence) {
258 best = Some(CustomDetection {
259 rule_name: rule.name.clone(),
260 vendor: rule.vendor.clone(),
261 confidence: max_confidence,
262 evasion_strategies: rule.evasion_strategies.clone(),
263 });
264 }
265 }
266 best
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 const SAMPLE_TOML: &str = r#"
274[[waf]]
275name = "TestWAF"
276vendor = "test-vendor"
277block_status_codes = [403, 406]
278evasion_strategies = ["DoubleUrlEncode", "SqlCommentInsertion"]
279
280[[waf.header_signatures]]
281name = "x-test-waf"
282confidence = 0.9
283
284[[waf.header_signatures]]
285name = "server"
286value_contains = "TestWAF"
287confidence = 0.8
288
289[[waf.body_signatures]]
290pattern = "Blocked by TestWAF"
291confidence = 0.95
292
293[[waf]]
294name = "AnotherWAF"
295vendor = "another"
296block_status_codes = [429]
297evasion_strategies = ["CaseAlternation"]
298
299[[waf.header_signatures]]
300name = "x-another-waf"
301confidence = 0.7
302"#;
303
304 #[test]
305 fn load_rules_basic() {
306 let rules = load_rules(SAMPLE_TOML).expect("should parse");
307 assert_eq!(rules.waf.len(), 2);
308 assert_eq!(rules.waf[0].name, "TestWAF");
309 assert_eq!(rules.waf[0].header_signatures.len(), 2);
310 assert_eq!(rules.waf[0].body_signatures.len(), 1);
311 assert_eq!(rules.waf[0].block_status_codes, vec![403, 406]);
312 assert_eq!(rules.waf[0].evasion_strategies.len(), 2);
313 }
314
315 #[test]
316 fn load_rules_empty() {
317 let rules = load_rules("").expect("empty should parse");
318 assert!(rules.waf.is_empty());
319 }
320
321 #[test]
322 fn load_rules_invalid_toml() {
323 let result = load_rules("this is not { valid toml");
324 assert!(result.is_err());
325 }
326
327 #[test]
328 fn detect_by_header() {
329 let rules = load_rules(SAMPLE_TOML).expect("should parse");
330 let headers = vec![("x-test-waf".into(), "active".into())];
331 let result = detect(&rules, 200, &headers, b"OK");
332 assert!(result.is_some());
333 let det = result.unwrap();
334 assert_eq!(det.rule_name, "TestWAF");
335 assert!((det.confidence - 0.9).abs() < 0.01);
336 }
337
338 #[test]
339 fn detect_by_body() {
340 let rules = load_rules(SAMPLE_TOML).expect("should parse");
341 let headers: Vec<(String, String)> = vec![];
342 let body = b"Error: Blocked by TestWAF engine";
343 let result = detect(&rules, 200, &headers, body);
344 assert!(result.is_some());
345 let det = result.unwrap();
346 assert_eq!(det.rule_name, "TestWAF");
347 assert!((det.confidence - 0.95).abs() < 0.01);
348 }
349
350 #[test]
351 fn detect_by_status() {
352 let rules = load_rules(SAMPLE_TOML).expect("should parse");
353 let headers: Vec<(String, String)> = vec![];
354 let result = detect(&rules, 403, &headers, b"");
355 assert!(result.is_some());
356 assert_eq!(result.unwrap().rule_name, "TestWAF");
357 }
358
359 #[test]
360 fn detect_no_match() {
361 let rules = load_rules(SAMPLE_TOML).expect("should parse");
362 let headers = vec![("server".into(), "nginx".into())];
363 let result = detect(&rules, 200, &headers, b"Welcome");
364 assert!(result.is_none());
365 }
366
367 #[test]
368 fn dynamic_strategy_validation_accepts_content_type_genes() {
369 let toml = r#"
370[[waf]]
371name = "Test"
372evasion_strategies = ["Multipart", "JsonNested"]
373"#;
374 let rules = load_rules(toml);
375 assert!(
376 rules.is_ok(),
377 "Multipart and JsonNested should be valid strategies"
378 );
379 }
380
381 #[test]
382 fn dynamic_strategy_validation_accepts_grammar_genes() {
383 let toml = r#"
384[[waf]]
385name = "Test"
386evasion_strategies = ["tautology_swap", "comment_swap"]
387"#;
388 let rules = load_rules(toml);
389 assert!(rules.is_ok(), "Grammar genes should be valid strategies");
390 }
391
392 #[test]
393 fn load_rules_rejects_oversized_payload() {
394 let huge = "x".repeat(1024 * 1024 + 1);
395 let result = load_rules(&huge);
396 assert!(result.is_err(), "should reject >1 MiB input");
397 let msg = result.unwrap_err();
398 assert!(msg.contains("exceeds maximum"), "error should mention size limit: {msg}");
399 }
400
401 #[test]
402 fn load_rules_rejects_empty_waf_name() {
403 let toml = r#"
404[[waf]]
405name = " "
406"#;
407 let result = load_rules(toml);
408 assert!(result.is_err(), "should reject empty/whitespace name");
409 }
410
411 #[test]
412 fn load_rules_rejects_invalid_confidence_high() {
413 let toml = r#"
414[[waf]]
415name = "Test"
416[[waf.header_signatures]]
417name = "X-Block"
418confidence = 1.5
419"#;
420 let result = load_rules(toml);
421 assert!(result.is_err(), "should reject confidence > 1.0");
422 }
423
424 #[test]
425 fn load_rules_rejects_invalid_confidence_negative() {
426 let toml = r#"
427[[waf]]
428name = "Test"
429[[waf.header_signatures]]
430name = "X-Block"
431confidence = -0.1
432"#;
433 let result = load_rules(toml);
434 assert!(result.is_err(), "should reject negative confidence");
435 }
436
437 #[test]
438 fn load_rules_rejects_invalid_status_code_zero() {
439 let toml = r#"
440[[waf]]
441name = "Test"
442block_status_codes = [0]
443"#;
444 let result = load_rules(toml);
445 assert!(result.is_err(), "should reject status code 0");
446 }
447
448 #[test]
449 fn load_rules_rejects_invalid_status_code_too_high() {
450 let toml = r#"
451[[waf]]
452name = "Test"
453block_status_codes = [1000]
454"#;
455 let result = load_rules(toml);
456 assert!(result.is_err(), "should reject status code > 999");
457 }
458
459 #[test]
460 fn load_rules_rejects_unknown_evasion_strategy() {
461 let toml = r#"
462[[waf]]
463name = "Test"
464evasion_strategies = ["DefinitelyNotRealStrategy123"]
465"#;
466 let result = load_rules(toml);
467 assert!(result.is_err(), "should reject unknown evasion strategy");
468 let msg = result.unwrap_err();
469 assert!(msg.contains("unknown evasion_strategy"), "error should name the strategy: {msg}");
470 }
471
472 #[test]
473 fn load_rules_rejects_empty_body_pattern() {
474 let toml = r#"
475[[waf]]
476name = "Test"
477[[waf.body_signatures]]
478pattern = " "
479"#;
480 let result = load_rules(toml);
481 assert!(result.is_err(), "should reject empty/whitespace body pattern");
482 }
483
484 #[test]
485 fn load_rules_rejects_empty_header_name() {
486 let toml = r#"
487[[waf]]
488name = "Test"
489[[waf.header_signatures]]
490name = " "
491"#;
492 let result = load_rules(toml);
493 assert!(result.is_err(), "should reject empty/whitespace header name");
494 }
495}