1use waf_core::Severity;
18
19use super::ast::{
20 map_severity, CrsRule, MatchUnit, Operator, ParsedRuleset, SkippedRule, TargetKind,
21 Transform, Variable,
22};
23use super::lexer::{lex, DirectiveLine};
24
25pub fn parse(input: &str) -> ParsedRuleset {
27 let dirs = lex(input);
28 let mut out = ParsedRuleset::default();
29 let mut defaults = Defaults::default();
30 let mut removed_ids: Vec<u64> = Vec::new();
31
32 let mut i = 0usize;
33 while i < dirs.len() {
34 let name = dirs[i].tokens[0].to_ascii_lowercase();
35 match name.as_str() {
36 "secrule" => {
37 let mut links: Vec<(usize, Result<RuleParts, String>)> =
39 vec![(dirs[i].line_no, parse_secrule(&dirs[i]))];
40 let mut chained = links[0].1.as_ref().map(|p| p.actions.chain).unwrap_or(false);
41 while chained {
42 if i + 1 < dirs.len() && dirs[i + 1].tokens[0].eq_ignore_ascii_case("secrule") {
43 i += 1;
44 let child = parse_secrule(&dirs[i]);
45 chained = child.as_ref().map(|p| p.actions.chain).unwrap_or(false);
46 links.push((dirs[i].line_no, child));
47 } else {
48 break;
50 }
51 }
52 assemble(links, &defaults, &mut out);
53 }
54 "secdefaultaction" => defaults.update(&dirs[i]),
55 "secruleremovebyid" => collect_removed_ids(&dirs[i], &mut removed_ids),
56 _ => {}
59 }
60 i += 1;
61 }
62
63 if !removed_ids.is_empty() {
64 out.rules.retain(|r| !removed_ids.contains(&r.id));
65 }
66 out
67}
68
69#[derive(Default, Clone)]
72struct Defaults {
73 transforms: Vec<Transform>, severity: Option<Severity>,
75 phase: Option<u8>,
76}
77
78impl Defaults {
79 fn update(&mut self, d: &DirectiveLine) {
80 let Some(action_str) = d.tokens.get(1) else { return };
82 let a = parse_actions(action_str);
83 self.transforms = resolve_transforms(&[], &a.transforms);
86 if a.severity.is_some() {
87 self.severity = a.severity;
88 }
89 if a.phase.is_some() {
90 self.phase = a.phase;
91 }
92 }
93}
94
95struct RuleParts {
98 variables: Vec<Variable>,
99 operator: Operator,
100 negated: bool,
101 actions: ActionParse,
102}
103
104struct ActionParse {
105 id: Option<u64>,
106 phase: Option<u8>,
107 severity: Option<Severity>,
108 transforms: Vec<Transform>, chain: bool,
110 msg: Option<String>,
111 blocking_unsupported: Option<String>,
113}
114
115fn parse_secrule(d: &DirectiveLine) -> Result<RuleParts, String> {
117 if d.tokens.len() < 3 {
118 return Err("malformed SecRule (expected VARIABLES OPERATOR [ACTIONS])".to_string());
119 }
120 let variables = parse_variables(&d.tokens[1]);
121 let (negated, operator) = parse_operator(&d.tokens[2]);
122 let actions = match d.tokens.get(3) {
123 Some(s) => parse_actions(s),
124 None => ActionParse {
125 id: None,
126 phase: None,
127 severity: None,
128 transforms: Vec::new(),
129 chain: false,
130 msg: None,
131 blocking_unsupported: None,
132 },
133 };
134 Ok(RuleParts { variables, operator, negated, actions })
135}
136
137fn assemble(links: Vec<(usize, Result<RuleParts, String>)>, defaults: &Defaults, out: &mut ParsedRuleset) {
139 let (head_line, head_res) = &links[0];
140 let head_line = *head_line;
141
142 let head = match head_res {
144 Ok(p) => p,
145 Err(reason) => {
146 out.skipped.push(SkippedRule { id: None, line_no: head_line, reason: reason.clone() });
147 return;
148 }
149 };
150 let Some(id) = head.actions.id else {
151 out.skipped.push(SkippedRule {
152 id: None,
153 line_no: head_line,
154 reason: "SecRule has no id (cannot form a stable rule_id)".to_string(),
155 });
156 return;
157 };
158
159 let phase = head.actions.phase.or(defaults.phase).unwrap_or(2);
161 if phase >= 3 {
162 out.skipped.push(SkippedRule {
163 id: Some(id),
164 line_no: head_line,
165 reason: format!("response/logging phase {phase} not supported (request-time only)"),
166 });
167 return;
168 }
169
170 let mut units: Vec<MatchUnit> = Vec::with_capacity(links.len());
173 for (idx, (line_no, res)) in links.iter().enumerate() {
174 let parts = match res {
175 Ok(p) => p,
176 Err(reason) => {
177 out.skipped.push(SkippedRule { id: Some(id), line_no: *line_no, reason: reason.clone() });
178 return;
179 }
180 };
181 if let Some(act) = &parts.actions.blocking_unsupported {
182 out.skipped.push(SkippedRule {
183 id: Some(id),
184 line_no: *line_no,
185 reason: format!("unsupported action {act}"),
186 });
187 return;
188 }
189 let base = if idx == 0 { defaults.transforms.as_slice() } else { &[] };
190 let transforms = resolve_transforms(base, &parts.actions.transforms);
191 let unit = MatchUnit {
192 variables: parts.variables.clone(),
193 operator: parts.operator.clone(),
194 negated: parts.negated,
195 transforms,
196 };
197 if let Some(reason) = unit_unsupported(&unit) {
198 out.skipped.push(SkippedRule { id: Some(id), line_no: *line_no, reason });
199 return;
200 }
201 units.push(unit);
202 }
203
204 let severity = head.actions.severity.or(defaults.severity).unwrap_or(Severity::Warning);
205 let head_unit = units.remove(0);
206 out.rules.push(CrsRule {
207 id,
208 severity,
209 head: head_unit,
210 chain: units,
211 msg: head.actions.msg.clone(),
212 line_no: head_line,
213 });
214}
215
216fn unit_unsupported(u: &MatchUnit) -> Option<String> {
218 if let Operator::Unsupported(name) = &u.operator {
219 return Some(format!("unsupported operator @{name}"));
220 }
221 if u.variables.is_empty() {
222 return Some("no variables".to_string());
223 }
224 for v in &u.variables {
225 if let TargetKind::Unsupported(name) = &v.kind {
226 return Some(format!("unsupported variable {name}"));
227 }
228 if v.count {
229 return Some("count operator (&VAR) not supported".to_string());
230 }
231 if v.regex_selector {
232 return Some("regex variable selector (:/…/) not supported".to_string());
233 }
234 }
235 for t in &u.transforms {
236 if let Transform::Unsupported(name) = t {
237 return Some(format!("unsupported transform t:{name}"));
238 }
239 }
240 None
241}
242
243fn resolve_transforms(base: &[Transform], rule: &[Transform]) -> Vec<Transform> {
249 let mut list = base.to_vec();
250 for t in rule {
251 if *t == Transform::None {
252 list.clear();
253 } else {
254 list.push(t.clone());
255 }
256 }
257 list
258}
259
260fn map_transform(name: &str) -> Transform {
261 match name.trim().to_ascii_lowercase().as_str() {
262 "none" => Transform::None,
263 "lowercase" => Transform::Lowercase,
264 "urldecode" => Transform::UrlDecode,
265 "urldecodeuni" => Transform::UrlDecodeUni,
266 "htmlentitydecode" => Transform::HtmlEntityDecode,
267 "compresswhitespace" => Transform::CompressWhitespace,
268 "removewhitespace" => Transform::RemoveWhitespace,
269 "removenulls" => Transform::RemoveNulls,
270 "normalizepath" | "normalisepath" => Transform::NormalizePath,
271 "base64decode" | "base64decodeext" => Transform::Base64Decode,
272 other => Transform::Unsupported(other.to_string()),
273 }
274}
275
276fn parse_variables(tok: &str) -> Vec<Variable> {
279 tok.split('|').filter(|s| !s.is_empty()).map(parse_variable).collect()
280}
281
282fn parse_variable(piece: &str) -> Variable {
283 let mut s = piece.trim();
284 let mut negated = false;
285 let mut count = false;
286 loop {
287 if let Some(r) = s.strip_prefix('!') {
288 negated = true;
289 s = r;
290 } else if let Some(r) = s.strip_prefix('&') {
291 count = true;
292 s = r;
293 } else {
294 break;
295 }
296 }
297 let (name, selector, regex_selector) = match s.split_once(':') {
298 Some((n, sel)) => {
299 let rx = sel.starts_with('/');
300 let selector = if rx || sel.is_empty() { None } else { Some(sel.to_string()) };
301 (n, selector, rx)
302 }
303 None => (s, None, false),
304 };
305 Variable { kind: map_target(name), selector, negated, count, regex_selector }
306}
307
308fn map_target(name: &str) -> TargetKind {
309 match name.trim().to_ascii_uppercase().as_str() {
310 "ARGS" => TargetKind::Args,
311 "ARGS_NAMES" => TargetKind::ArgsNames,
312 "ARGS_GET" => TargetKind::ArgsGet,
313 "ARGS_POST" => TargetKind::ArgsPost,
314 "REQUEST_HEADERS" => TargetKind::RequestHeaders,
315 "REQUEST_HEADERS_NAMES" => TargetKind::RequestHeadersNames,
316 "REQUEST_COOKIES" => TargetKind::RequestCookies,
317 "REQUEST_COOKIES_NAMES" => TargetKind::RequestCookiesNames,
318 "REQUEST_URI" => TargetKind::RequestUri,
319 "REQUEST_URI_RAW" => TargetKind::RequestUriRaw,
320 "REQUEST_FILENAME" => TargetKind::RequestFilename,
321 "QUERY_STRING" => TargetKind::QueryString,
322 "REQUEST_METHOD" => TargetKind::RequestMethod,
323 "REQUEST_PROTOCOL" => TargetKind::RequestProtocol,
324 "REQUEST_LINE" => TargetKind::RequestLine,
325 "REQUEST_BODY" => TargetKind::RequestBody,
326 other => TargetKind::Unsupported(other.to_string()),
327 }
328}
329
330fn parse_operator(tok: &str) -> (bool, Operator) {
333 let mut s = tok.trim_start();
334 let mut negated = false;
335 if let Some(r) = s.strip_prefix('!') {
336 negated = true;
337 s = r.trim_start();
338 }
339 let Some(rest) = s.strip_prefix('@') else {
340 return (negated, Operator::Rx(s.to_string()));
342 };
343 let (op, arg) = match rest.find(char::is_whitespace) {
345 Some(pos) => (&rest[..pos], &rest[pos + 1..]),
346 None => (rest, ""),
347 };
348 let operator = match op.to_ascii_lowercase().as_str() {
349 "rx" => Operator::Rx(arg.to_string()),
350 "pm" => Operator::Pm(split_phrases(arg)),
351 "pmf" | "pmfromfile" => Operator::PmFromFile(arg.trim().to_string()),
352 "contains" => Operator::Contains(arg.to_string()),
353 "beginswith" => Operator::BeginsWith(arg.to_string()),
354 "endswith" => Operator::EndsWith(arg.to_string()),
355 "within" => Operator::Within(arg.to_string()),
356 "streq" => Operator::StrEq(arg.to_string()),
357 other => Operator::Unsupported(other.to_string()),
358 };
359 (negated, operator)
360}
361
362fn split_phrases(arg: &str) -> Vec<String> {
363 arg.split_whitespace().map(|s| s.to_string()).collect()
364}
365
366fn parse_actions(s: &str) -> ActionParse {
369 let mut out = ActionParse {
370 id: None,
371 phase: None,
372 severity: None,
373 transforms: Vec::new(),
374 chain: false,
375 msg: None,
376 blocking_unsupported: None,
377 };
378 for item in split_actions(s) {
379 let (name, value) = match item.split_once(':') {
380 Some((n, v)) => (n.trim(), Some(unquote(v.trim()))),
381 None => (item.trim(), None),
382 };
383 match name.to_ascii_lowercase().as_str() {
384 "id" => out.id = value.as_deref().and_then(|v| v.parse().ok()),
385 "phase" => out.phase = value.as_deref().and_then(parse_phase),
386 "severity" => out.severity = value.as_deref().and_then(map_severity),
387 "t" => {
388 if let Some(v) = value {
389 out.transforms.push(map_transform(&v));
390 }
391 }
392 "chain" => out.chain = true,
393 "msg" => out.msg = value,
394 "block" | "deny" | "drop" | "pass" | "allow" | "status" | "tag" | "rev" | "ver"
398 | "accuracy" | "maturity" | "capture" | "log" | "nolog" | "auditlog"
399 | "noauditlog" | "logdata" | "multimatch" => {}
400 "setvar" => {
406 if !is_scoring_setvar(value.as_deref()) && out.blocking_unsupported.is_none() {
407 out.blocking_unsupported = Some(name.to_string());
408 }
409 }
410 "ctl" | "skip" | "skipafter" | "expirevar" | "initcol" | "setsid"
413 | "setuid" | "exec" | "deprecatevar" | "sanitisearg" | "sanitisematched"
414 | "sanitisematchedbytes" => {
415 if out.blocking_unsupported.is_none() {
416 out.blocking_unsupported = Some(name.to_string());
417 }
418 }
419 other if !other.is_empty() && out.blocking_unsupported.is_none() => {
421 out.blocking_unsupported = Some(other.to_string());
422 }
423 _ => {}
424 }
425 }
426 out
427}
428
429fn is_scoring_setvar(value: Option<&str>) -> bool {
435 let Some(v) = value else { return false };
436 let target = v.split('=').next().unwrap_or("").trim().trim_start_matches('!').to_ascii_lowercase();
438 target.starts_with("tx.") && (target.ends_with("_score") || target.contains("anomaly_score"))
439}
440
441fn parse_phase(v: &str) -> Option<u8> {
442 match v.trim().to_ascii_lowercase().as_str() {
443 "request" => Some(2),
444 "response" => Some(4),
445 "logging" => Some(5),
446 n => n.parse().ok(),
447 }
448}
449
450fn split_actions(s: &str) -> Vec<String> {
453 let mut parts = Vec::new();
454 let mut cur = String::new();
455 let mut in_q = false;
456 for c in s.chars() {
457 match c {
458 '\'' => {
459 in_q = !in_q;
460 cur.push(c);
461 }
462 ',' if !in_q => {
463 if !cur.trim().is_empty() {
464 parts.push(cur.trim().to_string());
465 }
466 cur.clear();
467 }
468 _ => cur.push(c),
469 }
470 }
471 if !cur.trim().is_empty() {
472 parts.push(cur.trim().to_string());
473 }
474 parts
475}
476
477fn unquote(v: &str) -> String {
479 let v = v.trim();
480 if v.len() >= 2 && v.starts_with('\'') && v.ends_with('\'') {
481 v[1..v.len() - 1].to_string()
482 } else {
483 v.to_string()
484 }
485}
486
487fn collect_removed_ids(d: &DirectiveLine, out: &mut Vec<u64>) {
488 for tok in &d.tokens[1..] {
489 if let Some((a, b)) = tok.split_once('-') {
490 if let (Ok(a), Ok(b)) = (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
492 for id in a..=b {
493 out.push(id);
494 }
495 }
496 } else if let Ok(id) = tok.trim().parse::<u64>() {
497 out.push(id);
498 }
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn simple_rx_rule() {
508 let r = parse(r#"SecRule ARGS "@rx (?i)union\s+select" "id:1,phase:2,severity:CRITICAL,block,t:lowercase,t:urlDecode""#);
509 assert_eq!(r.skipped.len(), 0);
510 assert_eq!(r.rules.len(), 1);
511 let rule = &r.rules[0];
512 assert_eq!(rule.id, 1);
513 assert_eq!(rule.severity, Severity::Critical);
514 assert!(matches!(rule.head.operator, Operator::Rx(_)));
515 assert_eq!(rule.head.transforms, vec![Transform::Lowercase, Transform::UrlDecode]);
516 assert_eq!(rule.head.variables[0].kind, TargetKind::Args);
517 }
518
519 #[test]
520 fn default_action_severity_and_transforms_inherited() {
521 let src = r#"
522SecDefaultAction "phase:2,t:none,t:lowercase,severity:WARNING"
523SecRule ARGS "@rx foo" "id:2"
524"#;
525 let r = parse(src);
526 assert_eq!(r.rules.len(), 1);
527 assert_eq!(r.rules[0].severity, Severity::Warning);
528 assert_eq!(r.rules[0].head.transforms, vec![Transform::Lowercase]);
529 }
530
531 #[test]
532 fn t_none_resets_defaults() {
533 let src = r#"
534SecDefaultAction "phase:2,t:lowercase"
535SecRule ARGS "@rx foo" "id:3,t:none,t:urlDecode"
536"#;
537 let r = parse(src);
538 assert_eq!(r.rules[0].head.transforms, vec![Transform::UrlDecode]);
540 }
541
542 #[test]
543 fn unsupported_operator_skips_rule_with_reason() {
544 let r = parse(r#"SecRule ARGS "@detectSQLi" "id:4,phase:2""#);
545 assert_eq!(r.rules.len(), 0);
546 assert_eq!(r.skipped.len(), 1);
547 assert_eq!(r.skipped[0].id, Some(4));
548 assert!(r.skipped[0].reason.contains("@detectsqli"));
549 }
550
551 #[test]
552 fn unsupported_transform_skips_rule() {
553 let r = parse(r#"SecRule ARGS "@rx x" "id:5,t:cssDecode""#);
554 assert_eq!(r.rules.len(), 0);
555 assert!(r.skipped[0].reason.contains("cssdecode"));
556 }
557
558 #[test]
559 fn non_score_setvar_skips_rule() {
560 let r = parse(r#"SecRule ARGS "@rx x" "id:6,phase:2,pass,setvar:tx.foo_flag=1""#);
562 assert_eq!(r.rules.len(), 0);
563 assert!(r.skipped[0].reason.contains("setvar"));
564 }
565
566 #[test]
567 fn anomaly_score_setvar_loads_rule() {
568 let src = r#"SecRule ARGS "@rx (?i)union\s+select" "id:942190,phase:2,block,capture,t:none,t:urlDecodeUni,severity:CRITICAL,setvar:'tx.sql_injection_score=+%{tx.critical_anomaly_score}',setvar:'tx.anomaly_score_pl1=+%{tx.critical_anomaly_score}'""#;
570 let r = parse(src);
571 assert_eq!(r.rules.len(), 1, "skipped: {:?}", r.skipped);
572 assert_eq!(r.rules[0].id, 942190);
573 assert_eq!(r.rules[0].severity, Severity::Critical);
574 }
575
576 #[test]
577 fn is_scoring_setvar_predicate() {
578 assert!(is_scoring_setvar(Some("tx.sql_injection_score=+%{tx.critical_anomaly_score}")));
579 assert!(is_scoring_setvar(Some("tx.outbound_anomaly_score_pl1=+1")));
580 assert!(is_scoring_setvar(Some("TX.XSS_SCORE=+5")));
581 assert!(!is_scoring_setvar(Some("tx.foo_flag=1")));
582 assert!(!is_scoring_setvar(Some("ip.reputation_block=1")));
583 assert!(!is_scoring_setvar(None));
584 }
585
586 #[test]
587 fn missing_id_skips() {
588 let r = parse(r#"SecRule ARGS "@rx x" "phase:2,pass""#);
589 assert_eq!(r.rules.len(), 0);
590 assert!(r.skipped[0].reason.contains("no id"));
591 }
592
593 #[test]
594 fn response_phase_skipped() {
595 let r = parse(r#"SecRule RESPONSE_BODY "@rx x" "id:7,phase:4""#);
596 assert_eq!(r.rules.len(), 0);
597 assert!(r.skipped[0].reason.contains("phase") || r.skipped[0].reason.contains("RESPONSE_BODY"));
599 }
600
601 #[test]
602 fn chain_all_links_supported() {
603 let src = r#"
604SecRule REQUEST_METHOD "@streq POST" "id:8,phase:2,chain,severity:ERROR"
605 SecRule ARGS "@rx (?i)evil" "t:lowercase"
606"#;
607 let r = parse(src);
608 assert_eq!(r.rules.len(), 1, "skipped: {:?}", r.skipped);
609 assert_eq!(r.rules[0].chain.len(), 1);
610 assert_eq!(r.rules[0].severity, Severity::Error);
611 }
612
613 #[test]
614 fn chain_with_unsupported_child_skips_whole() {
615 let src = r#"
616SecRule REQUEST_METHOD "@streq POST" "id:9,phase:2,chain"
617 SecRule XML "@detectXSS" "t:none"
618"#;
619 let r = parse(src);
620 assert_eq!(r.rules.len(), 0);
621 assert_eq!(r.skipped[0].id, Some(9));
622 }
623
624 #[test]
625 fn remove_by_id_drops_rule() {
626 let src = r#"
627SecRule ARGS "@rx a" "id:100,phase:2"
628SecRule ARGS "@rx b" "id:101,phase:2"
629SecRuleRemoveById 100
630"#;
631 let r = parse(src);
632 assert_eq!(r.rules.len(), 1);
633 assert_eq!(r.rules[0].id, 101);
634 }
635
636 #[test]
637 fn remove_by_id_range() {
638 let src = r#"
639SecRule ARGS "@rx a" "id:200,phase:2"
640SecRule ARGS "@rx b" "id:205,phase:2"
641SecRuleRemoveById 200-210
642"#;
643 let r = parse(src);
644 assert_eq!(r.rules.len(), 0);
645 }
646
647 #[test]
648 fn variable_selector_and_exclusion_parsed() {
649 let r = parse(r#"SecRule REQUEST_HEADERS:User-Agent|!ARGS:csrf_token "@rx x" "id:11,phase:2""#);
650 let v = &r.rules[0].head.variables;
651 assert_eq!(v[0].kind, TargetKind::RequestHeaders);
652 assert_eq!(v[0].selector.as_deref(), Some("User-Agent"));
653 assert_eq!(v[1].kind, TargetKind::Args);
654 assert!(v[1].negated);
655 assert_eq!(v[1].selector.as_deref(), Some("csrf_token"));
656 }
657
658 #[test]
659 fn count_variable_skips_rule() {
660 let r = parse(r#"SecRule &ARGS "@rx x" "id:12,phase:2""#);
661 assert_eq!(r.rules.len(), 0);
662 assert!(r.skipped[0].reason.contains("count"));
663 }
664
665 #[test]
666 fn default_operator_is_rx() {
667 let r = parse(r#"SecRule ARGS "evil" "id:13,phase:2""#);
668 assert!(matches!(r.rules[0].head.operator, Operator::Rx(ref p) if p == "evil"));
669 }
670
671 #[test]
672 fn secaction_is_ignored_not_skipped() {
673 let r = parse(r#"SecAction "id:900000,phase:1,nolog,pass,t:none,setvar:tx.crs_setup=1""#);
674 assert_eq!(r.rules.len(), 0);
675 assert_eq!(r.skipped.len(), 0);
676 }
677}