1use std::sync::LazyLock;
42
43use regex::Regex;
44use schemars::JsonSchema;
45use schemars::generate::SchemaSettings;
46use serde::{Deserialize, Serialize};
47
48use crate::schema_versions::{SchemaComponent, current_version};
49
50pub use crate::util::mask_sensitive_command;
51
52pub const REDACTION_SENTINEL: &str = "[REDACTED]";
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "snake_case")]
58pub enum RedactionMode {
59 Hashed,
62 LocalReference,
65 Omitted,
67 Masked,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
73#[serde(rename_all = "snake_case")]
74pub enum SensitiveDataCategory {
75 EnvSecret,
77 ApiKey,
79 BearerToken,
81 Password,
83 SshKey,
85 DatabaseUrl,
87 CloudCredential,
89 FilesystemPath,
91 Hostname,
93 SourceContent,
95 Pii,
97 Custom,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct RedactionRule {
104 pub id: String,
106 pub category: SensitiveDataCategory,
108 pub mode: RedactionMode,
110 pub description: String,
112}
113
114impl RedactionRule {
115 fn new(
116 id: &str,
117 category: SensitiveDataCategory,
118 mode: RedactionMode,
119 description: &str,
120 ) -> Self {
121 Self {
122 id: id.to_string(),
123 category,
124 mode,
125 description: description.to_string(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132pub struct RedactionPolicy {
133 pub schema_version: String,
135 pub sentinel: String,
137 pub audit_redactions: bool,
139 pub rules: Vec<RedactionRule>,
141}
142
143impl Default for RedactionPolicy {
144 fn default() -> Self {
145 use RedactionMode::{Hashed, LocalReference, Masked};
146 use SensitiveDataCategory::{
147 ApiKey, BearerToken, DatabaseUrl, EnvSecret, FilesystemPath, Hostname, Password,
148 SourceContent, SshKey,
149 };
150 Self {
151 schema_version: current_version(SchemaComponent::RedactionPolicy).to_string(),
152 sentinel: REDACTION_SENTINEL.to_string(),
153 audit_redactions: true,
154 rules: vec![
155 RedactionRule::new(
156 "RR-001",
157 EnvSecret,
158 Masked,
159 "Environment assignments for known secret keys (TOKEN=, PASSWORD=, \
160 *_KEY=, DATABASE_URL=, provider keys) are masked to ***.",
161 ),
162 RedactionRule::new(
163 "RR-002",
164 ApiKey,
165 Masked,
166 "Provider-shaped API keys (AWS AKIA…, GitHub ghp_/gho_/ghu_/ghs_/ghr_/\
167 github_pat_, OpenAI/Anthropic sk-…/sk-proj-…, Google AIza…, Slack xox*, \
168 Stripe sk_live_/sk_test_) are masked.",
169 ),
170 RedactionRule::new(
171 "RR-003",
172 BearerToken,
173 Masked,
174 "Authorization Bearer tokens and JWTs (eyJ….….…) are masked.",
175 ),
176 RedactionRule::new(
177 "RR-004",
178 Password,
179 Masked,
180 "Credential-shaped CLI flags (--password, --token, --secret, --api-key) \
181 are masked.",
182 ),
183 RedactionRule::new(
184 "RR-005",
185 DatabaseUrl,
186 Masked,
187 "Credentials embedded in connection URLs (scheme://user:pass@host, \
188 scheme://:pass@host) have the user:pass component masked.",
189 ),
190 RedactionRule::new(
191 "RR-006",
192 SshKey,
193 Masked,
194 "PEM private-key blocks (-----BEGIN … PRIVATE KEY-----) are masked.",
195 ),
196 RedactionRule::new(
197 "RR-007",
198 FilesystemPath,
199 Masked,
200 "Home/user path segments (/home/<user>, /Users/<user>) are masked to \
201 protect usernames while preserving the rest of the path for debugging.",
202 ),
203 RedactionRule::new(
204 "RR-008",
205 SourceContent,
206 Hashed,
207 "Source file contents are never stored; only blake3 fingerprints are kept \
208 (see proof_intent::SourceFingerprint), so unchanged-source checks remain \
209 possible without revealing code.",
210 ),
211 RedactionRule::new(
212 "RR-009",
213 Hostname,
214 LocalReference,
215 "Worker identities are referenced by stable worker id rather than raw \
216 hostname/username wherever a surface does not require the host.",
217 ),
218 ],
219 }
220 }
221}
222
223impl RedactionPolicy {
224 #[must_use]
227 pub fn redact(&self, text: &str) -> String {
228 redact_secrets(text)
229 }
230
231 #[must_use]
233 pub fn schema_json() -> serde_json::Value {
234 serde_json::to_value(
235 SchemaSettings::draft07()
236 .into_generator()
237 .into_root_schema_for::<RedactionPolicy>(),
238 )
239 .expect("RedactionPolicy schema serializes")
240 }
241
242 #[must_use]
244 pub fn covered_categories(&self) -> Vec<SensitiveDataCategory> {
245 let mut cats: Vec<SensitiveDataCategory> = self.rules.iter().map(|r| r.category).collect();
246 cats.dedup();
247 cats
248 }
249}
250
251struct ShapedPattern {
253 re: Regex,
254 replacement: &'static str,
256}
257
258static SHAPED_PATTERNS: LazyLock<Vec<ShapedPattern>> = LazyLock::new(|| {
262 let p = |pattern: &str, replacement: &'static str| ShapedPattern {
263 re: Regex::new(pattern).expect("redaction regex compiles"),
264 replacement,
265 };
266 vec![
267 p(
269 r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
270 REDACTION_SENTINEL,
271 ),
272 p(r"AKIA[0-9A-Z]{16}", REDACTION_SENTINEL),
274 p(r"gh[pousr]_[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
276 p(r"github_pat_[A-Za-z0-9_]{22,}", REDACTION_SENTINEL),
278 p(r"AIza[0-9A-Za-z_\-]{35}", REDACTION_SENTINEL),
280 p(r"xox[baprs]-[0-9A-Za-z\-]{10,}", REDACTION_SENTINEL),
282 p(r"[sr]k_(live|test)_[A-Za-z0-9]{16,}", REDACTION_SENTINEL),
284 p(r"sk-ant-[A-Za-z0-9_\-]{16,}", REDACTION_SENTINEL),
286 p(
289 r"sk-(proj|svcacct|admin)-[A-Za-z0-9_\-]{20,}",
290 REDACTION_SENTINEL,
291 ),
292 p(r"sk-[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
294 p(
296 r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
297 REDACTION_SENTINEL,
298 ),
299 p(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}", "Bearer [REDACTED]"),
301 p(
304 r"([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s:]*:[^/@\s]+@",
305 "${1}[REDACTED]@",
306 ),
307 p(r#"(/home/|/Users/)[^/\s:"']+"#, "${1}<redacted>"),
309 ]
310});
311
312#[must_use]
320pub fn redact_secrets(text: &str) -> String {
321 let mut out = mask_sensitive_command(text);
324 for pat in SHAPED_PATTERNS.iter() {
325 out = pat.re.replace_all(&out, pat.replacement).into_owned();
326 }
327 out
328}
329
330#[must_use]
335pub fn redact_path(path: &str) -> String {
336 for marker in ["/home/", "/Users/"] {
337 if let Some(rest_start) = path.find(marker) {
338 let (head, tail) = path.split_at(rest_start + marker.len());
339 let masked_tail = match tail.find('/') {
340 Some(slash) => format!("<redacted>{}", &tail[slash..]),
341 None => "<redacted>".to_string(),
342 };
343 return format!("{head}{masked_tail}");
344 }
345 }
346 path.to_string()
347}
348
349#[must_use]
354pub fn redacted_hash(value: &str) -> String {
355 let hash = blake3::hash(value.as_bytes());
356 let hex = hash.to_hex();
357 format!("blake3:{}", &hex[..16])
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn policy_default_is_versioned_and_covers_core_categories() {
366 let p = RedactionPolicy::default();
367 assert_eq!(
368 p.schema_version,
369 current_version(SchemaComponent::RedactionPolicy)
370 );
371 assert_eq!(p.sentinel, REDACTION_SENTINEL);
372 for r in &p.rules {
374 assert!(!r.id.is_empty());
375 assert!(!r.description.is_empty());
376 }
377 let cats = p.covered_categories();
379 for needed in [
380 SensitiveDataCategory::EnvSecret,
381 SensitiveDataCategory::ApiKey,
382 SensitiveDataCategory::BearerToken,
383 SensitiveDataCategory::Password,
384 SensitiveDataCategory::DatabaseUrl,
385 SensitiveDataCategory::SshKey,
386 SensitiveDataCategory::FilesystemPath,
387 SensitiveDataCategory::SourceContent,
388 SensitiveDataCategory::Hostname,
389 ] {
390 assert!(cats.contains(&needed), "policy missing category {needed:?}");
391 }
392 }
393
394 #[test]
395 fn policy_documents_three_information_boundary_modes() {
396 let p = RedactionPolicy::default();
399 let modes: Vec<RedactionMode> = p.rules.iter().map(|r| r.mode).collect();
400 assert!(modes.contains(&RedactionMode::Hashed));
401 assert!(modes.contains(&RedactionMode::LocalReference));
402 assert!(modes.contains(&RedactionMode::Masked));
403 }
404
405 #[test]
406 fn policy_serde_round_trip() {
407 let p = RedactionPolicy::default();
408 let json = serde_json::to_string(&p).expect("serialize");
409 let back: RedactionPolicy = serde_json::from_str(&json).expect("deserialize");
410 assert_eq!(p, back);
411 }
412
413 #[test]
414 fn representative_secrets_are_all_masked() {
415 let cases = [
421 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"), ];
431 for c in &cases {
432 let red = redact_secrets(c);
433 assert!(
434 red.contains(REDACTION_SENTINEL),
435 "secret not masked: {c} -> {red}"
436 );
437 assert!(
438 !red.contains(c.as_str()),
439 "raw secret survived: {c} -> {red}"
440 );
441 }
442 }
443
444 #[test]
445 fn env_and_cli_secrets_masked() {
446 let token = format!("ghp_{}", "abcdefghijklmnopqrstuvwx012345");
448 let red = redact_secrets(&format!(
449 "GITHUB_TOKEN={token} cargo build --token sekret123456"
450 ));
451 assert!(!red.contains(&token));
452 assert!(!red.contains("sekret123456"));
453 assert!(red.contains("GITHUB_TOKEN=***") || red.contains("GITHUB_TOKEN=[REDACTED]"));
454 assert!(red.contains("--token ***"));
455 }
456
457 #[test]
458 fn database_url_credentials_masked_host_preserved() {
459 let red = redact_secrets("postgres://admin:hunter2@db.internal:5432/app");
460 assert!(!red.contains("hunter2"));
461 assert!(!red.contains("admin:hunter2"));
462 assert!(red.contains("db.internal:5432/app"), "host lost: {red}");
464 assert!(red.contains("[REDACTED]@"), "creds not masked: {red}");
465
466 let red = redact_secrets("redis://:hunter2@cache.internal:6379/0");
467 assert!(!red.contains("hunter2"), "password-only URL leaked: {red}");
468 assert!(red.contains("cache.internal:6379/0"), "host lost: {red}");
469 }
470
471 #[test]
472 fn any_key_suffixed_assignment_masked() {
473 let red = redact_secrets("GROQ_KEY=gsk1234 SIGNING_KEY=abc cargo build");
474 assert!(!red.contains("gsk1234") && !red.contains("abc "), "{red}");
475 assert!(red.contains("GROQ_KEY=***") && red.contains("SIGNING_KEY=***"));
476 }
477
478 #[test]
479 fn bearer_and_jwt_masked() {
480 let jwt = format!(
482 "{}.{}.{}",
483 "eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "dozjgNryP4J3jVmNHl0w5N"
484 );
485 let red = redact_secrets(&format!("Authorization: Bearer {jwt}"));
486 assert!(!red.contains(&jwt), "token survived: {red}");
487 }
488
489 #[test]
490 fn pem_private_key_block_masked() {
491 let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA\nsecretkeymaterial\n-----END OPENSSH PRIVATE KEY-----";
492 let red = redact_secrets(pem);
493 assert!(!red.contains("secretkeymaterial"), "key survived: {red}");
494 assert!(red.contains(REDACTION_SENTINEL));
495 }
496
497 #[test]
498 fn home_paths_masked_in_text_and_single_field() {
499 let red = redact_secrets("loaded /home/alice/.ssh/id_rsa and /Users/bob/key.pem");
500 assert!(red.contains("/home/<redacted>/.ssh/id_rsa"), "{red}");
501 assert!(red.contains("/Users/<redacted>/key.pem"), "{red}");
502 assert_eq!(
504 redact_path("/home/carol/.local/state/rch/x.jsonl"),
505 "/home/<redacted>/.local/state/rch/x.jsonl"
506 );
507 assert_eq!(redact_path("/tmp/rch/x"), "/tmp/rch/x");
508 }
509
510 #[test]
511 fn no_false_positives_on_safe_text() {
512 let safe = "cargo build --release -p rch-common && echo done in /tmp/rch";
514 assert_eq!(redact_secrets(safe), safe);
515 }
516
517 #[test]
518 fn redaction_is_idempotent() {
519 let token = format!("ghp_{}", "abcdefghijklmnopqrstuvwx012345");
520 let once = redact_secrets(&format!("GITHUB_TOKEN={token}"));
521 let twice = redact_secrets(&once);
522 assert_eq!(once, twice);
523 }
524
525 #[test]
526 fn redacted_hash_is_stable_and_irreversible() {
527 let a = redacted_hash("super-secret-value");
528 let b = redacted_hash("super-secret-value");
529 assert_eq!(a, b);
530 assert!(a.starts_with("blake3:"));
531 assert!(!a.contains("super-secret"));
532 assert_ne!(a, redacted_hash("different-value"));
533 }
534
535 #[test]
536 fn schema_json_lists_policy_fields() {
537 let schema = RedactionPolicy::schema_json();
538 let props = schema
539 .get("properties")
540 .and_then(serde_json::Value::as_object)
541 .expect("schema has properties");
542 for f in ["schema_version", "sentinel", "audit_redactions", "rules"] {
543 assert!(props.contains_key(f), "schema missing field {f}");
544 }
545 }
546}