Skip to main content

wm_dispatch/
secret_scan.rs

1//! Output credential-shape sampling — warn-only secret-exposure tripwire.
2//!
3//! P-PROV-5/B(c) (2026-09-10, Glama secret-exposure thread): tool outputs
4//! can carry credential-shaped strings into logs, journals, and model
5//! context. The sampler scans a deterministic 1-in-N fraction of
6//! *successful* dispatch outputs with the ingest path's high-precision
7//! detector ([`wm_memory::credential_shaped_content`]) and warns — never
8//! blocks, never logs content (matched kind names + byte sizes only).
9//! Counters feed the false-positive-rate report that gates any future
10//! enforcement, which is an explicit non-goal of this module.
11//!
12//! Sampling is counter-deterministic (`seen % every == 0`), so tests and
13//! audits reproduce exactly which dispatches were scanned. `every == 0`
14//! disables scanning entirely (zero per-dispatch cost beyond one branch).
15
16use std::sync::Arc;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19/// Default sampling cadence: scan every 100th successful output (~1%).
20pub const DEFAULT_SAMPLE_EVERY: u64 = 100;
21
22/// Environment knob: `WM_SECRET_SCAN_EVERY` (`0` = off, unset/invalid =
23/// [`DEFAULT_SAMPLE_EVERY`]).
24pub const SAMPLE_EVERY_ENV: &str = "WM_SECRET_SCAN_EVERY";
25
26/// Warn-only sampler over successful dispatch outputs.
27pub struct SecretSampler {
28    every: u64,
29    seen: AtomicU64,
30    sampled: AtomicU64,
31    hits: AtomicU64,
32}
33
34impl SecretSampler {
35    /// Build with an explicit cadence (`0` disables).
36    #[must_use]
37    pub const fn new(every: u64) -> Self {
38        Self {
39            every,
40            seen: AtomicU64::new(0),
41            sampled: AtomicU64::new(0),
42            hits: AtomicU64::new(0),
43        }
44    }
45
46    /// Build from [`SAMPLE_EVERY_ENV`] (default [`DEFAULT_SAMPLE_EVERY`]).
47    #[must_use]
48    pub fn from_env() -> Self {
49        Self::new(parse_every(std::env::var(SAMPLE_EVERY_ENV).ok().as_deref()))
50    }
51
52    /// Sampling cadence (`0` = disabled).
53    #[must_use]
54    pub const fn every(&self) -> u64 {
55        self.every
56    }
57
58    /// (outputs seen, outputs scanned, outputs with credential-shaped hits).
59    #[must_use]
60    pub fn stats(&self) -> (u64, u64, u64) {
61        (
62            self.seen.load(Ordering::Relaxed),
63            self.sampled.load(Ordering::Relaxed),
64            self.hits.load(Ordering::Relaxed),
65        )
66    }
67
68    /// Maybe scan one successful output. Returns the matched kinds (empty
69    /// when skipped, disabled, clean, or unserializable). Hits emit a
70    /// `WARN` carrying kind names and sizes only — content is never logged.
71    pub fn scan(&self, tool: &str, output: &serde_json::Value) -> Vec<&'static str> {
72        if self.every == 0 {
73            return Vec::new();
74        }
75        let n = self.seen.fetch_add(1, Ordering::Relaxed);
76        if n % self.every != 0 {
77            return Vec::new();
78        }
79        self.sampled.fetch_add(1, Ordering::Relaxed);
80        let Ok(text) = serde_json::to_string(output) else {
81            return Vec::new();
82        };
83        let kinds = wm_memory::credential_shaped_content(&text);
84        if !kinds.is_empty() {
85            self.hits.fetch_add(1, Ordering::Relaxed);
86            tracing::warn!(
87                tool,
88                kinds = ?kinds,
89                output_bytes = text.len(),
90                "secret-scan: successful output looks credential-bearing (warn-only; content withheld)"
91            );
92        }
93        kinds
94    }
95}
96
97/// Parse a cadence value.
98///
99/// Explicit numbers honored (`0` disables), missing/garbage falls back to
100/// [`DEFAULT_SAMPLE_EVERY`]. Split out so tests never mutate process-global
101/// env (Rust 2024 `set_var` is unsafe and the crates forbid it).
102#[must_use]
103pub fn parse_every(value: Option<&str>) -> u64 {
104    value
105        .and_then(|v| v.trim().parse::<u64>().ok())
106        .unwrap_or(DEFAULT_SAMPLE_EVERY)
107}
108
109/// Shared sampler handle for the dispatch pipeline.
110pub type SharedSampler = Arc<SecretSampler>;
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    // AWS-documentation example key shape (non-live, publisheddocs only).
117    const EXAMPLE_AKIA: &str = "AKIAIOSFODNN7EXAMPLE";
118
119    fn key_output() -> serde_json::Value {
120        serde_json::json!({"data": format!("key={EXAMPLE_AKIA}")})
121    }
122
123    #[test]
124    fn disabled_sampler_scans_nothing() {
125        let s = SecretSampler::new(0);
126        assert!(s.scan("memory.read", &key_output()).is_empty());
127        assert_eq!(s.stats(), (0, 0, 0));
128    }
129
130    #[test]
131    fn cadence_is_counter_deterministic() {
132        let s = SecretSampler::new(3);
133        for _ in 0..9 {
134            s.scan("t", &serde_json::json!({"ok": true}));
135        }
136        // Dispatches 0, 3, 6 (0-indexed) sampled.
137        assert_eq!(s.stats(), (9, 3, 0));
138    }
139
140    #[test]
141    fn hit_kinds_returned_and_counted() {
142        let s = SecretSampler::new(1);
143        let kinds = s.scan("memory.read", &key_output());
144        assert!(kinds.contains(&"aws_access_key_id"));
145        assert_eq!(s.stats(), (1, 1, 1));
146    }
147
148    #[test]
149    fn clean_output_sampled_without_hit() {
150        let s = SecretSampler::new(1);
151        let kinds = s.scan("memory.search", &serde_json::json!({"results": []}));
152        assert!(kinds.is_empty());
153        assert_eq!(s.stats(), (1, 1, 0));
154    }
155
156    #[test]
157    fn parse_every_defaults_and_parses() {
158        // Pure parse function — no process-global env mutation (Rust 2024
159        // `set_var` is unsafe; the crates forbid it).
160        assert_eq!(parse_every(None), DEFAULT_SAMPLE_EVERY);
161        assert_eq!(parse_every(Some("7")), 7);
162        assert_eq!(parse_every(Some("0")), 0);
163        assert_eq!(parse_every(Some("  25  ")), 25);
164        assert_eq!(parse_every(Some("garbage")), DEFAULT_SAMPLE_EVERY);
165        assert_eq!(parse_every(Some("")), DEFAULT_SAMPLE_EVERY);
166    }
167}