Skip to main content

waf_capture/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Request capture for [Light WAF](https://github.com/0x00spor3/Light-WAF): keep the **full** HTTP
5//! request (method, path, query, headers, cookies, body) of every request the WAF blocks or rejects,
6//! and ship it to the control plane for the event drill-down. Optional, default off.
7//!
8//! Three pieces, no change to the core:
9//!
10//! 1. [`CaptureModule`] — a detection module that never scores: it clones each request into a bounded
11//!    [`CaptureRing`] keyed by request id (cheap: the body is a ref-counted `Bytes`).
12//! 2. [`CaptureLayer`] — a `tracing` layer that watches the `request blocked` / `request rejected`
13//!    decision-log events and pulls the matching capture out of the ring.
14//! 3. [`CaptureShipper`] — a bounded queue with best-effort retry that POSTs captures to the control
15//!    plane's `/ingest/captures`; it drops on overflow and never puts backpressure on the datapath.
16//!
17//! Privacy: capture is raw by default; [`RedactionConfig`] replaces header and body-field values with
18//! `***REDACTED***` on the node, and [`CaptureConfig::max_body_bytes`] caps the body. Retention and
19//! access control are enforced by the control plane.
20
21mod capture;
22mod layer;
23mod module;
24mod ring;
25mod shipper;
26
27pub use capture::{CapturedRequest, REDACTED};
28pub use layer::{CaptureHandle, CaptureLayer};
29pub use module::CaptureModule;
30pub use ring::CaptureRing;
31pub use shipper::{CaptureShipper, CaptureSink};
32
33use std::time::Duration;
34
35/// Which header names and body field names to strip from a capture before it is stored/shipped.
36///
37/// Both lists are matched **case-insensitively**. Empty lists (the default) mean *raw capture* —
38/// nothing is stripped. Redaction is applied node-side by the capture module (C1), so a redacted
39/// value never travels to the control-plane.
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct RedactionConfig {
42    /// Header names whose value is replaced with `***REDACTED***` (e.g. `authorization`, `cookie`,
43    /// `set-cookie`). Compared case-insensitively against the request header names.
44    pub headers: Vec<String>,
45    /// Body field names (JSON keys / form parameters) whose value is replaced with `***REDACTED***`
46    /// (e.g. `password`, `token`, `card`). Compared case-insensitively.
47    pub body_fields: Vec<String>,
48}
49
50impl RedactionConfig {
51    /// Whether `name` (a request header name) must be redacted. Case-insensitive.
52    pub fn header_redacted(&self, name: &str) -> bool {
53        self.headers.iter().any(|h| h.eq_ignore_ascii_case(name))
54    }
55
56    /// Whether `name` (a body field / form parameter name) must be redacted. Case-insensitive.
57    pub fn field_redacted(&self, name: &str) -> bool {
58        self.body_fields.iter().any(|f| f.eq_ignore_ascii_case(name))
59    }
60
61    /// True when nothing is configured to redact — i.e. a raw capture.
62    pub fn is_empty(&self) -> bool {
63        self.headers.is_empty() && self.body_fields.is_empty()
64    }
65}
66
67/// Typed configuration for the request-capture feature. Built by the enterprise wiring from the
68/// `[enterprise.capture]` TOML section. Every field has a fail-safe default and the feature is OFF by
69/// default, so a config without the section behaves exactly like the plain enterprise proxy.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct CaptureConfig {
72    /// Off by default → the capture module is never even instantiated (zero datapath cost).
73    pub enabled: bool,
74    /// Base URL of the control-plane ingest (e.g. `http://controlplane:8080`). The shipper appends
75    /// `/ingest/captures`. Required when `enabled` (the wiring rejects an empty URL).
76    pub controlplane_url: String,
77    /// Name of the environment variable holding the bearer token for the ingest POST. The token
78    /// itself is NEVER read from the TOML file (same discipline as the other enterprise secrets).
79    pub token_env: String,
80    /// Cap on how many body bytes are captured; a larger body is truncated and flagged. Bounds both
81    /// datapath memory and the size shipped to the control-plane.
82    pub max_body_bytes: usize,
83    /// Max number of in-flight captures held in the ring (bound by entry count).
84    pub ring_capacity: usize,
85    /// Max total bytes held across all ring entries (bound by size — evicts oldest past this).
86    pub ring_bytes: usize,
87    /// How long a capture lives in the ring before it is evicted unshipped. A request/decision pair
88    /// is microseconds apart, so a few seconds is ample; allowed requests age out and are dropped.
89    pub ring_ttl: Duration,
90    /// Bound on the shipper's outbound queue. When full, new captures are dropped (logged/metered),
91    /// never blocking the request path.
92    pub queue_capacity: usize,
93    /// Node-side redaction of sensitive headers / body fields. Empty = raw capture (the default).
94    pub redaction: RedactionConfig,
95}
96
97impl Default for CaptureConfig {
98    fn default() -> Self {
99        Self {
100            enabled: false,
101            controlplane_url: String::new(),
102            token_env: "WAF_CAPTURE_TOKEN".to_string(),
103            max_body_bytes: 64 * 1024,
104            ring_capacity: 1024,
105            ring_bytes: 32 * 1024 * 1024,
106            ring_ttl: Duration::from_secs(5),
107            queue_capacity: 256,
108            redaction: RedactionConfig::default(),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn config_default_is_off_and_raw() {
119        let c = CaptureConfig::default();
120        assert!(!c.enabled, "capture must default OFF");
121        assert!(c.redaction.is_empty(), "default is raw capture (no redaction)");
122        assert_eq!(c.token_env, "WAF_CAPTURE_TOKEN");
123        assert_eq!(c.max_body_bytes, 64 * 1024);
124        assert_eq!(c.ring_ttl, Duration::from_secs(5));
125    }
126
127    #[test]
128    fn redaction_matches_case_insensitively() {
129        let r = RedactionConfig {
130            headers: vec!["Authorization".to_string(), "Cookie".to_string()],
131            body_fields: vec!["password".to_string()],
132        };
133        assert!(r.header_redacted("authorization"));
134        assert!(r.header_redacted("COOKIE"));
135        assert!(!r.header_redacted("user-agent"));
136        assert!(r.field_redacted("PASSWORD"));
137        assert!(!r.field_redacted("username"));
138        assert!(!r.is_empty());
139    }
140}