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}