use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
struct RedactRule {
pattern: Regex,
replacement: &'static str,
category: &'static str,
}
fn compile(rules: &[(&str, &'static str, &'static str)]) -> Vec<RedactRule> {
rules
.iter()
.filter_map(|(p, r, c)| {
Regex::new(p).ok().map(|pattern| RedactRule {
pattern,
replacement: r,
category: c,
})
})
.collect()
}
fn secret_rules() -> Vec<RedactRule> {
compile(&[
(
r"sk-(?:proj-|live-)?[A-Za-z0-9_\-]{20,}",
"sk-<redacted>",
"openai_key",
),
(
r"sk-ant-[A-Za-z0-9_\-]{20,}",
"sk-ant-<redacted>",
"anthropic_key",
),
(
r"(?i)Bearer\s+[A-Za-z0-9._\-]{16,}",
"Bearer <redacted>",
"bearer_token",
),
(r"gh[pousr]_[A-Za-z0-9]{20,}", "gh<redacted>", "github_pat"),
(r"AKIA[0-9A-Z]{16}", "AKIA<redacted>", "aws_key"),
(r"ASIA[0-9A-Z]{16}", "ASIA<redacted>", "aws_key"),
(
r"xox[baprs]-[A-Za-z0-9\-]{10,}",
"xox<redacted>",
"slack_token",
),
(r"AIza[0-9A-Za-z_\-]{35}", "AIza<redacted>", "google_key"),
(
r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
"<redacted-private-key>",
"private_key",
),
(
r#"(?i)api[_-]?key["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{24,}"#,
"api_key=<redacted>",
"api_key_assignment",
),
])
}
fn pii_rules() -> Vec<RedactRule> {
compile(&[
(
r"([A-Za-z0-9._%+\-]+)@([A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
"<redacted>@$2",
"email",
),
(
r"\+?\d{1,3}[\s\-]?\(?\d{2,4}\)?[\s\-]?\d{3,4}[\s\-]?\d{3,4}",
"<redacted-phone>",
"phone",
),
])
}
static SECRET_RULES: Lazy<Vec<RedactRule>> = Lazy::new(secret_rules);
static PII_RULES: Lazy<Vec<RedactRule>> = Lazy::new(pii_rules);
fn apply_rules(input: &str, rules: &[RedactRule]) -> String {
let mut out = input.to_string();
for rule in rules {
if rule.pattern.is_match(&out) {
out = rule
.pattern
.replace_all(&out, rule.replacement)
.into_owned();
}
}
out
}
pub fn redact(input: &str) -> String {
let secrets = apply_rules(input, &SECRET_RULES);
apply_rules(&secrets, &PII_RULES)
}
pub fn redact_in_place(buf: &mut String) {
let new = redact(buf);
if new != *buf {
*buf = new;
}
}
pub fn redact_secrets(input: &str) -> String {
apply_rules(input, &SECRET_RULES)
}
pub fn scan_secrets(input: &str) -> Vec<&'static str> {
let mut found: Vec<&'static str> = Vec::new();
for rule in SECRET_RULES.iter() {
if rule.pattern.is_match(input) && !found.contains(&rule.category) {
found.push(rule.category);
}
}
found
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputGuardrailMode {
#[default]
Off,
Redact,
Block,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct OutputGuardrailConfig {
#[serde(default)]
pub mode: OutputGuardrailMode,
}
pub struct OutputGuardrailOutcome {
pub text: String,
pub categories: Vec<&'static str>,
pub blocked: bool,
}
pub const OUTPUT_BLOCKED_NOTICE: &str =
"[message withheld by output guardrail: a credential-like string was detected]";
pub fn apply_output_guardrail(
text: &str,
mode: OutputGuardrailMode,
) -> Option<OutputGuardrailOutcome> {
if mode == OutputGuardrailMode::Off {
return None;
}
let categories = scan_secrets(text);
if categories.is_empty() {
return None;
}
match mode {
OutputGuardrailMode::Off => None,
OutputGuardrailMode::Redact => Some(OutputGuardrailOutcome {
text: redact_secrets(text),
categories,
blocked: false,
}),
OutputGuardrailMode::Block => Some(OutputGuardrailOutcome {
text: OUTPUT_BLOCKED_NOTICE.to_string(),
categories,
blocked: true,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_keys_redacted() {
let out = redact("key is sk-proj-abcdef1234567890ABCDEF here");
assert!(!out.contains("abcdef"));
assert!(out.contains("sk-<redacted>"));
}
#[test]
fn bearer_header_redacted() {
let out = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
assert!(!out.contains("eyJhbG"));
assert!(out.contains("Bearer <redacted>"));
}
#[test]
fn github_pat_redacted() {
let out = redact("token=ghp_abcdef1234567890ABCDEFghij");
assert!(!out.contains("abcdef"));
}
#[test]
fn aws_key_redacted() {
let out = redact("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
assert!(out.contains("AKIA<redacted>"));
}
#[test]
fn email_partially_masked() {
let out = redact("send to alice@example.com please");
assert!(out.contains("<redacted>@example.com"));
}
#[test]
fn passthrough_for_plain_text() {
let input = "hello world, nothing sensitive here";
assert_eq!(redact(input), input);
}
#[test]
fn multiple_secrets_in_one_string() {
let input = format!(
"sk-live-1234567890abcdefghij and Bearer {}",
"xyzabcdefghijk1234567890",
);
let out = redact(&input);
assert!(!out.contains("1234567890abcdefghij"));
assert!(out.contains("sk-<redacted>"));
}
#[test]
fn redact_secrets_keeps_pii() {
let out = redact_secrets("email alice@example.com, key sk-live-1234567890abcdefghij");
assert!(out.contains("alice@example.com"));
assert!(out.contains("sk-<redacted>"));
}
#[test]
fn scan_secrets_reports_categories() {
let cats = scan_secrets("ghp_abcdef1234567890ABCDEFghij and AKIAIOSFODNN7EXAMPLE");
assert!(cats.contains(&"github_pat"));
assert!(cats.contains(&"aws_key"));
}
#[test]
fn private_key_block_redacted() {
let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEabc\n-----END RSA PRIVATE KEY-----";
assert!(scan_secrets(pem).contains(&"private_key"));
assert!(redact_secrets(pem).contains("<redacted-private-key>"));
}
#[test]
fn guardrail_off_is_noop() {
assert!(
apply_output_guardrail("sk-live-1234567890abcdefghij", OutputGuardrailMode::Off)
.is_none()
);
}
#[test]
fn guardrail_passes_clean_text() {
assert!(
apply_output_guardrail("just a normal reply", OutputGuardrailMode::Redact).is_none()
);
}
#[test]
fn guardrail_redacts_secret() {
let out = apply_output_guardrail(
"here is ghp_abcdef1234567890ABCDEFghij",
OutputGuardrailMode::Redact,
)
.expect("should fire");
assert!(!out.blocked);
assert!(out.text.contains("gh<redacted>"));
assert!(out.categories.contains(&"github_pat"));
}
#[test]
fn guardrail_blocks_secret() {
let out = apply_output_guardrail(
"here is ghp_abcdef1234567890ABCDEFghij",
OutputGuardrailMode::Block,
)
.expect("should fire");
assert!(out.blocked);
assert_eq!(out.text, OUTPUT_BLOCKED_NOTICE);
}
}