Skip to main content

waf_capture/
layer.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! The verdict-join `tracing::Layer`.
5//!
6//! The capture module cannot see the WAF's final verdict (the pipeline decides it after every module
7//! runs). The decision, however, is emitted as a structured `tracing` event — `request blocked` /
8//! `request rejected`, carrying `request_id` — which the control-plane ingest already relies on. This
9//! layer reads those same events: on a denial it pulls the matching capture out of the ring and hands
10//! it to the shipper. Everything else ages out of the ring unshipped.
11//!
12//! # Deferred arming
13//! The enterprise subscriber is installed at the very top of `main`, *before* the config is read, so
14//! the layer is created disarmed and the ring + sink are attached later via [`CaptureHandle::arm`]
15//! once `[enterprise.capture]` is known (and only when the feature is enabled). While disarmed —
16//! including for the whole life of a process that never enables capture — `on_event` is a single
17//! atomic load and a return: effectively free.
18
19use std::fmt;
20use std::sync::{Arc, OnceLock};
21
22use tracing::field::{Field, Visit};
23use tracing::{Event, Subscriber};
24use tracing_subscriber::layer::{Context, Layer};
25
26use crate::{CaptureRing, CaptureSink};
27
28/// Shared state attached once the config is known. Behind a `OnceLock` so the disarmed hot path is a
29/// lock-free load.
30struct Armed {
31    ring: Arc<CaptureRing>,
32    sink: CaptureSink,
33}
34
35/// Handle shared between the installed [`CaptureLayer`] and `main`. `main` keeps one clone to
36/// [`arm`](CaptureHandle::arm) after loading the config; the layer keeps another to read it.
37#[derive(Clone, Default)]
38pub struct CaptureHandle {
39    armed: Arc<OnceLock<Armed>>,
40}
41
42impl CaptureHandle {
43    /// A fresh, disarmed handle.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Attach the ring + shipper sink, enabling capture. Idempotent: a second call is a no-op (the
49    /// feature is armed exactly once at boot).
50    pub fn arm(&self, ring: Arc<CaptureRing>, sink: CaptureSink) {
51        let _ = self.armed.set(Armed { ring, sink });
52    }
53
54    fn armed(&self) -> Option<&Armed> {
55        self.armed.get()
56    }
57}
58
59/// The layer. Zero-cost while disarmed; on a denial event it does one ring `take` + one non-blocking
60/// enqueue.
61pub struct CaptureLayer {
62    handle: CaptureHandle,
63}
64
65impl CaptureLayer {
66    /// Build the layer over a (typically disarmed) handle. `main` arms the same handle later.
67    pub fn new(handle: CaptureHandle) -> Self {
68        Self { handle }
69    }
70}
71
72impl<S: Subscriber> Layer<S> for CaptureLayer {
73    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
74        let Some(armed) = self.handle.armed() else {
75            return; // disarmed → free
76        };
77        let mut visitor = DecisionVisitor::default();
78        event.record(&mut visitor);
79        if !visitor.is_denial() {
80            return;
81        }
82        let Some(request_id) = visitor.request_id else {
83            return;
84        };
85        // The capture was inserted by the module microseconds earlier, on the same request.
86        if let Some(capture) = armed.ring.take(&request_id) {
87            armed.sink.enqueue(capture);
88        }
89    }
90}
91
92/// Extracts the `message` and `request_id` fields from a decision-log event. The proxy emits both via
93/// the `%`/message sigils → `record_debug` with `format_args` (no surrounding quotes); `record_str`
94/// is handled too in case a field is ever recorded as a plain `&str`.
95#[derive(Default)]
96struct DecisionVisitor {
97    message: Option<String>,
98    request_id: Option<String>,
99}
100
101impl DecisionVisitor {
102    fn is_denial(&self) -> bool {
103        matches!(self.message.as_deref(), Some("request blocked") | Some("request rejected"))
104    }
105}
106
107impl Visit for DecisionVisitor {
108    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
109        match field.name() {
110            "message" => self.message = Some(format!("{value:?}")),
111            "request_id" if self.request_id.is_none() => {
112                self.request_id = Some(format!("{value:?}"));
113            }
114            _ => {}
115        }
116    }
117
118    fn record_str(&mut self, field: &Field, value: &str) {
119        if field.name() == "request_id" {
120            self.request_id = Some(value.to_string());
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use std::time::Duration;
129    use tracing_subscriber::layer::SubscriberExt;
130
131    use crate::CapturedRequest;
132
133    fn sample(id: &str) -> CapturedRequest {
134        CapturedRequest {
135            request_id: id.to_string(),
136            method: "POST".to_string(),
137            path: "/x".to_string(),
138            query: None,
139            http_version: "HTTP/1.1".to_string(),
140            headers: vec![],
141            cookies: vec![],
142            body_b64: String::new(),
143            body_size: 0,
144            body_truncated: false,
145            redacted: vec![],
146        }
147    }
148
149    fn armed_layer() -> (CaptureHandle, Arc<CaptureRing>, tokio::sync::mpsc::Receiver<CapturedRequest>) {
150        let ring = Arc::new(CaptureRing::new(8, 1 << 20, Duration::from_secs(5)));
151        let (sink, rx) = CaptureSink::channel(8);
152        let handle = CaptureHandle::new();
153        handle.arm(Arc::clone(&ring), sink);
154        (handle, ring, rx)
155    }
156
157    #[test]
158    fn denial_event_takes_from_ring_and_ships() {
159        let (handle, ring, mut rx) = armed_layer();
160        ring.insert(sample("req-1"));
161        let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
162        tracing::subscriber::with_default(subscriber, || {
163            let rid = "req-1";
164            // Mirror the proxy's emission: `%` sigil for request_id, static message.
165            tracing::warn!(request_id = %rid, rule_id = "sqli", "request blocked");
166        });
167        let got = rx.try_recv().expect("capture shipped");
168        assert_eq!(got.request_id, "req-1");
169        assert!(ring.is_empty(), "capture removed from the ring");
170    }
171
172    #[test]
173    fn rejected_event_also_ships() {
174        let (handle, ring, mut rx) = armed_layer();
175        ring.insert(sample("req-2"));
176        let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
177        tracing::subscriber::with_default(subscriber, || {
178            let rid = "req-2";
179            tracing::warn!(request_id = %rid, status = 429, "request rejected");
180        });
181        assert_eq!(rx.try_recv().expect("shipped").request_id, "req-2");
182    }
183
184    #[test]
185    fn non_denial_event_ships_nothing() {
186        let (handle, ring, mut rx) = armed_layer();
187        ring.insert(sample("req-3"));
188        let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
189        tracing::subscriber::with_default(subscriber, || {
190            let rid = "req-3";
191            tracing::info!(request_id = %rid, method = "GET", "→ request");
192        });
193        assert!(rx.try_recv().is_err(), "no capture for a non-denial event");
194        assert_eq!(ring.len(), 1, "ring untouched");
195    }
196
197    #[test]
198    fn disarmed_layer_is_inert() {
199        // A denial event with no armed handle must not panic and must ship nothing.
200        let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(CaptureHandle::new()));
201        tracing::subscriber::with_default(subscriber, || {
202            let rid = "req-x";
203            tracing::warn!(request_id = %rid, "request blocked");
204        });
205    }
206}