Skip to main content

waf_detection/crs/
parse.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parse lexed [`DirectiveLine`](super::lexer::DirectiveLine)s into a [`ParsedRuleset`].
5//!
6//! Scope (v1 subset — see the B2 plan table). Supported directives: `SecRule`,
7//! `SecDefaultAction`, `SecRuleRemoveById`. `SecAction` is configuration/setup (it sets
8//! `TX` variables we do not model) and is ignored. `SecMarker`, `SecComponentSignature`,
9//! `SecRuleUpdate*` and unknown directives are ignored (they cannot make us *miss* an
10//! attack — at worst we run a rule the operator meant to skip, which is fail-closed).
11//!
12//! Anything a `SecRule` needs that the v1 subset cannot model (unsupported operator /
13//! variable / transform, a response-phase rule, a stateful action like `setvar`/`ctl`,
14//! a missing `id`) makes the WHOLE rule (and its whole chain) a [`SkippedRule`] with a
15//! precise reason — never a silent partial evaluation (policy D3=A).
16
17use 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
25/// Parse a whole `seclang` source into supported + skipped rules.
26pub 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                // Collect the head plus any chained children (each a following `SecRule`).
38                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                        // Dangling `chain` with no following SecRule — stop consuming.
49                        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            // SecAction (config), SecMarker, SecComponentSignature, SecRuleUpdate*, unknown:
57            // ignored. They are not detection rules in the v1 model.
58            _ => {}
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// ── SecDefaultAction state ─────────────────────────────────────────────────────
70
71#[derive(Default, Clone)]
72struct Defaults {
73    transforms: Vec<Transform>, // resolved (no `None` markers)
74    severity: Option<Severity>,
75    phase: Option<u8>,
76}
77
78impl Defaults {
79    fn update(&mut self, d: &DirectiveLine) {
80        // `SecDefaultAction "phase:2,t:none,t:lowercase,..."`
81        let Some(action_str) = d.tokens.get(1) else { return };
82        let a = parse_actions(action_str);
83        // Defaults' transform pipeline is whatever the directive resolves to (its own
84        // `t:none` resets from empty).
85        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
95// ── one SecRule's raw parts ─────────────────────────────────────────────────────
96
97struct 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>, // raw order, may contain `Transform::None` reset markers
109    chain: bool,
110    msg: Option<String>,
111    /// First stateful/unsupported action whose presence forces the rule to be skipped.
112    blocking_unsupported: Option<String>,
113}
114
115/// Parse a `SecRule VARS OP [ACTIONS]` directive line into its raw parts.
116fn 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
137/// Build a [`CrsRule`] (or a [`SkippedRule`]) from a head + chain children.
138fn 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    // Head must parse and carry an id (ModSecurity requires it on the chain starter).
143    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    // Response phases (3/4/5) inspect the response/log — out of the request-time v1 model.
160    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    // Build each match unit (head uses the SecDefaultAction transform defaults; chain
171    // children use only their own `t:` — SecDefaultAction does not apply to chained rules).
172    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
216/// Why a built [`MatchUnit`] cannot be evaluated by the v1 subset (or `None` if it can).
217fn 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
243// ── transforms ──────────────────────────────────────────────────────────────────
244
245/// Resolve a final transform pipeline: start from `base` (the SecDefaultAction defaults),
246/// then apply the rule's `t:` list in order, where `t:none` clears everything accumulated
247/// so far (defaults included) and subsequent `t:` re-add. Faithful to ModSecurity (#3).
248fn 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
276// ── variables ───────────────────────────────────────────────────────────────────
277
278fn 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
330// ── operator ────────────────────────────────────────────────────────────────────
331
332fn 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        // No explicit operator → ModSecurity defaults to `@rx` over the whole token.
341        return (negated, Operator::Rx(s.to_string()));
342    };
343    // Split the operator name from its (verbatim, NOT trimmed) argument at the first space.
344    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
366// ── actions ─────────────────────────────────────────────────────────────────────
367
368fn 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            // Disposition + metadata that do NOT change whether the rule matches → ignored.
395            // (Per-rule immediate block/deny is folded into the cumulative anomaly score in
396            // v1 — see the B2 plan, paletto #4.)
397            "block" | "deny" | "drop" | "pass" | "allow" | "status" | "tag" | "rev" | "ver"
398            | "accuracy" | "maturity" | "capture" | "log" | "nolog" | "auditlog"
399            | "noauditlog" | "logdata" | "multimatch" => {}
400            // Anomaly-score setvars (`tx.*_score` / `tx.anomaly_score*`) only express the
401            // rule's contribution to the cumulative score (paletto #4) — they do not gate
402            // whether the rule matches, so they are NON-blocking and the rule still loads.
403            // Every CRS 4.x detection rule ships these; treating them as stateful skipped the
404            // whole ruleset. Any other setvar writes state later logic may read → still skip.
405            "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            // Stateful / control actions we do not model: their presence changes behaviour,
411            // so the rule is skipped rather than silently mis-evaluated.
412            "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            // Unknown action → conservatively skip the rule (it might be state-changing).
420            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
429/// True for a `setvar` that only accumulates the CRS anomaly score
430/// (`setvar:tx.<...>_score=...` or any `tx.*anomaly_score*`). These express the rule's
431/// contribution to the cumulative score (paletto #4), not state other rules branch on, so
432/// they must not block the rule from loading. Anything else (`tx.foo_flag=1`, …) returns
433/// false and keeps the conservative skip.
434fn is_scoring_setvar(value: Option<&str>) -> bool {
435    let Some(v) = value else { return false };
436    // `setvar:'tx.sql_injection_score=+%{tx.critical_anomaly_score}'` → target before `=`.
437    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
450/// Split an action string on commas that are NOT inside a single-quoted value
451/// (`msg:'SQLi, attempt'` stays one item).
452fn 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
477/// Strip surrounding single quotes from an action value, if present.
478fn 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            // Range `N-M`.
491            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        // t:none clears the inherited t:lowercase, leaving only t:urlDecode.
539        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        // A setvar writing a non-score flag later logic may read → still skipped.
561        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        // Every CRS 4.x detection rule carries these score setvars; they must NOT block it.
569        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        // Either the phase or the RESPONSE_BODY target trips first; both are correct skips.
598        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}