Skip to main content

wafrift_evolution/
custom_rules.rs

1//! Community-configurable WAF detection and evasion rules.
2
3use serde::Deserialize;
4
5/// A complete custom rules file containing multiple WAF definitions.
6#[derive(Debug, Clone, Deserialize)]
7pub struct CustomRulesFile {
8    /// WAF detection rules.
9    #[serde(default)]
10    pub waf: Vec<CustomWafRule>,
11}
12
13/// A single WAF detection and evasion rule.
14#[derive(Debug, Clone, Deserialize)]
15pub struct CustomWafRule {
16    /// Human-readable WAF name.
17    pub name: String,
18    /// Vendor or product family.
19    #[serde(default)]
20    pub vendor: String,
21    /// HTTP response header signatures.
22    #[serde(default)]
23    pub header_signatures: Vec<HeaderSignature>,
24    /// HTTP response body patterns.
25    #[serde(default)]
26    pub body_signatures: Vec<BodySignature>,
27    /// HTTP status codes that indicate blocking.
28    #[serde(default)]
29    pub block_status_codes: Vec<u16>,
30    /// Recommended evasion strategy names.
31    #[serde(default)]
32    pub evasion_strategies: Vec<String>,
33}
34
35/// A header-based WAF detection signature.
36#[derive(Debug, Clone, Deserialize)]
37pub struct HeaderSignature {
38    /// Header name to check (case-insensitive).
39    pub name: String,
40    /// If present, the header value must contain this substring.
41    #[serde(default)]
42    pub value_contains: Option<String>,
43    /// Detection confidence when this signature matches (0.0–1.0).
44    #[serde(default = "default_confidence")]
45    pub confidence: f64,
46}
47
48/// A body-based WAF detection signature.
49#[derive(Debug, Clone, Deserialize)]
50pub struct BodySignature {
51    /// Substring to search for in the response body (case-insensitive).
52    pub pattern: String,
53    /// Detection confidence when this signature matches (0.0–1.0).
54    #[serde(default = "default_confidence")]
55    pub confidence: f64,
56}
57
58fn default_confidence() -> f64 {
59    0.5
60}
61
62/// Result of matching a custom rule against a response.
63#[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
71/// Build the valid evasion strategy set dynamically from the gene pool.
72fn valid_evasion_strategies() -> Vec<String> {
73    let pool = crate::evolution::GenePool::default_wafrift();
74    // Include encoding values and content-type values as valid strategies
75    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    // Also include common aliases used in TOML rules
105    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
114/// Load custom rules from a TOML string.
115pub fn load_rules(toml_str: &str) -> std::result::Result<CustomRulesFile, String> {
116    let rules: CustomRulesFile =
117        toml::from_str(toml_str).map_err(|e| format!("failed to parse custom rules TOML: {e}"))?;
118    validate_rules(&rules)?;
119    validate_evasion_strategies(&rules)?;
120    Ok(rules)
121}
122
123fn validate_rules(rules: &CustomRulesFile) -> std::result::Result<(), String> {
124    for (idx, waf) in rules.waf.iter().enumerate() {
125        if waf.name.trim().is_empty() {
126            return Err(format!(
127                "validation error: waf[{}] missing required field 'name'",
128                idx
129            ));
130        }
131        for (sig_idx, sig) in waf.header_signatures.iter().enumerate() {
132            if sig.name.trim().is_empty() {
133                return Err(format!(
134                    "validation error: waf[{}].header_signatures[{}] missing required field 'name'",
135                    idx, sig_idx
136                ));
137            }
138            if !(0.0..=1.0).contains(&sig.confidence) {
139                return Err(format!(
140                    "validation error: waf[{}].header_signatures[{}] confidence must be between 0.0 and 1.0, got {}",
141                    idx, sig_idx, sig.confidence
142                ));
143            }
144        }
145        for (sig_idx, sig) in waf.body_signatures.iter().enumerate() {
146            if sig.pattern.trim().is_empty() {
147                return Err(format!(
148                    "validation error: waf[{}].body_signatures[{}] missing required field 'pattern'",
149                    idx, sig_idx
150                ));
151            }
152            if !(0.0..=1.0).contains(&sig.confidence) {
153                return Err(format!(
154                    "validation error: waf[{}].body_signatures[{}] confidence must be between 0.0 and 1.0, got {}",
155                    idx, sig_idx, sig.confidence
156                ));
157            }
158        }
159        for code in &waf.block_status_codes {
160            if *code == 0 || *code > 999 {
161                return Err(format!(
162                    "validation error: waf[{}] invalid status code {} (must be 1-999)",
163                    idx, code
164                ));
165            }
166        }
167    }
168    Ok(())
169}
170
171fn validate_evasion_strategies(rules: &CustomRulesFile) -> std::result::Result<(), String> {
172    let valid = valid_evasion_strategies();
173    let mut unknown_strategies: Vec<(usize, String)> = Vec::new();
174    for (waf_idx, waf) in rules.waf.iter().enumerate() {
175        for strategy in &waf.evasion_strategies {
176            if !valid.contains(strategy) {
177                unknown_strategies.push((waf_idx, strategy.clone()));
178            }
179        }
180    }
181    if !unknown_strategies.is_empty() {
182        let errors: Vec<String> = unknown_strategies
183            .into_iter()
184            .map(|(idx, s)| format!("waf[{}]: unknown evasion_strategy '{}'", idx, s))
185            .collect();
186        return Err(format!(
187            "validation error: invalid evasion_strategies found:\n  - {}",
188            errors.join("\n  - ")
189        ));
190    }
191    Ok(())
192}
193
194/// Load custom rules from a file path.
195pub fn load_rules_from_file(
196    path: &std::path::Path,
197) -> std::result::Result<CustomRulesFile, String> {
198    let content = std::fs::read_to_string(path)
199        .map_err(|e| format!("failed to read rules file {}: {}", path.display(), e))?;
200    load_rules(&content)
201}
202
203/// Match custom rules against an HTTP response.
204#[must_use]
205pub fn detect(
206    rules: &CustomRulesFile,
207    status: u16,
208    headers: &[(String, String)],
209    body: &[u8],
210) -> Option<CustomDetection> {
211    let body_str = String::from_utf8_lossy(&body[..body.len().min(4096)]).to_ascii_lowercase();
212    let mut best: Option<CustomDetection> = None;
213    for rule in &rules.waf {
214        let mut max_confidence: f64 = 0.0;
215        let mut matched = false;
216        if rule.block_status_codes.contains(&status) {
217            max_confidence = max_confidence.max(0.3);
218            matched = true;
219        }
220        for sig in &rule.header_signatures {
221            let header_match = headers.iter().any(|(name, value)| {
222                if !name.eq_ignore_ascii_case(&sig.name) {
223                    return false;
224                }
225                match &sig.value_contains {
226                    Some(substring) => value
227                        .to_ascii_lowercase()
228                        .contains(&substring.to_ascii_lowercase()),
229                    None => true,
230                }
231            });
232            if header_match {
233                max_confidence = max_confidence.max(sig.confidence);
234                matched = true;
235            }
236        }
237        for sig in &rule.body_signatures {
238            if body_str.contains(&sig.pattern.to_ascii_lowercase()) {
239                max_confidence = max_confidence.max(sig.confidence);
240                matched = true;
241            }
242        }
243        if matched && max_confidence > best.as_ref().map_or(0.0, |b| b.confidence) {
244            best = Some(CustomDetection {
245                rule_name: rule.name.clone(),
246                vendor: rule.vendor.clone(),
247                confidence: max_confidence,
248                evasion_strategies: rule.evasion_strategies.clone(),
249            });
250        }
251    }
252    best
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    const SAMPLE_TOML: &str = r#"
260[[waf]]
261name = "TestWAF"
262vendor = "test-vendor"
263block_status_codes = [403, 406]
264evasion_strategies = ["DoubleUrlEncode", "SqlCommentInsertion"]
265
266[[waf.header_signatures]]
267name = "x-test-waf"
268confidence = 0.9
269
270[[waf.header_signatures]]
271name = "server"
272value_contains = "TestWAF"
273confidence = 0.8
274
275[[waf.body_signatures]]
276pattern = "Blocked by TestWAF"
277confidence = 0.95
278
279[[waf]]
280name = "AnotherWAF"
281vendor = "another"
282block_status_codes = [429]
283evasion_strategies = ["CaseAlternation"]
284
285[[waf.header_signatures]]
286name = "x-another-waf"
287confidence = 0.7
288"#;
289
290    #[test]
291    fn load_rules_basic() {
292        let rules = load_rules(SAMPLE_TOML).expect("should parse");
293        assert_eq!(rules.waf.len(), 2);
294        assert_eq!(rules.waf[0].name, "TestWAF");
295        assert_eq!(rules.waf[0].header_signatures.len(), 2);
296        assert_eq!(rules.waf[0].body_signatures.len(), 1);
297        assert_eq!(rules.waf[0].block_status_codes, vec![403, 406]);
298        assert_eq!(rules.waf[0].evasion_strategies.len(), 2);
299    }
300
301    #[test]
302    fn load_rules_empty() {
303        let rules = load_rules("").expect("empty should parse");
304        assert!(rules.waf.is_empty());
305    }
306
307    #[test]
308    fn load_rules_invalid_toml() {
309        let result = load_rules("this is not { valid toml");
310        assert!(result.is_err());
311    }
312
313    #[test]
314    fn detect_by_header() {
315        let rules = load_rules(SAMPLE_TOML).expect("should parse");
316        let headers = vec![("x-test-waf".into(), "active".into())];
317        let result = detect(&rules, 200, &headers, b"OK");
318        assert!(result.is_some());
319        let det = result.unwrap();
320        assert_eq!(det.rule_name, "TestWAF");
321        assert!((det.confidence - 0.9).abs() < 0.01);
322    }
323
324    #[test]
325    fn detect_by_body() {
326        let rules = load_rules(SAMPLE_TOML).expect("should parse");
327        let headers: Vec<(String, String)> = vec![];
328        let body = b"Error: Blocked by TestWAF engine";
329        let result = detect(&rules, 200, &headers, body);
330        assert!(result.is_some());
331        let det = result.unwrap();
332        assert_eq!(det.rule_name, "TestWAF");
333        assert!((det.confidence - 0.95).abs() < 0.01);
334    }
335
336    #[test]
337    fn detect_by_status() {
338        let rules = load_rules(SAMPLE_TOML).expect("should parse");
339        let headers: Vec<(String, String)> = vec![];
340        let result = detect(&rules, 403, &headers, b"");
341        assert!(result.is_some());
342        assert_eq!(result.unwrap().rule_name, "TestWAF");
343    }
344
345    #[test]
346    fn detect_no_match() {
347        let rules = load_rules(SAMPLE_TOML).expect("should parse");
348        let headers = vec![("server".into(), "nginx".into())];
349        let result = detect(&rules, 200, &headers, b"Welcome");
350        assert!(result.is_none());
351    }
352
353    #[test]
354    fn dynamic_strategy_validation_accepts_content_type_genes() {
355        let toml = r#"
356[[waf]]
357name = "Test"
358evasion_strategies = ["Multipart", "JsonNested"]
359"#;
360        let rules = load_rules(toml);
361        assert!(
362            rules.is_ok(),
363            "Multipart and JsonNested should be valid strategies"
364        );
365    }
366
367    #[test]
368    fn dynamic_strategy_validation_accepts_grammar_genes() {
369        let toml = r#"
370[[waf]]
371name = "Test"
372evasion_strategies = ["tautology_swap", "comment_swap"]
373"#;
374        let rules = load_rules(toml);
375        assert!(rules.is_ok(), "Grammar genes should be valid strategies");
376    }
377}