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
114pub 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
194pub 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#[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}