Skip to main content

waf_pipeline/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod noop_logger;
5pub use noop_logger::NoopLogger;
6
7use std::panic::{catch_unwind, AssertUnwindSafe};
8
9use tracing::{debug, error, info, warn};
10use waf_core::{
11    Config, Decision, FailMode, Phase, RequestContext, ScoreContribution, Severity, SeverityScores,
12    WafMode, WafModule,
13};
14
15#[derive(Debug)]
16pub enum PipelineVerdict {
17    Allow,
18    Block {
19        rule_id: String,
20        reason: String,
21    },
22    /// Direct rejection with an explicit HTTP status (e.g. 429 from rate
23    /// limiting), carrying an optional `Retry-After` in seconds.
24    Reject {
25        rule_id: String,
26        reason: String,
27        status: u16,
28        retry_after: Option<u64>,
29    },
30}
31
32/// Canonical order in which phases are executed.
33const PHASE_ORDER: &[Phase] = &[
34    Phase::Connection,
35    Phase::RequestLine,
36    Phase::Headers,
37    Phase::Body,
38    Phase::Response,
39];
40
41/// Phases that run *after* normalization (Fase 2). The Connection phase runs
42/// before normalization (see `run_connection`), so it is excluded here.
43const INSPECTION_PHASES: &[Phase] = &[
44    Phase::RequestLine,
45    Phase::Headers,
46    Phase::Body,
47    Phase::Response,
48];
49
50pub struct Pipeline {
51    modules: Vec<Box<dyn WafModule>>,
52    block_threshold: u32,
53    mode: WafMode,
54    /// severity -> points policy; the pipeline is the only place that maps a
55    /// `Decision::Scores` severity to a concrete weight.
56    severity_scores: SeverityScores,
57    /// What to do when a module panics (Fase 6 / Pillar 2).
58    on_internal_error: FailMode,
59}
60
61impl Pipeline {
62    /// Builds the pipeline and calls `init` on every module.
63    pub fn new(config: &Config, mut modules: Vec<Box<dyn WafModule>>) -> Self {
64        for m in &mut modules {
65            m.init(config);
66        }
67        Self {
68            modules,
69            block_threshold: config.waf.block_threshold,
70            mode: config.waf.mode,
71            severity_scores: config.waf.severity_scores,
72            on_internal_error: config.resilience.on_internal_error,
73        }
74    }
75
76    /// Accumulate one contribution into the context: add points, record the
77    /// breakdown for audit, and log it. Returns the running total. This is the
78    /// single chokepoint for mutating `ctx.score`.
79    fn accumulate(
80        &self,
81        ctx: &mut RequestContext,
82        module: &str,
83        rule_id: String,
84        severity: Option<Severity>,
85        points: u32,
86    ) {
87        ctx.score += points;
88        info!(
89            request_id = %ctx.request_id,
90            module = module,
91            rule_id = %rule_id,
92            severity = ?severity,
93            points = points,
94            score = ctx.score,
95            decision = "score",
96            "scoring contribution"
97        );
98        ctx.score_contributions.push(ScoreContribution {
99            module: module.to_string(),
100            rule_id,
101            severity,
102            points,
103        });
104    }
105
106    /// Runs all modules in phase order, accumulates score, and returns the verdict.
107    ///
108    /// Scoring model (CRS-inspired):
109    /// - `Decision::Scores` contributes one weighted point per matched rule;
110    /// - `Decision::Score` contributes explicit points (high-confidence rules);
111    /// - the request blocks when `ctx.score >= block_threshold`;
112    /// - `Decision::Block` is a direct short-circuit that blocks regardless of
113    ///   the accumulated score (reserved for very-high-confidence rules).
114    ///
115    /// In blocking mode: the first Block/Reject, or score crossing the threshold,
116    /// short-circuits the chain. In detection-only: all modules always run and
117    /// the verdict is always Allow (threshold crossings are logged only).
118    ///
119    /// Runs every phase. The proxy instead uses `run_connection` (before
120    /// normalization) + `run_inspection` (after) to short-circuit rate-limited
121    /// traffic before parsing; `run` is kept for in-process/whole-pipeline use.
122    pub fn run(&self, ctx: &mut RequestContext) -> PipelineVerdict {
123        let verdict = self.run_phases(ctx, PHASE_ORDER);
124        self.log_decision(ctx, &verdict);
125        verdict
126    }
127
128    /// Runs only the `Connection` phase (e.g. rate limiting). Meant to be called
129    /// **before** normalization so flood traffic is rejected without parsing.
130    /// Logs a final decision only when it actually rejects/blocks.
131    pub fn run_connection(&self, ctx: &mut RequestContext) -> PipelineVerdict {
132        let verdict = self.run_phases(ctx, &[Phase::Connection]);
133        if !matches!(verdict, PipelineVerdict::Allow) {
134            self.log_decision(ctx, &verdict);
135        }
136        verdict
137    }
138
139    /// Runs the post-normalization phases (`RequestLine`..`Response`). The score
140    /// accumulated by `run_connection` on the same `ctx` carries over.
141    pub fn run_inspection(&self, ctx: &mut RequestContext) -> PipelineVerdict {
142        let verdict = self.run_phases(ctx, INSPECTION_PHASES);
143        self.log_decision(ctx, &verdict);
144        verdict
145    }
146
147    /// Fast-path gate (Fase 7 / Pillar 3). When `inspect` is true, runs the normal
148    /// content inspection. When false — the caller's `ContentPrefilter` has *proven*
149    /// no content rule can match the canonical surface — SKIPS inspection and returns
150    /// `Allow`, while emitting the **same** final decision log as a clean inspection
151    /// (score 0, allow) so a skipped benign request is indistinguishable in the logs
152    /// from an inspected one (no observability hole).
153    ///
154    /// This is the single gating point shared by the proxy (`handle`) and the
155    /// equivalence oracle (`waf-corpus`), so the property tested is the property run.
156    pub fn run_inspection_gated(&self, ctx: &mut RequestContext, inspect: bool) -> PipelineVerdict {
157        if inspect {
158            self.run_inspection(ctx)
159        } else {
160            // The content fast-path proved no CONTENT rule can match — but STRUCTURAL
161            // inspection modules (e.g. GraphQL) are not content rules and must still
162            // run, else a structural attack with no content signature would bypass.
163            let verdict = self.run_phases_filtered(ctx, INSPECTION_PHASES, true);
164            self.log_decision(ctx, &verdict);
165            verdict
166        }
167    }
168
169    /// Core executor over an arbitrary set of phases. Accumulates score and
170    /// returns the verdict; does not emit the final decision log (callers do).
171    fn run_phases(&self, ctx: &mut RequestContext, phases: &[Phase]) -> PipelineVerdict {
172        self.run_phases_filtered(ctx, phases, false)
173    }
174
175    /// As [`run_phases`], but when `structural_only` is set, only STRUCTURAL modules
176    /// run (the content fast-path skip path — see [`run_inspection_gated`]).
177    fn run_phases_filtered(
178        &self,
179        ctx: &mut RequestContext,
180        phases: &[Phase],
181        structural_only: bool,
182    ) -> PipelineVerdict {
183        let mut block_verdict: Option<PipelineVerdict> = None;
184
185        'pipeline: for &phase in phases {
186            for module in self
187                .modules
188                .iter()
189                .filter(|m| m.phase() == phase && (!structural_only || m.structural()))
190            {
191                let module_id = module.id().to_string();
192
193                // Panic isolation (Fase 6 / Pillar 2): a bug in a module (panic,
194                // pathological regex) must not abort the worker or other clients.
195                // `inspect` takes `&RequestContext` (read-only), so a panic cannot
196                // leave `ctx` partially mutated → `AssertUnwindSafe` is sound.
197                let decision = match catch_unwind(AssertUnwindSafe(|| module.inspect(ctx))) {
198                    Ok(d) => d,
199                    Err(_) => {
200                        error!(
201                            request_id = %ctx.request_id,
202                            module = %module_id,
203                            policy = ?self.on_internal_error,
204                            decision = "internal_error",
205                            "module panicked; applying on_internal_error policy"
206                        );
207                        match self.on_internal_error {
208                            // Skip the faulty module; the request proceeds.
209                            FailMode::FailOpen => Decision::Allow,
210                            // Surface a synthetic block (enforced in blocking mode,
211                            // logged-only in detection-only — mode semantics intact).
212                            FailMode::FailClosed => Decision::Block {
213                                rule_id: "internal-error".to_string(),
214                                reason: format!("module {module_id} panicked"),
215                            },
216                        }
217                    }
218                };
219
220                match decision {
221                    Decision::Allow => {
222                        debug!(
223                            request_id = %ctx.request_id,
224                            module = %module_id,
225                            phase = ?phase,
226                            "allow"
227                        );
228                    }
229
230                    Decision::Monitor { rule_id } => {
231                        info!(
232                            request_id = %ctx.request_id,
233                            module = %module_id,
234                            rule_id = %rule_id,
235                            score = ctx.score,
236                            decision = "monitor",
237                            "module triggered"
238                        );
239                    }
240
241                    Decision::Score { rule_id, points } => {
242                        self.accumulate(ctx, &module_id, rule_id.clone(), None, points);
243                        if let Some(verdict) = self.threshold_check(ctx, &rule_id) {
244                            block_verdict = Some(verdict);
245                            break 'pipeline;
246                        }
247                    }
248
249                    Decision::Scores(items) => {
250                        let mut last_rule_id = String::new();
251                        for item in items {
252                            let points = self.severity_scores.points_for(item.severity);
253                            self.accumulate(
254                                ctx,
255                                &module_id,
256                                item.rule_id.clone(),
257                                Some(item.severity),
258                                points,
259                            );
260                            last_rule_id = item.rule_id;
261                        }
262                        if let Some(verdict) = self.threshold_check(ctx, &last_rule_id) {
263                            block_verdict = Some(verdict);
264                            break 'pipeline;
265                        }
266                    }
267
268                    Decision::Block { rule_id, reason } => {
269                        warn!(
270                            request_id = %ctx.request_id,
271                            module = %module_id,
272                            rule_id = %rule_id,
273                            reason = %reason,
274                            score = ctx.score,
275                            decision = "block",
276                            "module triggered"
277                        );
278                        if self.mode == WafMode::Blocking {
279                            block_verdict = Some(PipelineVerdict::Block { rule_id, reason });
280                            break 'pipeline;
281                        }
282                    }
283
284                    Decision::Reject { rule_id, reason, status, retry_after } => {
285                        warn!(
286                            request_id = %ctx.request_id,
287                            module = %module_id,
288                            rule_id = %rule_id,
289                            reason = %reason,
290                            status = status,
291                            decision = "reject",
292                            "module triggered"
293                        );
294                        if self.mode == WafMode::Blocking {
295                            block_verdict = Some(PipelineVerdict::Reject {
296                                rule_id,
297                                reason,
298                                status,
299                                retry_after,
300                            });
301                            break 'pipeline;
302                        }
303                    }
304                }
305            }
306        }
307
308        match self.mode {
309            WafMode::Blocking => block_verdict.unwrap_or(PipelineVerdict::Allow),
310            WafMode::DetectionOnly => PipelineVerdict::Allow,
311        }
312    }
313
314    /// Returns a Block verdict if the score reached the threshold *and* we are in
315    /// blocking mode; otherwise `None`. Detection-only logs the crossing only.
316    fn threshold_check(&self, ctx: &RequestContext, rule_id: &str) -> Option<PipelineVerdict> {
317        if ctx.score < self.block_threshold {
318            return None;
319        }
320        warn!(
321            request_id = %ctx.request_id,
322            score = ctx.score,
323            threshold = self.block_threshold,
324            mode = ?self.mode,
325            "anomaly score threshold reached"
326        );
327        if self.mode == WafMode::Blocking {
328            Some(PipelineVerdict::Block {
329                rule_id: rule_id.to_string(),
330                reason: format!(
331                    "anomaly score {} >= threshold {}",
332                    ctx.score, self.block_threshold
333                ),
334            })
335        } else {
336            None
337        }
338    }
339
340    /// Final structured decision log: total score, threshold, mode and the full
341    /// per-rule contribution breakdown.
342    fn log_decision(&self, ctx: &RequestContext, verdict: &PipelineVerdict) {
343        let decision = match verdict {
344            PipelineVerdict::Block { .. } => "block",
345            PipelineVerdict::Reject { .. } => "reject",
346            PipelineVerdict::Allow => "allow",
347        };
348        info!(
349            request_id = %ctx.request_id,
350            decision = decision,
351            score = ctx.score,
352            threshold = self.block_threshold,
353            mode = ?self.mode,
354            contributions = ?ctx.score_contributions,
355            "pipeline decision"
356        );
357    }
358}