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!(
135            out.contains("[REDACTED:openai_api_key]"),
136            "openai key redacted; got: {out}"
137        );
138        assert_eq!(hits.len(), 1);
139    }
140
141    #[test]
142    fn anthropic_key_preferred_over_generic_sk() {
143        let text = "sk-ant-api03-abcdefghij0123456789012345 more";
144        let (out, hits) = redact(text);
145        assert!(out.contains("[REDACTED:anthropic_api_key]"));
146        // must not also emit generic openai for the same span
147        assert_eq!(hits.len(), 1);
148    }
149
150    #[test]
151    fn private_key_block_redacted() {
152        let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nafter";
153        let (out, _) = redact(text);
154        assert!(out.contains("[REDACTED:private_key]"));
155        assert!(out.starts_with("before\n"));
156        assert!(out.ends_with("\nafter"));
157    }
158
159    #[test]
160    fn bearer_token_redacted() {
161        let (out, hits) = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc");
162        assert!(out.contains("[REDACTED:bearer_token]"), "got: {out}");
163        assert_eq!(hits[0].kind, "bearer_token");
164    }
165
166    #[test]
167    fn basic_auth_url_redacted() {
168        let (out, _) = redact("clone https://alice:s3cret@example.com/repo.git");
169        assert!(out.contains("[REDACTED:basic_auth_url]"));
170        assert!(out.contains("example.com/repo.git"));
171    }
172
173    #[test]
174    fn aws_access_key_redacted() {
175        let (out, hits) = redact("aws AKIAIOSFODNN7EXAMPLE etc");
176        assert!(out.contains("[REDACTED:aws_access_key_id]"));
177        assert_eq!(hits[0].kind, "aws_access_key_id");
178    }
179
180    #[test]
181    fn github_token_redacted() {
182        let (out, _) = redact("push ghp_abcdefghijklmnop0123456789 ok");
183        assert!(out.contains("[REDACTED:github_token]"));
184    }
185
186    #[test]
187    fn clean_text_unchanged_and_no_hits() {
188        let text = "this is a decision with no secrets in it at all.";
189        let (out, hits) = redact(text);
190        assert_eq!(out, text);
191        assert!(hits.is_empty());
192    }
193
194    #[test]
195    fn idempotent_second_pass_finds_nothing() {
196        let text = "sk-abcdefghijklmnopqrstuvwxyz012345 ghp_abcdefghijklmnop0123456789";
197        let (first, hits1) = redact(text);
198        assert!(!hits1.is_empty(), "first pass should hit");
199        let (second, hits2) = redact(&first);
200        assert_eq!(second, first, "second pass produces identical output");
201        assert!(hits2.is_empty(), "second pass has no hits (idempotent)");
202    }
203
204    #[test]
205    fn hot_path_under_ten_ms_for_realistic_length() {
206        // Realistic decide value + reason ~1-2KB — must be micro-second class.
207        let clean = "this is a normal decision reason about architecture choices ".repeat(60);
208        let start = std::time::Instant::now();
209        for _ in 0..1000 {
210            let _ = redact(&clean);
211        }
212        let elapsed = start.elapsed();
213        assert!(
214            elapsed.as_millis() < 500,
215            "1000 scans of ~4KB clean text should stay under 500ms; got {elapsed:?}"
216        );
217    }
218}