use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretHit {
pub kind: &'static str,
pub start: usize,
pub end: usize,
}
struct Pattern {
kind: &'static str,
re: Regex,
}
fn patterns() -> &'static [Pattern] {
static SET: OnceLock<Vec<Pattern>> = OnceLock::new();
SET.get_or_init(|| {
vec![
Pattern {
kind: "private_key",
re: Regex::new(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----").unwrap(),
},
Pattern {
kind: "anthropic_api_key",
re: Regex::new(r"sk-ant-[A-Za-z0-9_\-]{20,}").unwrap(),
},
Pattern {
kind: "openai_api_key",
re: Regex::new(r"sk-[A-Za-z0-9]{20,}").unwrap(),
},
Pattern {
kind: "stripe_key",
re: Regex::new(r"sk_(?:live|test)_[A-Za-z0-9]{20,}").unwrap(),
},
Pattern {
kind: "github_token",
re: Regex::new(r"gh[pousr]_[A-Za-z0-9]{20,}").unwrap(),
},
Pattern {
kind: "github_pat",
re: Regex::new(r"github_pat_[A-Za-z0-9_]{20,}").unwrap(),
},
Pattern {
kind: "aws_access_key_id",
re: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
},
Pattern {
kind: "bearer_token",
re: Regex::new(r"(?i)bearer\s+[A-Za-z0-9._~+/=\-]{20,}").unwrap(),
},
Pattern {
kind: "basic_auth_url",
re: Regex::new(r"(?i)(?:https?|ftp|ssh|redis|postgres|mysql|mongodb)://[A-Za-z0-9._%+-]+:[^\s@:/]+@").unwrap(),
},
]
})
}
pub fn redact(text: &str) -> (String, Vec<SecretHit>) {
let mut hits: Vec<(usize, usize, &'static str)> = Vec::new();
for pattern in patterns() {
for m in pattern.re.find_iter(text) {
hits.push((m.start(), m.end(), pattern.kind));
}
}
if hits.is_empty() {
return (text.to_string(), Vec::new());
}
hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
let mut chosen: Vec<(usize, usize, &'static str)> = Vec::with_capacity(hits.len());
let mut cursor_end: usize = 0;
for h in &hits {
if h.0 >= cursor_end {
chosen.push(*h);
cursor_end = h.1;
}
}
let mut out = String::with_capacity(text.len());
let mut prev = 0;
let mut reported = Vec::with_capacity(chosen.len());
for (start, end, kind) in chosen {
out.push_str(&text[prev..start]);
out.push_str(&format!("[REDACTED:{kind}]"));
reported.push(SecretHit { kind, start, end });
prev = end;
}
out.push_str(&text[prev..]);
(out, reported)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_key_redacted() {
let text = "auth: sk-abcdefghijklmnopqrstuvwxyz012345 do stuff";
let (out, hits) = redact(text);
assert!(out.contains("[REDACTED:openai_api_key]"), "openai key redacted; got: {out}");
assert_eq!(hits.len(), 1);
}
#[test]
fn anthropic_key_preferred_over_generic_sk() {
let text = "sk-ant-api03-abcdefghij0123456789012345 more";
let (out, hits) = redact(text);
assert!(out.contains("[REDACTED:anthropic_api_key]"));
assert_eq!(hits.len(), 1);
}
#[test]
fn private_key_block_redacted() {
let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nafter";
let (out, _) = redact(text);
assert!(out.contains("[REDACTED:private_key]"));
assert!(out.starts_with("before\n"));
assert!(out.ends_with("\nafter"));
}
#[test]
fn bearer_token_redacted() {
let (out, hits) = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc");
assert!(out.contains("[REDACTED:bearer_token]"), "got: {out}");
assert_eq!(hits[0].kind, "bearer_token");
}
#[test]
fn basic_auth_url_redacted() {
let (out, _) = redact("clone https://alice:s3cret@example.com/repo.git");
assert!(out.contains("[REDACTED:basic_auth_url]"));
assert!(out.contains("example.com/repo.git"));
}
#[test]
fn aws_access_key_redacted() {
let (out, hits) = redact("aws AKIAIOSFODNN7EXAMPLE etc");
assert!(out.contains("[REDACTED:aws_access_key_id]"));
assert_eq!(hits[0].kind, "aws_access_key_id");
}
#[test]
fn github_token_redacted() {
let (out, _) = redact("push ghp_abcdefghijklmnop0123456789 ok");
assert!(out.contains("[REDACTED:github_token]"));
}
#[test]
fn clean_text_unchanged_and_no_hits() {
let text = "this is a decision with no secrets in it at all.";
let (out, hits) = redact(text);
assert_eq!(out, text);
assert!(hits.is_empty());
}
#[test]
fn idempotent_second_pass_finds_nothing() {
let text = "sk-abcdefghijklmnopqrstuvwxyz012345 ghp_abcdefghijklmnop0123456789";
let (first, hits1) = redact(text);
assert!(!hits1.is_empty(), "first pass should hit");
let (second, hits2) = redact(&first);
assert_eq!(second, first, "second pass produces identical output");
assert!(hits2.is_empty(), "second pass has no hits (idempotent)");
}
#[test]
fn hot_path_under_ten_ms_for_realistic_length() {
let clean = "this is a normal decision reason about architecture choices ".repeat(60);
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = redact(&clean);
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 500,
"1000 scans of ~4KB clean text should stay under 500ms; got {elapsed:?}"
);
}
}