use std::sync::LazyLock;
use regex::Regex;
use schemars::JsonSchema;
use schemars::generate::SchemaSettings;
use serde::{Deserialize, Serialize};
use crate::schema_versions::{SchemaComponent, current_version};
pub use crate::util::mask_sensitive_command;
pub const REDACTION_SENTINEL: &str = "[REDACTED]";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RedactionMode {
Hashed,
LocalReference,
Omitted,
Masked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SensitiveDataCategory {
EnvSecret,
ApiKey,
BearerToken,
Password,
SshKey,
DatabaseUrl,
CloudCredential,
FilesystemPath,
Hostname,
SourceContent,
Pii,
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RedactionRule {
pub id: String,
pub category: SensitiveDataCategory,
pub mode: RedactionMode,
pub description: String,
}
impl RedactionRule {
fn new(
id: &str,
category: SensitiveDataCategory,
mode: RedactionMode,
description: &str,
) -> Self {
Self {
id: id.to_string(),
category,
mode,
description: description.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RedactionPolicy {
pub schema_version: String,
pub sentinel: String,
pub audit_redactions: bool,
pub rules: Vec<RedactionRule>,
}
impl Default for RedactionPolicy {
fn default() -> Self {
use RedactionMode::{Hashed, LocalReference, Masked};
use SensitiveDataCategory::{
ApiKey, BearerToken, DatabaseUrl, EnvSecret, FilesystemPath, Hostname, Password,
SourceContent, SshKey,
};
Self {
schema_version: current_version(SchemaComponent::RedactionPolicy).to_string(),
sentinel: REDACTION_SENTINEL.to_string(),
audit_redactions: true,
rules: vec![
RedactionRule::new(
"RR-001",
EnvSecret,
Masked,
"Environment assignments for known secret keys (TOKEN=, PASSWORD=, \
*_KEY=, DATABASE_URL=, provider keys) are masked to ***.",
),
RedactionRule::new(
"RR-002",
ApiKey,
Masked,
"Provider-shaped API keys (AWS AKIA…, GitHub ghp_/gho_/ghu_/ghs_/ghr_/\
github_pat_, OpenAI/Anthropic sk-…/sk-proj-…, Google AIza…, Slack xox*, \
Stripe sk_live_/sk_test_) are masked.",
),
RedactionRule::new(
"RR-003",
BearerToken,
Masked,
"Authorization Bearer tokens and JWTs (eyJ….….…) are masked.",
),
RedactionRule::new(
"RR-004",
Password,
Masked,
"Credential-shaped CLI flags (--password, --token, --secret, --api-key) \
are masked.",
),
RedactionRule::new(
"RR-005",
DatabaseUrl,
Masked,
"Credentials embedded in connection URLs (scheme://user:pass@host, \
scheme://:pass@host) have the user:pass component masked.",
),
RedactionRule::new(
"RR-006",
SshKey,
Masked,
"PEM private-key blocks (-----BEGIN … PRIVATE KEY-----) are masked.",
),
RedactionRule::new(
"RR-007",
FilesystemPath,
Masked,
"Home/user path segments (/home/<user>, /Users/<user>) are masked to \
protect usernames while preserving the rest of the path for debugging.",
),
RedactionRule::new(
"RR-008",
SourceContent,
Hashed,
"Source file contents are never stored; only blake3 fingerprints are kept \
(see proof_intent::SourceFingerprint), so unchanged-source checks remain \
possible without revealing code.",
),
RedactionRule::new(
"RR-009",
Hostname,
LocalReference,
"Worker identities are referenced by stable worker id rather than raw \
hostname/username wherever a surface does not require the host.",
),
],
}
}
}
impl RedactionPolicy {
#[must_use]
pub fn redact(&self, text: &str) -> String {
redact_secrets(text)
}
#[must_use]
pub fn schema_json() -> serde_json::Value {
serde_json::to_value(
SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<RedactionPolicy>(),
)
.expect("RedactionPolicy schema serializes")
}
#[must_use]
pub fn covered_categories(&self) -> Vec<SensitiveDataCategory> {
let mut cats: Vec<SensitiveDataCategory> = self.rules.iter().map(|r| r.category).collect();
cats.dedup();
cats
}
}
struct ShapedPattern {
re: Regex,
replacement: &'static str,
}
static SHAPED_PATTERNS: LazyLock<Vec<ShapedPattern>> = LazyLock::new(|| {
let p = |pattern: &str, replacement: &'static str| ShapedPattern {
re: Regex::new(pattern).expect("redaction regex compiles"),
replacement,
};
vec![
p(
r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
REDACTION_SENTINEL,
),
p(r"AKIA[0-9A-Z]{16}", REDACTION_SENTINEL),
p(r"gh[pousr]_[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
p(r"github_pat_[A-Za-z0-9_]{22,}", REDACTION_SENTINEL),
p(r"AIza[0-9A-Za-z_\-]{35}", REDACTION_SENTINEL),
p(r"xox[baprs]-[0-9A-Za-z\-]{10,}", REDACTION_SENTINEL),
p(r"[sr]k_(live|test)_[A-Za-z0-9]{16,}", REDACTION_SENTINEL),
p(r"sk-ant-[A-Za-z0-9_\-]{16,}", REDACTION_SENTINEL),
p(
r"sk-(proj|svcacct|admin)-[A-Za-z0-9_\-]{20,}",
REDACTION_SENTINEL,
),
p(r"sk-[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
p(
r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
REDACTION_SENTINEL,
),
p(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}", "Bearer [REDACTED]"),
p(
r"([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s:]*:[^/@\s]+@",
"${1}[REDACTED]@",
),
p(r#"(/home/|/Users/)[^/\s:"']+"#, "${1}<redacted>"),
]
});
#[must_use]
pub fn redact_secrets(text: &str) -> String {
let mut out = mask_sensitive_command(text);
for pat in SHAPED_PATTERNS.iter() {
out = pat.re.replace_all(&out, pat.replacement).into_owned();
}
out
}
#[must_use]
pub fn redact_path(path: &str) -> String {
for marker in ["/home/", "/Users/"] {
if let Some(rest_start) = path.find(marker) {
let (head, tail) = path.split_at(rest_start + marker.len());
let masked_tail = match tail.find('/') {
Some(slash) => format!("<redacted>{}", &tail[slash..]),
None => "<redacted>".to_string(),
};
return format!("{head}{masked_tail}");
}
}
path.to_string()
}
#[must_use]
pub fn redacted_hash(value: &str) -> String {
let hash = blake3::hash(value.as_bytes());
let hex = hash.to_hex();
format!("blake3:{}", &hex[..16])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_default_is_versioned_and_covers_core_categories() {
let p = RedactionPolicy::default();
assert_eq!(
p.schema_version,
current_version(SchemaComponent::RedactionPolicy)
);
assert_eq!(p.sentinel, REDACTION_SENTINEL);
for r in &p.rules {
assert!(!r.id.is_empty());
assert!(!r.description.is_empty());
}
let cats = p.covered_categories();
for needed in [
SensitiveDataCategory::EnvSecret,
SensitiveDataCategory::ApiKey,
SensitiveDataCategory::BearerToken,
SensitiveDataCategory::Password,
SensitiveDataCategory::DatabaseUrl,
SensitiveDataCategory::SshKey,
SensitiveDataCategory::FilesystemPath,
SensitiveDataCategory::SourceContent,
SensitiveDataCategory::Hostname,
] {
assert!(cats.contains(&needed), "policy missing category {needed:?}");
}
}
#[test]
fn policy_documents_three_information_boundary_modes() {
let p = RedactionPolicy::default();
let modes: Vec<RedactionMode> = p.rules.iter().map(|r| r.mode).collect();
assert!(modes.contains(&RedactionMode::Hashed));
assert!(modes.contains(&RedactionMode::LocalReference));
assert!(modes.contains(&RedactionMode::Masked));
}
#[test]
fn policy_serde_round_trip() {
let p = RedactionPolicy::default();
let json = serde_json::to_string(&p).expect("serialize");
let back: RedactionPolicy = serde_json::from_str(&json).expect("deserialize");
assert_eq!(p, back);
}
#[test]
fn representative_secrets_are_all_masked() {
let cases = [
format!("AKIA{}", "IOSFODNN7EXAMPLE"), format!("ghp_{}", "1234567890abcdefghijklmnopqrstuvwxyz"), format!("sk-{}", "abcdefghijklmnopqrstuvwxyz0123456789"), format!("sk-ant-{}", "api03abcdefghijklmnopqrstuvwxyz"), format!("AIza{}", "SyA1234567890abcdefghijklmnopqrstuvw"), format!("xox{}-{}-{}", "b", "123456789012", "abcdefghijklmnop"), format!("sk_{}_{}", "live", "abcdefghijklmnop12345678"), format!("sk-proj-{}", "Ab3_cd-EfGhIjKlMnOpQrStUvWx"), format!("github_pat_{}", "11ABCDEFG0123456789_abcdefghijklmnop"), ];
for c in &cases {
let red = redact_secrets(c);
assert!(
red.contains(REDACTION_SENTINEL),
"secret not masked: {c} -> {red}"
);
assert!(
!red.contains(c.as_str()),
"raw secret survived: {c} -> {red}"
);
}
}
#[test]
fn env_and_cli_secrets_masked() {
let token = format!("ghp_{}", "abcdefghijklmnopqrstuvwx012345");
let red = redact_secrets(&format!(
"GITHUB_TOKEN={token} cargo build --token sekret123456"
));
assert!(!red.contains(&token));
assert!(!red.contains("sekret123456"));
assert!(red.contains("GITHUB_TOKEN=***") || red.contains("GITHUB_TOKEN=[REDACTED]"));
assert!(red.contains("--token ***"));
}
#[test]
fn database_url_credentials_masked_host_preserved() {
let red = redact_secrets("postgres://admin:hunter2@db.internal:5432/app");
assert!(!red.contains("hunter2"));
assert!(!red.contains("admin:hunter2"));
assert!(red.contains("db.internal:5432/app"), "host lost: {red}");
assert!(red.contains("[REDACTED]@"), "creds not masked: {red}");
let red = redact_secrets("redis://:hunter2@cache.internal:6379/0");
assert!(!red.contains("hunter2"), "password-only URL leaked: {red}");
assert!(red.contains("cache.internal:6379/0"), "host lost: {red}");
}
#[test]
fn any_key_suffixed_assignment_masked() {
let red = redact_secrets("GROQ_KEY=gsk1234 SIGNING_KEY=abc cargo build");
assert!(!red.contains("gsk1234") && !red.contains("abc "), "{red}");
assert!(red.contains("GROQ_KEY=***") && red.contains("SIGNING_KEY=***"));
}
#[test]
fn bearer_and_jwt_masked() {
let jwt = format!(
"{}.{}.{}",
"eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "dozjgNryP4J3jVmNHl0w5N"
);
let red = redact_secrets(&format!("Authorization: Bearer {jwt}"));
assert!(!red.contains(&jwt), "token survived: {red}");
}
#[test]
fn pem_private_key_block_masked() {
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA\nsecretkeymaterial\n-----END OPENSSH PRIVATE KEY-----";
let red = redact_secrets(pem);
assert!(!red.contains("secretkeymaterial"), "key survived: {red}");
assert!(red.contains(REDACTION_SENTINEL));
}
#[test]
fn home_paths_masked_in_text_and_single_field() {
let red = redact_secrets("loaded /home/alice/.ssh/id_rsa and /Users/bob/key.pem");
assert!(red.contains("/home/<redacted>/.ssh/id_rsa"), "{red}");
assert!(red.contains("/Users/<redacted>/key.pem"), "{red}");
assert_eq!(
redact_path("/home/carol/.local/state/rch/x.jsonl"),
"/home/<redacted>/.local/state/rch/x.jsonl"
);
assert_eq!(redact_path("/tmp/rch/x"), "/tmp/rch/x");
}
#[test]
fn no_false_positives_on_safe_text() {
let safe = "cargo build --release -p rch-common && echo done in /tmp/rch";
assert_eq!(redact_secrets(safe), safe);
}
#[test]
fn redaction_is_idempotent() {
let token = format!("ghp_{}", "abcdefghijklmnopqrstuvwx012345");
let once = redact_secrets(&format!("GITHUB_TOKEN={token}"));
let twice = redact_secrets(&once);
assert_eq!(once, twice);
}
#[test]
fn redacted_hash_is_stable_and_irreversible() {
let a = redacted_hash("super-secret-value");
let b = redacted_hash("super-secret-value");
assert_eq!(a, b);
assert!(a.starts_with("blake3:"));
assert!(!a.contains("super-secret"));
assert_ne!(a, redacted_hash("different-value"));
}
#[test]
fn schema_json_lists_policy_fields() {
let schema = RedactionPolicy::schema_json();
let props = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema has properties");
for f in ["schema_version", "sentinel", "audit_redactions", "rules"] {
assert!(props.contains_key(f), "schema missing field {f}");
}
}
}