Skip to main content

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}