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}