use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::BTreeMap;
use crate::error::{Error, Result};
mod structured;
pub use structured::transform_outbound_json;
pub(crate) use structured::{
discover_outbound_json, mask_known_json, restore_inbound_json, transform_assistant_replay_json,
};
pub const SECRETS_SCHEMA: &str = "pi.secrets.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SecretsMode {
Off,
#[default]
Obfuscate,
Block,
}
impl SecretsMode {
#[must_use]
pub fn from_setting(raw: Option<&str>) -> Self {
match raw
.unwrap_or("obfuscate")
.trim()
.to_ascii_lowercase()
.as_str()
{
"off" | "false" | "disabled" => Self::Off,
"block" => Self::Block,
_ => Self::Obfuscate,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Obfuscate => "obfuscate",
Self::Block => "block",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretsSettings {
pub mode: Option<String>,
pub extra_patterns: Option<Vec<String>>,
}
struct Rule {
name: &'static str,
regex: regex::Regex,
label: &'static str,
reject: Option<fn(&str) -> bool>,
}
fn dotted_without_digit(value: &str) -> bool {
value.contains('.') && !value.bytes().any(|b| b.is_ascii_digit())
}
pub const SECRETS_RULESET_VERSION: u32 = 6;
fn dotted_body(class: &str, tail_class: &str, min: usize) -> String {
debug_assert!(min >= 2, "dotted_body needs room for head + tail");
let middle = min - 2;
format!("[{class}](?:\\.?[{class}]){{{middle},}}\\.?[{tail_class}]")
}
fn rules() -> &'static Vec<Rule> {
static RULES: std::sync::LazyLock<Vec<Rule>> = std::sync::LazyLock::new(|| {
const TOKEN: &str = r"A-Za-z0-9_\-";
const ALNUM: &str = "A-Za-z0-9";
const SK_PREFIX: &str = r"(?:^|[^A-Za-z]|\\[bfnrtv])";
let sk_body = dotted_body(TOKEN, ALNUM, 16);
vec![
Rule {
name: "anthropic-key",
regex: regex::Regex::new(&format!(r"{SK_PREFIX}(sk-ant-{sk_body})")).expect("rule"),
label: "anthropic_key",
reject: None,
},
Rule {
name: "openai-key",
regex: regex::Regex::new(&format!(r"{SK_PREFIX}(sk-{sk_body})")).expect("rule"),
label: "openai_key",
reject: None,
},
Rule {
name: "github-pat",
regex: regex::Regex::new(r"gh[pousr]_[A-Za-z0-9]{20,}").expect("rule"),
label: "github_pat",
reject: None,
},
Rule {
name: "github-pat-fine",
regex: regex::Regex::new(r"github_pat_[A-Za-z0-9_]{20,}").expect("rule"),
label: "github_pat",
reject: None,
},
Rule {
name: "aws-access-key",
regex: regex::Regex::new(r"AKIA[0-9A-Z]{16}").expect("rule"),
label: "aws_access_key",
reject: None,
},
Rule {
name: "private-key",
regex: regex::Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").expect("rule"),
label: "private_key",
reject: None,
},
Rule {
name: "google-api-key",
regex: regex::Regex::new(r"AIza[0-9A-Za-z_\-]{20,}").expect("rule"),
label: "google_api_key",
reject: None,
},
Rule {
name: "slack-token",
regex: regex::Regex::new(r"xox[baprs]-[A-Za-z0-9\-]{10,}").expect("rule"),
label: "slack_token",
reject: None,
},
Rule {
name: "jwt",
regex: regex::Regex::new(
r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}",
)
.expect("rule"),
label: "jwt",
reject: None,
},
Rule {
name: "dsn",
regex: regex::Regex::new(
r"(?i)(?:postgres|mysql|mongodb|redis|amqp)://[^\s/:@]+:[^\s/@]+@",
)
.expect("rule"),
label: "dsn",
reject: None,
},
Rule {
name: "generic-assignment",
regex: regex::Regex::new(&format!(
r#"(?i)["']?(?:api[_-]?key|secret|token|password|passwd|pwd)["']?\s*[=:]\s*['"]?({})['"]?"#,
dotted_body(r"A-Za-z0-9+/=_\-", "A-Za-z0-9+/=", 16)
))
.expect("rule"),
label: "generic_secret",
reject: Some(dotted_without_digit),
},
]
});
&RULES
}
#[derive(Debug, Clone)]
pub struct Detection {
pub start: usize,
pub end: usize,
pub rule: &'static str,
pub label: &'static str,
}
#[must_use]
pub fn scan(text: &str, extra_patterns: &[regex::Regex]) -> Vec<Detection> {
let mut out = Vec::new();
for rule in rules() {
let mut covered_until = 0;
for caps in rule.regex.captures_iter(text) {
let m = caps.get(1).or_else(|| caps.get(0)).expect("match group 0");
if m.start() < covered_until || rule.reject.is_some_and(|reject| reject(m.as_str())) {
continue;
}
let end = if rule.name == "private-key" {
private_key_end(text, m.start(), m.end())
} else {
m.end()
};
covered_until = end;
out.push(Detection {
start: m.start(),
end,
rule: rule.name,
label: rule.label,
});
}
}
for pattern in extra_patterns {
for m in pattern.find_iter(text) {
if !m.is_empty() {
out.push(Detection {
start: m.start(),
end: m.end(),
rule: "user",
label: "user_pattern",
});
}
}
}
merge_detections(out)
}
fn private_key_end(text: &str, start: usize, header_end: usize) -> usize {
const BEGIN: &str = "-----BEGIN ";
const DASHES: &str = "-----";
let label = &text[start + BEGIN.len()..header_end - DASHES.len()];
let footer = format!("-----END {label}-----");
text[header_end..]
.find(&footer)
.map_or(text.len(), |offset| header_end + offset + footer.len())
}
fn merge_detections(mut detections: Vec<Detection>) -> Vec<Detection> {
detections.sort_by_key(|detection| detection.start);
let mut merged: Vec<Detection> = Vec::with_capacity(detections.len());
for detection in detections {
if let Some(previous) = merged.last_mut()
&& detection.start < previous.end
{
previous.end = previous.end.max(detection.end);
} else {
merged.push(detection);
}
}
merged
}
#[must_use]
pub fn contains_secret(text: &str, extra_patterns: &[regex::Regex]) -> bool {
!scan(text, extra_patterns).is_empty()
}
#[derive(Clone, Default)]
pub struct SecretVault {
by_value: std::collections::HashMap<String, String>,
by_placeholder: std::collections::HashMap<String, String>,
next: u64,
}
impl std::fmt::Debug for SecretVault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretVault")
.field("entries", &self.by_value.len())
.finish_non_exhaustive()
}
}
type MaskReplacements<'a> = BTreeMap<Cow<'a, str>, &'a str>;
const OVERLAP_REDACTION: &str = "<pi-secret:redacted>";
fn placeholder_pattern() -> &'static regex::Regex {
static PATTERN: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"<pi-secret:[0-9a-f]{6,16}>").expect("placeholder grammar")
});
&PATTERN
}
fn inside_placeholder(start: usize, end: usize, protected: &[(usize, usize)]) -> bool {
protected
.iter()
.any(|&(left, right)| start >= left && end <= right)
}
fn literal_detections<'a>(
text: &str,
values: impl IntoIterator<Item = &'a str>,
protected: &[(usize, usize)],
) -> Vec<Detection> {
let mut detections = Vec::new();
for value in values {
if value.is_empty() {
continue;
}
let mut offset = 0;
while let Some(relative) = text[offset..].find(value) {
let start = offset + relative;
let end = start + value.len();
if !inside_placeholder(start, end, protected) {
detections.push(Detection {
start,
end,
rule: "known-value",
label: "known_secret",
});
}
offset = start + value.chars().next().expect("nonempty value").len_utf8();
}
}
detections
}
impl SecretVault {
fn placeholder_for(&mut self, value: &str, _label: &str) -> String {
if let Some(existing) = self.by_value.get(value) {
return existing.clone();
}
self.next = self.next.saturating_add(1);
let placeholder = format!("<pi-secret:{:06x}>", self.next);
self.by_value.insert(value.to_string(), placeholder.clone());
self.by_placeholder
.insert(placeholder.clone(), value.to_string());
placeholder
}
fn protected_placeholders(&self, text: &str) -> Vec<(usize, usize)> {
placeholder_pattern()
.find_iter(text)
.filter(|found| self.by_placeholder.contains_key(found.as_str()))
.map(|found| (found.start(), found.end()))
.collect()
}
#[must_use]
pub fn restore(&self, text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for found in placeholder_pattern().find_iter(text) {
out.push_str(&text[cursor..found.start()]);
out.push_str(
self.by_placeholder
.get(found.as_str())
.map_or(found.as_str(), String::as_str),
);
cursor = found.end();
}
out.push_str(&text[cursor..]);
out
}
fn mask_replacements(&self) -> MaskReplacements<'_> {
let mut replacements: MaskReplacements<'_> = self
.by_value
.iter()
.map(|(value, placeholder)| (Cow::Borrowed(value.as_str()), placeholder.as_str()))
.collect();
for (value, placeholder) in &self.by_value {
let encoded = serde_json::to_string(value).expect("serialize secret string");
let escaped = &encoded[1..encoded.len() - 1];
if escaped != value {
replacements
.entry(Cow::Owned(escaped.to_string()))
.or_insert(placeholder.as_str());
}
}
replacements
}
fn mask_literal_text(&self, text: &str, replacements: &MaskReplacements<'_>) -> String {
let protected = self.protected_placeholders(text);
let detections = merge_detections(literal_detections(
text,
replacements.keys().map(AsRef::as_ref),
&protected,
));
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for detection in detections {
out.push_str(&text[cursor..detection.start]);
let value = &text[detection.start..detection.end];
out.push_str(
replacements
.get(value)
.copied()
.unwrap_or(OVERLAP_REDACTION),
);
cursor = detection.end;
}
out.push_str(&text[cursor..]);
out
}
#[allow(clippy::option_if_let_else)]
fn mask_json_document(
&self,
text: &str,
replacements: &MaskReplacements<'_>,
) -> Option<String> {
if !matches!(text.trim_start().chars().next()?, '{' | '[' | '"') {
return None;
}
let value: serde_json::Value = serde_json::from_str(text).ok()?;
let masked = if let Some(placeholder) = self.by_value.get(text.trim()) {
serde_json::Value::String(placeholder.clone())
} else {
structured::mask_known_json_with_replacements(&value, self, replacements)
.unwrap_or_else(|_| serde_json::Value::String(OVERLAP_REDACTION.to_string()))
};
if masked == value {
return Some(text.to_string());
}
Some(serde_json::to_string(&masked).expect("serialize masked JSON value"))
}
fn mask_json_lines(&self, text: &str, replacements: &MaskReplacements<'_>) -> Option<String> {
let mut output = String::with_capacity(text.len());
let mut saw_document = false;
for line in text.split_inclusive('\n') {
let (body, ending) = line
.strip_suffix("\r\n")
.map(|body| (body, "\r\n"))
.or_else(|| line.strip_suffix('\n').map(|body| (body, "\n")))
.unwrap_or((line, ""));
if body.trim().is_empty() {
output.push_str(line);
continue;
}
output.push_str(&self.mask_json_document(body, replacements)?);
output.push_str(ending);
saw_document = true;
}
saw_document.then_some(output)
}
#[must_use]
pub fn mask(&self, text: &str) -> String {
if self.by_value.is_empty() {
return text.to_string();
}
let replacements = self.mask_replacements();
if let Some(masked) = self.mask_json_document(text, &replacements) {
return masked;
}
if text.contains('\n')
&& let Some(masked) = self.mask_json_lines(text, &replacements)
{
return masked;
}
self.mask_literal_text(text, &replacements)
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.by_value.len()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TransformAudit {
pub schema: String,
pub direction: String,
pub detections: usize,
pub rules: Vec<String>,
}
pub fn obfuscate(
text: &str,
vault: &mut SecretVault,
extra_patterns: &[regex::Regex],
) -> (String, TransformAudit) {
let protected = vault.protected_placeholders(text);
let mut detections = scan(text, extra_patterns);
detections.retain(|detection| !inside_placeholder(detection.start, detection.end, &protected));
for detection in &detections {
let _ = vault.placeholder_for(&text[detection.start..detection.end], detection.label);
}
detections.extend(literal_detections(
text,
vault.by_value.keys().map(String::as_str),
&protected,
));
let detections = merge_detections(detections);
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
let mut rules_hit: Vec<String> = Vec::new();
for detection in &detections {
out.push_str(&text[cursor..detection.start]);
let value = &text[detection.start..detection.end];
let placeholder = vault.placeholder_for(value, detection.label);
out.push_str(&placeholder);
cursor = detection.end;
if !rules_hit.iter().any(|r| r == detection.label) {
rules_hit.push(detection.label.to_string());
}
}
out.push_str(&text[cursor..]);
(
out,
TransformAudit {
schema: SECRETS_SCHEMA.to_string(),
direction: "outbound".to_string(),
detections: detections.len(),
rules: rules_hit,
},
)
}
#[must_use]
pub fn restore(text: &str, vault: &SecretVault) -> String {
vault.restore(text)
}
pub fn gate_outbound(text: &str, mode: SecretsMode, extra_patterns: &[regex::Regex]) -> Result<()> {
if mode == SecretsMode::Block && contains_secret(text, extra_patterns) {
return Err(Error::validation(
"PI_SECRET_BLOCK: message contains credential-shaped content and secrets.mode=block \
— refusing to send. Remove the secret or switch secrets.mode to obfuscate."
.to_string(),
));
}
Ok(())
}
#[must_use]
pub fn compile_extra_patterns(patterns: &[String]) -> Vec<regex::Regex> {
patterns
.iter()
.filter_map(|pattern| regex::Regex::new(pattern).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detector_finds_known_shapes() {
assert!(contains_secret("key = sk-abcdefghijklmnopqrstuvwxyz", &[]));
assert!(contains_secret("sk-ant-api03-aaaaaaaaaaaaaaaaaaaa", &[]));
assert!(contains_secret("ghp_abcdefghijklmnopqrstuvwxyz0123", &[]));
assert!(contains_secret(concat!("AKIA", "IOSFODNN7EXAMPLE"), &[])); assert!(contains_secret(
concat!("-----BEGIN ", "OPENSSH PRIVATE KEY-----"),
&[]
));
assert!(contains_secret(
"postgres://user:hunter2secret@db.internal/prod",
&[]
));
assert!(contains_secret("api_key = sk_live_51abcdefghijklmnop", &[]));
}
#[test]
fn detector_passes_clean_code() {
assert!(!contains_secret("fn main() { println!(\"hello\"); }", &[]));
assert!(!contains_secret("let timeout = 30;", &[]));
assert!(!contains_secret("use std::collections::HashMap;", &[]));
assert!(!contains_secret("const MAX: usize = 1024;", &[]));
}
#[test]
fn sk_rules_do_not_match_inside_identifiers() {
for clean in [
"mkdir task-management-service",
"mcp__task-master-ai__get_tasks",
"cd risk-assessment-pipeline-v2",
"disk-ant-performance-tuning-guide",
r"\ntask-management-service",
r"\tmcp__task-master-ai__get_tasks",
"nsk-0123456789abcdefghijklmn",
"rsk-ant-api03-aaaaaaaaaaaaaaaaaaaa",
] {
assert!(!contains_secret(clean, &[]), "{clean}");
}
for key in [
"OPENAI_API_KEY=sk-proj-0123456789abcdefghij",
"\"sk-ant-api03-aaaaaaaaaaaaaaaaaaaa\"",
"Authorization: Bearer sk-0123456789abcdefghijklmn",
"Bearer%20sk-0123456789abcdefghijklmn",
"OPENAI_KEY_sk-0123456789abcdefghijklmn",
"sk-0123456789abcdefghijklmn",
] {
assert!(contains_secret(key, &[]), "{key}");
}
let detections = scan("token:sk-0123456789abcdefghijklmn", &[]);
assert_eq!(detections.len(), 1);
assert_eq!(detections[0].start, "token:".len());
}
#[test]
fn sk_rules_detect_keys_after_escaped_control_boundaries() {
for (key, rule) in [
("sk-0123456789abcdefghijklmn", "openai-key"),
("sk-ant-api03-aaaaaaaaaaaaaaaaaaaa", "anthropic-key"),
] {
for escape in [r"\n", r"\t", r"\r", r"\b", r"\f", r"\v", r"\r\n", r"\\n"] {
let prefix = format!("π: before{escape}");
let input = format!("{prefix}{key}{escape}after");
let detections = scan(&input, &[]);
assert_eq!(detections.len(), 1, "{input}");
let detection = &detections[0];
assert_eq!(detection.start, prefix.len());
assert_eq!(&input[detection.start..detection.end], key);
assert_eq!(detection.rule, rule);
let mut vault = SecretVault::default();
let (protected, audit) = obfuscate(&input, &mut vault, &[]);
assert_eq!(
protected,
format!("{prefix}<pi-secret:000001>{escape}after")
);
assert_eq!(audit.detections, 1);
assert_eq!(vault.restore(&protected), input);
let error = gate_outbound(&input, SecretsMode::Block, &[]).unwrap_err();
assert!(error.to_string().contains("PI_SECRET_BLOCK"));
assert!(!error.to_string().contains(key));
}
}
}
#[test]
fn vault_placeholder_stable_and_restorable() {
let mut vault = SecretVault::default();
let (first, _) = obfuscate("the key is sk-aaaaaaaaaaaaaaaaaaaaaaaa", &mut vault, &[]);
let (second, _) = obfuscate("again: sk-aaaaaaaaaaaaaaaaaaaaaaaa", &mut vault, &[]);
let placeholder = "<pi-secret:000001>";
assert!(first.contains(placeholder), "{first}");
assert!(second.contains(placeholder), "stable per session: {second}");
assert_eq!(vault.len(), 1);
let restored = vault.restore(&first);
assert!(restored.contains("sk-aaaaaaaaaaaaaaaaaaaaaaaa"));
let masked = vault.mask("echo sk-aaaaaaaaaaaaaaaaaaaaaaaa");
assert!(masked.contains(placeholder), "{masked}");
}
#[test]
fn block_mode_refuses_with_named_error() {
let err =
gate_outbound("sk-aaaaaaaaaaaaaaaaaaaaaaaa", SecretsMode::Block, &[]).unwrap_err();
assert!(err.to_string().contains("PI_SECRET_BLOCK"), "{err}");
assert!(gate_outbound("clean text", SecretsMode::Block, &[]).is_ok());
assert!(gate_outbound("sk-aaaaaaaaaaaaaaaaaaaaaaaa", SecretsMode::Off, &[]).is_ok());
}
#[test]
fn generic_assignment_vaults_only_the_value() {
let mut vault = SecretVault::default();
let (out, _) = obfuscate("API_KEY=hunter2hunter2hunter2", &mut vault, &[]);
assert!(
out.starts_with("API_KEY=<pi-secret:"),
"key name must survive, only the value is vaulted: {out}"
);
let restored = vault.restore("export TOKEN=<pi-secret:000001>");
assert_eq!(restored, "export TOKEN=hunter2hunter2hunter2");
assert_eq!(vault.mask("hunter2hunter2hunter2"), "<pi-secret:000001>");
let (dsn_out, _) = obfuscate("postgres://user:pw@host/db", &mut vault, &[]);
assert!(dsn_out.starts_with("<pi-secret:"), "{dsn_out}");
}
#[test]
fn detector_matrix_positive_shapes() {
let cases: &[(&str, &str, &str)] = &[
(
"openai plain",
"sk-proj-abcdefghijklmnopqrstuvwxyz0123",
"openai-key",
),
(
"openai dotted (BaiLian sk-sp)",
"sk-sp-H.EEDDM.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV.wXyZ01234",
"openai-key",
),
(
"openai dotted short segments",
"sk-ab.cd.ef.gh.ij.kl.mn.op.qr",
"openai-key",
),
(
"anthropic",
"sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz_0123456789-AbCdEfGh",
"anthropic-key",
),
(
"github classic",
"ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789",
"github-pat",
),
(
"github oauth",
"gho_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789",
"github-pat",
),
(
"github fine-grained",
"github_pat_11ABCDEFG0abcdefghijklmn_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij",
"github-pat-fine",
),
("aws", concat!("AKIA", "IOSFODNN7EXAMPLE"), "aws-access-key"),
(
"google",
"AIzaSyA-bCdEfGhIjKlMnOpQrStUvWxYz0123456",
"google-api-key",
),
(
"slack bot",
"xoxb-1234567890-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx",
"slack-token",
),
(
"jwt",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"jwt",
),
(
"private key header",
concat!("-----BEGIN ", "RSA PRIVATE KEY-----"),
"private-key",
),
(
"dsn dotted password",
"postgres://svc:p.ass.word@db.internal:5432/prod",
"dsn",
),
(
"generic plain",
"API_KEY=hunter2hunter2hunter2",
"generic-assignment",
),
(
"generic dotted",
"apiKey: \"H.EEDDM1.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV\"",
"generic-assignment",
),
(
"generic dotted, digit only in the last segment",
"token=abcdefgh.ijklmnop.qrstuvwxyz.42",
"generic-assignment",
),
];
for (name, text, expect) in cases {
let hits = scan(text, &[]);
assert!(
hits.iter().any(|d| d.rule == *expect),
"{name}: expected rule {expect} to fire on {text:?}, got {:?}",
hits.iter().map(|d| d.rule).collect::<Vec<_>>()
);
}
}
#[test]
fn detector_matrix_negative_shapes() {
let cases: &[&str] = &[
"sk-foo.example.com",
"sk-8 is the Skoda model. Not a key.",
"sk-",
"sk-abc.def",
"<pi-secret:000001>",
"restored <pi-secret:00000a> and <pi-secret:0000ff> fine",
"version: 1.2.3.4",
"token: docs.example.com",
"apiKey: process.env.OPENAI_API_KEY",
"const token = process.env.GITHUB_TOKEN;",
"password: self.config.database.password",
"secret = settings.integrations.slack.signing_secret",
"password: correct horse battery staple",
"eyJhbGciOiJIUzI1NiJ9 alone is not a jwt",
"let x = a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p;",
];
for text in cases {
let hits = scan(text, &[]);
assert!(
hits.is_empty(),
"false positive on {text:?}: {:?}",
hits.iter()
.map(|d| (d.rule, &text[d.start..d.end]))
.collect::<Vec<_>>()
);
}
}
#[test]
fn dotted_keys_do_not_capture_trailing_punctuation() {
let key = "sk-sp-H.EEDDM.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV";
for (text, expected_tail) in [
(format!("the key is {key}."), "."),
(format!("the key is {key}..."), "..."),
(format!("the key is {key}.\nnext line"), ".\nnext line"),
(format!("(\"{key}\")"), "\")"),
] {
let hits = scan(&text, &[]);
assert_eq!(hits.len(), 1, "{text:?}: {hits:?}");
assert_eq!(&text[hits[0].start..hits[0].end], key, "{text:?}");
assert!(text[hits[0].end..].starts_with(expected_tail), "{text:?}");
}
let text = "password: H.EEDDM1.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV.";
let hits = scan(text, &[]);
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(
&text[hits[0].start..hits[0].end],
"H.EEDDM1.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV"
);
}
#[test]
fn dotted_key_round_trips_through_the_vault() {
let key = "sk-sp-H.EEDDM.JOZh.MEQ.aBcDeFgHiJ.kLmNoPqRsTuV.wXyZ01234";
let mut vault = SecretVault::default();
let (out, audit) = obfuscate(&format!("\"apiKey\": \"{key}\""), &mut vault, &[]);
assert_eq!(out, "\"apiKey\": \"<pi-secret:000001>\"", "{out}");
assert_eq!(audit.detections, 1, "one detection, not one per segment");
assert!(
scan(&out, &[]).is_empty(),
"placeholder must not be re-detected"
);
assert_eq!(
vault.restore("export KEY=<pi-secret:000001>"),
format!("export KEY={key}")
);
assert_eq!(
vault.mask(&format!("echo {key}")),
"echo <pi-secret:000001>"
);
let (again, _) = obfuscate(&key.replace('.', "1"), &mut vault, &[]);
assert_eq!(again, "<pi-secret:000002>");
assert_eq!(vault.len(), 2);
}
#[test]
fn overlapping_hits_collapse_cleanly() {
let mut vault = SecretVault::default();
let (out, audit) = obfuscate("api_key=sk-aaaaaaaaaaaaaaaaaaaaaaaa", &mut vault, &[]);
assert_eq!(out, "api_key=<pi-secret:000001>");
assert_eq!(audit.detections, 1);
}
#[test]
fn complete_private_keys_are_one_restorable_region() {
for label in [
"PRIVATE KEY",
"RSA PRIVATE KEY",
"EC PRIVATE KEY",
"DSA PRIVATE KEY",
"OPENSSH PRIVATE KEY",
"ENCRYPTED PRIVATE KEY",
] {
for newline in ["\n", "\r\n", "\\n"] {
let key = format!(
"-----BEGIN {label}-----{newline}Proc-Type: 4,ENCRYPTED{newline}DEK-Info: AES-256-CBC,0123456789ABCDEF{newline}U1lOVEhFVElDLUtFWS1CT0RZ{newline}-----END {label}-----"
);
let input = format!("before\n{key}\nafter");
let hits = scan(&input, &[]);
assert_eq!(hits.len(), 1, "{label}, {newline:?}");
assert_eq!(&input[hits[0].start..hits[0].end], key);
assert_eq!(hits[0].rule, "private-key");
let mut vault = SecretVault::default();
let (masked, audit) = obfuscate(&input, &mut vault, &[]);
assert_eq!(masked, "before\n<pi-secret:000001>\nafter");
assert_eq!(audit.detections, 1);
assert_eq!(vault.restore(&masked), input);
assert_eq!(vault.mask(&input), masked);
assert_eq!(obfuscate(&input, &mut vault, &[]).0, masked);
assert!(gate_outbound(&input, SecretsMode::Block, &[]).is_err());
}
}
}
#[test]
fn truncated_or_mismatched_private_key_envelopes_fail_closed() {
for suffix in [
"",
"\n-----END PUBLIC KEY-----\nMORE-PRIVATE-MATERIAL",
"\n-----END EC PRIVATE KEY-----\nMORE-PRIVATE-MATERIAL",
concat!("\n-----BEGIN ", "PRIVATE KEY-----\nNESTED-PRIVATE-MATERIAL"),
] {
let header = concat!("-----BEGIN ", "RSA PRIVATE KEY-----");
let input = format!("safe prefix\n{header}\nPRIVATE-BODY{suffix}");
let hits = scan(&input, &[]);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].end, input.len());
let mut vault = SecretVault::default();
let (masked, _) = obfuscate(&input, &mut vault, &[]);
assert_eq!(masked, "safe prefix\n<pi-secret:000001>");
assert_eq!(vault.restore(&masked), input);
}
}
#[test]
fn separate_pem_blocks_do_not_swallow_intervening_safe_text() {
let first = concat!(
"-----BEGIN ",
"PRIVATE KEY-----\nFIRST-BODY\n-----END PRIVATE KEY-----"
);
let second = concat!(
"-----BEGIN ",
"EC PRIVATE KEY-----\nSECOND-BODY\n-----END EC PRIVATE KEY-----"
);
let input = format!("{first}\nsafe separator\n{second}\nsafe tail");
let mut vault = SecretVault::default();
let (masked, audit) = obfuscate(&input, &mut vault, &[]);
assert_eq!(
masked,
"<pi-secret:000001>\nsafe separator\n<pi-secret:000002>\nsafe tail"
);
assert_eq!(audit.detections, 2);
assert_eq!(vault.restore(&masked), input);
for public in [
"-----BEGIN PUBLIC KEY-----\nPUBLIC-BODY\n-----END PUBLIC KEY-----",
"-----BEGIN CERTIFICATE-----\nPUBLIC-CERT\n-----END CERTIFICATE-----",
] {
assert!(scan(public, &[]).is_empty());
}
}
#[test]
fn overlapping_user_patterns_redact_the_entire_transitive_union() {
let input = "prefix abcdefghij suffix";
for patterns in [["abcde", "defgh", "ghij"], ["ghij", "defgh", "abcde"]] {
let patterns: Vec<_> = patterns
.into_iter()
.map(|pattern| regex::Regex::new(pattern).unwrap())
.collect();
let hits = scan(input, &patterns);
assert_eq!(hits.len(), 1);
assert_eq!(&input[hits[0].start..hits[0].end], "abcdefghij");
let mut vault = SecretVault::default();
let (masked, audit) = obfuscate(input, &mut vault, &patterns);
assert_eq!(masked, "prefix <pi-secret:000001> suffix");
assert_eq!(audit.detections, 1);
assert_eq!(vault.restore(&masked), input);
}
}
#[test]
fn same_start_short_matches_cannot_hide_longer_or_adjacent_matches() {
let patterns =
["abc", "abcdef", "bcde", "XYZ"].map(|pattern| regex::Regex::new(pattern).unwrap());
let input = "α abcdefXYZ ω";
let hits = scan(input, &patterns);
assert_eq!(hits.len(), 2);
assert_eq!(&input[hits[0].start..hits[0].end], "abcdef");
assert_eq!(&input[hits[1].start..hits[1].end], "XYZ");
let mut vault = SecretVault::default();
let (masked, _) = obfuscate(input, &mut vault, &patterns);
assert_eq!(masked, "α <pi-secret:000001><pi-secret:000002> ω");
assert_eq!(vault.restore(&masked), input);
}
#[test]
fn zero_width_user_matches_are_not_credentials() {
let patterns =
["", "^", "$", "SENSITIVE"].map(|pattern| regex::Regex::new(pattern).unwrap());
assert!(scan("", &patterns).is_empty());
assert!(scan("ordinary text", &patterns).is_empty());
assert!(gate_outbound("ordinary text", SecretsMode::Block, &patterns).is_ok());
let mut vault = SecretVault::default();
let (masked, audit) = obfuscate("before SENSITIVE after", &mut vault, &patterns);
assert_eq!(masked, "before <pi-secret:000001> after");
assert_eq!(audit.detections, 1);
}
#[test]
fn quoted_configuration_keys_vault_only_the_credential_value() {
let secret = "hunter2hunter2hunter2";
for key in [
"apiKey", "API_KEY", "secret", "token", "password", "passwd", "pwd",
] {
for quote in ["\"", "'", ""] {
let input = format!("{quote}{key}{quote}: \"{secret}\"");
let hits = scan(&input, &[]);
assert_eq!(hits.len(), 1);
assert_eq!(&input[hits[0].start..hits[0].end], secret);
let mut vault = SecretVault::default();
let (masked, _) = obfuscate(&input, &mut vault, &[]);
assert_eq!(
masked,
format!("{quote}{key}{quote}: \"<pi-secret:000001>\"")
);
assert_eq!(vault.restore(&masked), input);
}
}
for input in [
"\"apiKey\": \"process.env.OPENAI_API_KEY\"",
"'password': 'self.config.database.password'",
"\"token\": \"docs.example.com\"",
] {
assert!(scan(input, &[]).is_empty());
}
}
#[test]
fn discovery_protects_bare_echoes_before_and_after_the_assignment() {
let secret = "hunter2hunter2hunter2";
let input = format!("echo {secret}; API_KEY={secret}; again {secret}");
let mut vault = SecretVault::default();
let (masked, audit) = obfuscate(&input, &mut vault, &[]);
assert_eq!(
masked,
"echo <pi-secret:000001>; API_KEY=<pi-secret:000001>; again <pi-secret:000001>"
);
assert_eq!(audit.detections, 3);
assert_eq!(vault.len(), 1);
assert_eq!(vault.restore(&masked), input);
let (later, audit) = obfuscate(&format!("only {secret} remains"), &mut vault, &[]);
assert_eq!(later, "only <pi-secret:000001> remains");
assert_eq!(audit.detections, 1);
assert_eq!(audit.rules, ["known_secret"]);
}
#[test]
fn restore_does_not_interpret_inserted_values_as_more_placeholders() {
let mut vault = SecretVault::default();
let first_value = "A:<pi-secret:000002>";
let second_value = "B:<pi-secret:000001>";
let first = vault.placeholder_for(first_value, "user");
let second = vault.placeholder_for(second_value, "user");
assert_eq!(
vault.restore(&format!("{first}|{second}")),
format!("{first_value}|{second_value}")
);
let unknown = "<pi-secret:ffffff> <pi-secret:bad> <pi-secret:000001";
assert_eq!(vault.restore(unknown), unknown);
}
#[test]
fn known_placeholders_are_not_rewritten_by_literal_or_custom_matches() {
let mut vault = SecretVault::default();
let pattern = regex::Regex::new("SENSITIVE|000001").unwrap();
let patterns = [pattern];
let (masked, _) = obfuscate("SENSITIVE", &mut vault, &patterns);
assert_eq!(masked, "<pi-secret:000001>");
let (again, audit) = obfuscate(&masked, &mut vault, &patterns);
assert_eq!(again, masked);
assert_eq!(audit.detections, 0);
let _ = vault.placeholder_for("000001", "user");
assert_eq!(vault.mask(&masked), masked);
}
#[test]
fn json_masking_preserves_structure_and_escaped_secret_values() {
let mut vault = SecretVault::default();
let secret = "line one\r\nquote=\"x\"; path=C:\\keys\\value\tend";
let placeholder = vault.placeholder_for(secret, "user");
let input = serde_json::json!({
"nested": [{"credential": secret, "safe": 42}],
"clean": "unchanged",
"keyed": {secret: "kept"}
});
let encoded = serde_json::to_string_pretty(&input).unwrap();
let masked = vault.mask(&encoded);
let decoded: serde_json::Value = serde_json::from_str(&masked).unwrap();
assert_eq!(decoded["nested"][0]["credential"], placeholder);
assert_eq!(decoded["nested"][0]["safe"], 42);
assert_eq!(decoded["clean"], "unchanged");
assert_eq!(decoded["keyed"][&placeholder], "kept");
assert!(!masked.contains("line one"));
assert!(!masked.contains("keys"));
let clean = " { \"safe\" : 42, \"text\" : \"unchanged\" } \n";
assert_eq!(vault.mask(clean), clean);
assert_eq!(vault.mask(&masked), masked);
}
#[test]
fn json_lines_masking_keeps_record_boundaries_and_safe_primitives() {
let mut vault = SecretVault::default();
let secret = "first line\nsecond line";
let placeholder = vault.placeholder_for(secret, "user");
let numeric = vault.placeholder_for("1234567890123456", "user");
let first = serde_json::json!({"content": secret, "safe": true}).to_string();
let second = serde_json::json!({"token": 1_234_567_890_123_456_u64, "safe": 7}).to_string();
let input = format!("{first}\r\n\r\n{second}\n");
let masked = vault.mask(&input);
assert!(masked.contains("\r\n\r\n"));
assert!(masked.ends_with('\n'));
let records: Vec<serde_json::Value> = masked
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["content"], placeholder);
assert_eq!(records[0]["safe"], true);
assert_eq!(records[1]["token"], numeric);
assert_eq!(records[1]["safe"], 7);
}
#[test]
fn plain_scalar_text_masking_preserves_text_semantics() {
let mut vault = SecretVault::default();
let number = vault.placeholder_for("1234567890123456", "user");
let boolean = vault.placeholder_for("true", "user");
let empty = vault.placeholder_for("null", "user");
for (text, expected) in [
("1234567890123456", number),
("true", boolean),
("null", empty),
] {
let masked = vault.mask(text);
assert_eq!(masked, expected, "bare text must not acquire JSON quotes");
assert_eq!(vault.restore(&masked), text);
assert_eq!(vault.mask(&masked), masked);
}
assert_eq!(vault.mask(" 42 \n"), " 42 \n");
}
#[test]
fn ambiguous_json_exports_redact_instead_of_silently_discarding_members() {
let mut vault = SecretVault::default();
let secret = "credential-for-export";
let placeholder = vault.placeholder_for(secret, "user");
let input = serde_json::json!({
secret: "original member",
placeholder: "different member",
});
let output = vault.mask(&input.to_string());
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output).unwrap(),
serde_json::json!(OVERLAP_REDACTION),
"ambiguous documents must not look like successful partial exports",
);
assert!(!output.contains(secret));
assert_eq!(input.as_object().unwrap().len(), 2);
assert_eq!(vault.mask(&output), output);
}
#[test]
fn prefixed_logs_and_plain_multiline_values_both_mask_without_cascading() {
let mut vault = SecretVault::default();
let secret = "α secret\nβ secret";
let placeholder = vault.placeholder_for(secret, "user");
let encoded = serde_json::to_string(secret).unwrap();
assert_eq!(
vault.mask(&format!("log={encoded}")),
format!("log=\"{placeholder}\"")
);
assert_eq!(
vault.mask(&format!("before\n{secret}\nafter")),
format!("before\n{placeholder}\nafter")
);
}
#[test]
fn overlapping_remembered_values_never_expose_a_suffix_or_restore_the_wrong_key() {
let mut vault = SecretVault::default();
let _ = vault.placeholder_for("abcdef", "user");
let _ = vault.placeholder_for("defghi", "user");
assert_eq!(
vault.mask("safe abcdefghi safe"),
format!("safe {OVERLAP_REDACTION} safe")
);
let (masked, audit) = obfuscate("safe abcdefghi safe", &mut vault, &[]);
assert_eq!(masked, "safe <pi-secret:000003> safe");
assert_eq!(audit.detections, 1);
assert_eq!(vault.restore(&masked), "safe abcdefghi safe");
assert_eq!(vault.mask("safe abcdefghi safe"), masked);
}
#[test]
fn repeated_overlapping_literals_cover_the_last_match_too() {
let mut vault = SecretVault::default();
let _ = vault.placeholder_for("aaa", "user");
let (masked, _) = obfuscate("aaaa", &mut vault, &[]);
assert_eq!(masked, "<pi-secret:000002>");
assert_eq!(vault.restore(&masked), "aaaa");
assert_eq!(vault.mask("aaaa"), masked);
}
#[test]
fn vault_debug_never_prints_values_or_the_raw_value_map() {
let mut vault = SecretVault::default();
let _ = vault.placeholder_for("private-debug-canary", "user");
let debug = format!("{vault:?}");
assert!(debug.contains("entries: 1"));
assert!(!debug.contains("private-debug-canary"));
assert!(!debug.contains("<pi-secret:"));
assert!(!debug.contains("by_value"));
}
}