waf_detection/crs/ast.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! AST produced by [`super::parse`] from the lexed directive lines. A [`CrsRule`] is the
5//! owned, runtime-loaded analogue of the static [`crate::Rule`] tables — but richer,
6//! because it carries the ModSecurity variable/operator/transform structure. Anything the
7//! v1 subset does not model becomes an [`Unsupported`](Operator::Unsupported) /
8//! [`TargetKind::Unsupported`] / [`Transform::Unsupported`] leaf, so the parser can SKIP the
9//! whole rule with a precise reason instead of silently mis-evaluating it (policy D3=A).
10
11use waf_core::Severity;
12
13/// One ModSecurity transformation (`t:` action). Applied, in order, to the **raw** target
14/// value before the operator runs (faithful to ModSecurity — see the module docs, paletto #1).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Transform {
17 None,
18 Lowercase,
19 UrlDecode,
20 UrlDecodeUni,
21 HtmlEntityDecode,
22 CompressWhitespace,
23 RemoveWhitespace,
24 RemoveNulls,
25 NormalizePath,
26 Base64Decode,
27 /// A transform outside the v1 subset (e.g. `cssDecode`, `jsDecode`, `sqlHexDecode`):
28 /// its presence makes the rule Unsupported, because skipping a declared decode would
29 /// change what the pattern sees.
30 Unsupported(String),
31}
32
33/// One ModSecurity operator. The string argument is kept raw at parse time; `@rx` is
34/// compiled to a [`regex::Regex`] later (in the loader/`init`), never in `inspect`.
35#[derive(Debug, Clone)]
36pub enum Operator {
37 /// `@rx <pattern>` — the pattern is compiled later.
38 Rx(String),
39 /// `@pm <phrase1> <phrase2> …` — case-insensitive multi-phrase match (any phrase).
40 Pm(Vec<String>),
41 /// `@pmf <path>` / `@pmFromFile <path>` — phrases from a file, resolved by the loader
42 /// (file I/O is not done at parse time). Becomes [`Operator::Pm`] once read.
43 PmFromFile(String),
44 /// `@contains <s>` — target contains `s`.
45 Contains(String),
46 /// `@beginsWith <s>`.
47 BeginsWith(String),
48 /// `@endsWith <s>`.
49 EndsWith(String),
50 /// `@within <set>` — target value is a substring of `set`.
51 Within(String),
52 /// `@streq <s>` — exact string equality.
53 StrEq(String),
54 /// An operator outside the v1 subset (`@detectSQLi`, `@ipMatch`, `@eq`, …) → rule Skipped.
55 Unsupported(String),
56}
57
58/// The collection a [`Variable`] points at, mapped to a slice of the request.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum TargetKind {
61 Args,
62 ArgsNames,
63 ArgsGet,
64 ArgsPost,
65 RequestHeaders,
66 RequestHeadersNames,
67 RequestCookies,
68 RequestCookiesNames,
69 RequestUri,
70 RequestUriRaw,
71 RequestFilename,
72 QueryString,
73 RequestMethod,
74 RequestProtocol,
75 RequestLine,
76 RequestBody,
77 /// A target outside the v1 subset (`XML`, `JSON`, `FILES`, `TX:*`, `RESPONSE_*`, …)
78 /// → rule Skipped.
79 Unsupported(String),
80}
81
82/// One parsed variable token (`ARGS`, `REQUEST_HEADERS:User-Agent`, `!ARGS:csrf`, `&ARGS`).
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Variable {
85 pub kind: TargetKind,
86 /// `:Name` selector — exact, case-insensitive name match within the collection.
87 pub selector: Option<String>,
88 /// Leading `!` → this is an EXCLUSION (remove the named member from the set).
89 pub negated: bool,
90 /// Leading `&` → count operator. Not modelled in v1 → makes the rule Unsupported.
91 pub count: bool,
92 /// A regex selector (`:/re/`) — not modelled in v1 → makes the rule Unsupported.
93 pub regex_selector: bool,
94}
95
96/// One link of a rule: its variables, the (possibly negated) operator and the transform
97/// pipeline that runs before it. A bare rule has one link (the head); a `chain` rule has
98/// the head plus one link per chained child — ALL must match.
99#[derive(Debug, Clone)]
100pub struct MatchUnit {
101 pub variables: Vec<Variable>,
102 pub operator: Operator,
103 /// Leading `!` on the operator string (`"!@rx ^$"`).
104 pub negated: bool,
105 /// Resolved transform pipeline (SecDefaultAction defaults + the link's own `t:`,
106 /// with `t:none` resetting — see paletto #3).
107 pub transforms: Vec<Transform>,
108}
109
110/// A fully parsed, supported rule ready to be compiled and evaluated.
111#[derive(Debug, Clone)]
112pub struct CrsRule {
113 /// ModSecurity numeric `id`. Emitted as `rule_id = "crs-<id>"`.
114 pub id: u64,
115 /// Resolved severity (rule → SecDefaultAction → `Warning` fallback).
116 pub severity: Severity,
117 /// Head match unit.
118 pub head: MatchUnit,
119 /// Chain children (all must match for the rule to fire). Empty for a non-chained rule.
120 pub chain: Vec<MatchUnit>,
121 /// `msg:` text, for logging.
122 pub msg: Option<String>,
123 /// 1-based source line where the rule started.
124 pub line_no: usize,
125}
126
127/// A rule that could not be loaded into the v1 subset. Counted and surfaced at boot
128/// (policy D3=A) so the operator sees the exact coverage gap — never a silent drop.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SkippedRule {
131 pub id: Option<u64>,
132 pub line_no: usize,
133 pub reason: String,
134}
135
136/// Outcome of parsing one `seclang` source: the supported rules plus the skipped ones.
137#[derive(Debug, Default)]
138pub struct ParsedRuleset {
139 pub rules: Vec<CrsRule>,
140 pub skipped: Vec<SkippedRule>,
141}
142
143/// Map a CRS/syslog severity word to our [`Severity`] classes. CRS uses
144/// CRITICAL/ERROR/WARNING/NOTICE; the rarer syslog words collapse to the nearest class.
145pub fn map_severity(word: &str) -> Option<Severity> {
146 match word.trim().to_ascii_uppercase().as_str() {
147 "EMERGENCY" | "ALERT" | "CRITICAL" | "0" | "1" | "2" => Some(Severity::Critical),
148 "ERROR" | "3" => Some(Severity::Error),
149 "WARNING" | "4" => Some(Severity::Warning),
150 "NOTICE" | "INFO" | "DEBUG" | "5" | "6" | "7" => Some(Severity::Notice),
151 _ => None,
152 }
153}