Skip to main content

edda_core/
secret_guard.rs

1//! Deterministic secret scrubbing for text about to enter the ledger
2//! (`edda decide`, `edda note`). Pattern-based, zero LLM, hot-path safe.
3//!
4//! Contract (Foundry q331 EDDA-SECRET-GUARD1):
5//! - Redact well-known secret shapes (API keys, private-key blocks, bearer
6//!   tokens, AWS access keys, basic-auth URLs) to `[REDACTED:<type>]`.
7//! - Report every hit so the caller can audit / warn.
8//! - Idempotent: running `redact()` twice yields the same output.
9//! - Micro-second hot path: regex set is precompiled once via `OnceLock`.
10//!
11//! Non-goals for v1:
12//! - Entropy scan (harder to tune, false-positive prone) — v2.
13//! - LLM assist — the whole point is the zero-LLM path stays honest.
14//! - Verbatim transcript ingest (BRIDGE-03 contract preserves the raw file).
15//!   This module is for decide/note text only.
16
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use std::sync::OnceLock;
20
21/// One redacted region: what was hit and where in the input text.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct SecretHit {
24    pub kind: &'static str,
25    pub start: usize,
26    pub end: usize,
27}
28
29struct Pattern {
30    kind: &'static str,
31    re: Regex,
32}
33
34fn patterns() -> &'static [Pattern] {
35    static SET: OnceLock<Vec<Pattern>> = OnceLock::new();
36    SET.get_or_init(|| {
37        vec![
38            // -----BEGIN … PRIVATE KEY----- … -----END … PRIVATE KEY-----
39            Pattern {
40                kind: "private_key",
41                re: Regex::new(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----").unwrap(),
42            },
43            // Anthropic API key (must come before generic sk-)
44            Pattern {
45                kind: "anthropic_api_key",
46                re: Regex::new(r"sk-ant-[A-Za-z0-9_\-]{20,}").unwrap(),
47            },
48            // OpenAI-style key
49            Pattern {
50                kind: "openai_api_key",
51                re: Regex::new(r"sk-[A-Za-z0-9]{20,}").unwrap(),
52            },
53            // Stripe live/test
54            Pattern {
55                kind: "stripe_key",
56                re: Regex::new(r"sk_(?:live|test)_[A-Za-z0-9]{20,}").unwrap(),
57            },
58            // GitHub token families
59            Pattern {
60                kind: "github_token",
61                re: Regex::new(r"gh[pousr]_[A-Za-z0-9]{20,}").unwrap(),
62            },
63            Pattern {
64                kind: "github_pat",
65                re: Regex::new(r"github_pat_[A-Za-z0-9_]{20,}").unwrap(),
66            },
67            // AWS access key id
68            Pattern {
69                kind: "aws_access_key_id",
70                re: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
71            },
72            // Bearer / Authorization tokens (min 20 chars)
73            Pattern {
74                kind: "bearer_token",
75                re: Regex::new(r"(?i)bearer\s+[A-Za-z0-9._~+/=\-]{20,}").unwrap(),
76            },
77            // basic-auth in URL (protocol://user:pass@host)
78            Pattern {
79                kind: "basic_auth_url",
80                re: Regex::new(r"(?i)(?:https?|ftp|ssh|redis|postgres|mysql|mongodb)://[A-Za-z0-9._%+-]+:[^\s@:/]+@").unwrap(),
81            },
82        ]
83    })
84}
85
86/// Scrub known secret patterns in `text`. Returns `(redacted_text, hits)`.
87///
88/// - `hits` reports positions in the ORIGINAL text (pre-replacement) so the
89///   caller can log what happened without re-scanning.
90/// - Idempotent: `redact(redact(x).0).1` is always empty.
91pub fn redact(text: &str) -> (String, Vec<SecretHit>) {
92    let mut hits: Vec<(usize, usize, &'static str)> = Vec::new();
93    for pattern in patterns() {
94        for m in pattern.re.find_iter(text) {
95            hits.push((m.start(), m.end(), pattern.kind));
96        }
97    }
98    if hits.is_empty() {
99        return (text.to_string(), Vec::new());
100    }
101
102    // Sort by start; on overlap, longer/earlier wins to avoid nested replacement.
103    hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
104    let mut chosen: Vec<(usize, usize, &'static str)> = Vec::with_capacity(hits.len());
105    let mut cursor_end: usize = 0;
106    for h in &hits {
107        if h.0 >= cursor_end {
108            chosen.push(*h);
109            cursor_end = h.1;
110        }
111    }
112
113    let mut out = String::with_capacity(text.len());
114    let mut prev = 0;
115    let mut reported = Vec::with_capacity(chosen.len());
116    for (start, end, kind) in chosen {
117        out.push_str(&text[prev..start]);
118        out.push_str(&format!("[REDACTED:{kind}]"));
119        reported.push(SecretHit { kind, start, end });
120        prev = end;
121    }
122    out.push_str(&text[prev..]);
123    (out, reported)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn openai_key_redacted() {
132        let text = "auth: sk-abcdefghijklmnopqrstuvwxyz012345 do stuff";
133        let (out, hits) = redact(text);
134        assert!(out.contains("[REDACTED:openai_api_key]"), "openai key redacted; got: {out}");
135        assert_eq!(hits.len(), 1);
136    }
137
138    #[test]
139    fn anthropic_key_preferred_over_generic_sk() {
140        let text = "sk-ant-api03-abcdefghij0123456789012345 more";
141        let (out, hits) = redact(text);
142        assert!(out.contains("[REDACTED:anthropic_api_key]"));
143        // must not also emit generic openai for the same span
144        assert_eq!(hits.len(), 1);
145    }
146
147    #[test]
148    fn private_key_block_redacted() {
149        let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nafter";
150        let (out, _) = redact(text);
151        assert!(out.contains("[REDACTED:private_key]"));
152        assert!(out.starts_with("before\n"));
153        assert!(out.ends_with("\nafter"));
154    }
155
156    #[test]
157    fn bearer_token_redacted() {
158        let (out, hits) = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc");
159        assert!(out.contains("[REDACTED:bearer_token]"), "got: {out}");
160        assert_eq!(hits[0].kind, "bearer_token");
161    }
162
163    #[test]
164    fn basic_auth_url_redacted() {
165        let (out, _) = redact("clone https://alice:s3cret@example.com/repo.git");
166        assert!(out.contains("[REDACTED:basic_auth_url]"));
167        assert!(out.contains("example.com/repo.git"));
168    }
169
170    #[test]
171    fn aws_access_key_redacted() {
172        let (out, hits) = redact("aws AKIAIOSFODNN7EXAMPLE etc");
173        assert!(out.contains("[REDACTED:aws_access_key_id]"));
174        assert_eq!(hits[0].kind, "aws_access_key_id");
175    }
176
177    #[test]
178    fn github_token_redacted() {
179        let (out, _) = redact("push ghp_abcdefghijklmnop0123456789 ok");
180        assert!(out.contains("[REDACTED:github_token]"));
181    }
182
183    #[test]
184    fn clean_text_unchanged_and_no_hits() {
185        let text = "this is a decision with no secrets in it at all.";
186        let (out, hits) = redact(text);
187        assert_eq!(out, text);
188        assert!(hits.is_empty());
189    }
190
191    #[test]
192    fn idempotent_second_pass_finds_nothing() {
193        let text = "sk-abcdefghijklmnopqrstuvwxyz012345 ghp_abcdefghijklmnop0123456789";
194        let (first, hits1) = redact(text);
195        assert!(!hits1.is_empty(), "first pass should hit");
196        let (second, hits2) = redact(&first);
197        assert_eq!(second, first, "second pass produces identical output");
198        assert!(hits2.is_empty(), "second pass has no hits (idempotent)");
199    }
200
201    #[test]
202    fn hot_path_under_ten_ms_for_realistic_length() {
203        // Realistic decide value + reason ~1-2KB — must be micro-second class.
204        let clean = "this is a normal decision reason about architecture choices ".repeat(60);
205        let start = std::time::Instant::now();
206        for _ in 0..1000 {
207            let _ = redact(&clean);
208        }
209        let elapsed = start.elapsed();
210        assert!(
211            elapsed.as_millis() < 500,
212            "1000 scans of ~4KB clean text should stay under 500ms; got {elapsed:?}"
213        );
214    }
215}