Skip to main content

rch_common/
redaction.rs

1//! Shared secret-redaction and privacy policy for all remediation data
2//! (bd-session-history-remediation-ocv9i.16.8).
3//!
4//! The session-history remediation program adds many data surfaces — incident
5//! ledgers, proof intents, admission output, fleet status, config export,
6//! telemetry, artifact diagnostics, real-fleet smoke logs, and agent handoff
7//! JSON. Each must be useful for debugging **without** leaking SSH identities,
8//! tokens, absolute sensitive paths, environment secrets, credential-shaped
9//! command arguments, or private source contents.
10//!
11//! This module is the single library home for that policy. Before it, redaction
12//! primitives were scattered ([`crate::util::mask_sensitive_command`] for
13//! env/argv, a private path masker in `remediation_config`) and the policy
14//! vocabulary lived only inside a test. Here we provide:
15//!
16//! - [`redact_secrets`] — the comprehensive free-text redactor (env/argv key
17//!   masking + provider-shaped key detection + credential URLs + PEM blocks +
18//!   home/user path segments). Apply it at the **data layer**, before any
19//!   rendering, so the same redacted value flows identically through JSON, TOON,
20//!   plain, colored, and interactive output.
21//! - [`redact_path`] — masks the home/user segment of a single structured path
22//!   field (used by [`crate::remediation_config`]).
23//! - [`redacted_hash`] — a short, stable, irreversible blake3 reference for
24//!   values that must be correlated but never revealed.
25//! - [`RedactionPolicy`] — the versioned, serializable description of every
26//!   rule and the treatment ([`RedactionMode`]) it applies, so future agents
27//!   know what can be correlated safely.
28//!
29//! # Three information-boundary modes
30//!
31//! The policy distinguishes how each class of data is protected:
32//! - [`RedactionMode::Hashed`] — irreversible (blake3) fingerprint; correlatable
33//!   but not recoverable (e.g. proof source fingerprints).
34//! - [`RedactionMode::LocalReference`] — a stable local-only reference (e.g. an
35//!   env-var *name* instead of its value, or a worker id instead of a hostname).
36//! - [`RedactionMode::Omitted`] — the field is never recorded at all.
37//! - [`RedactionMode::Masked`] — the value is replaced in place by the
38//!   [`REDACTION_SENTINEL`] (or `***`), preserving surrounding structure for
39//!   debugging.
40
41use 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
52/// Sentinel used to replace a masked value.
53pub const REDACTION_SENTINEL: &str = "[REDACTED]";
54
55/// How a class of sensitive data is protected (the information boundary).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "snake_case")]
58pub enum RedactionMode {
59    /// Replaced by an irreversible blake3 fingerprint: correlatable, not
60    /// recoverable.
61    Hashed,
62    /// Replaced by a stable local-only reference (e.g. an env-var name or a
63    /// worker id) that does not reveal the secret itself.
64    LocalReference,
65    /// Never recorded at all.
66    Omitted,
67    /// Replaced in place by the sentinel / `***`, preserving structure.
68    Masked,
69}
70
71/// Categories of sensitive data the policy governs.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
73#[serde(rename_all = "snake_case")]
74pub enum SensitiveDataCategory {
75    /// `KEY=value` environment-variable secrets (tokens, passwords, keys).
76    EnvSecret,
77    /// Provider-shaped API keys (AWS, GitHub, OpenAI, Google, Slack, Stripe).
78    ApiKey,
79    /// `Authorization: Bearer …` tokens and JWTs.
80    BearerToken,
81    /// Passwords/secrets passed as CLI flags.
82    Password,
83    /// SSH/TLS private keys (PEM blocks) and key files.
84    SshKey,
85    /// Credentials embedded in connection URLs (`scheme://user:pass@host`).
86    DatabaseUrl,
87    /// Cloud-provider credential material.
88    CloudCredential,
89    /// Filesystem paths that reveal a home directory / username.
90    FilesystemPath,
91    /// Worker hostnames / usernames.
92    Hostname,
93    /// Raw source file contents.
94    SourceContent,
95    /// Personally identifying information.
96    Pii,
97    /// Project-specific / custom secrets.
98    Custom,
99}
100
101/// A named rule describing the treatment of one category of sensitive data.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct RedactionRule {
104    /// Stable rule identifier for audit trails (`RR-001`…).
105    pub id: String,
106    /// Category of sensitive data the rule targets.
107    pub category: SensitiveDataCategory,
108    /// How matched data is protected.
109    pub mode: RedactionMode,
110    /// Human-readable explanation of the rule.
111    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/// The versioned, serializable secret-redaction / privacy policy.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132pub struct RedactionPolicy {
133    /// Schema version (`SchemaComponent::RedactionPolicy`).
134    pub schema_version: String,
135    /// Sentinel string substituted for masked values.
136    pub sentinel: String,
137    /// Whether redaction events should be recorded for audit.
138    pub audit_redactions: bool,
139    /// The active rule set, in audit-id order.
140    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    /// Apply the masking rules to free text. Equivalent to [`redact_secrets`];
225    /// provided as a method so callers that hold a policy can express intent.
226    #[must_use]
227    pub fn redact(&self, text: &str) -> String {
228        redact_secrets(text)
229    }
230
231    /// The machine-readable JSON Schema for the policy.
232    #[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    /// Every category the default policy governs (for coverage checks).
243    #[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
251/// One compiled value-shaped pattern and its replacement.
252struct ShapedPattern {
253    re: Regex,
254    /// Replacement template; `${1}` style group refs are honored by `replace_all`.
255    replacement: &'static str,
256}
257
258/// Lazily-compiled redaction engine: value-shaped secret patterns plus the
259/// credential-URL and home-path maskers. Compiled once; redaction is not on the
260/// hook hot path (it guards logging/export surfaces).
261static 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        // PEM private-key block (multi-line). Match the whole block.
268        p(
269            r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
270            REDACTION_SENTINEL,
271        ),
272        // AWS access key id.
273        p(r"AKIA[0-9A-Z]{16}", REDACTION_SENTINEL),
274        // GitHub personal/OAuth/app tokens.
275        p(r"gh[pousr]_[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
276        // GitHub fine-grained personal access tokens.
277        p(r"github_pat_[A-Za-z0-9_]{22,}", REDACTION_SENTINEL),
278        // Google API key.
279        p(r"AIza[0-9A-Za-z_\-]{35}", REDACTION_SENTINEL),
280        // Slack tokens.
281        p(r"xox[baprs]-[0-9A-Za-z\-]{10,}", REDACTION_SENTINEL),
282        // Stripe live/test secret + restricted keys.
283        p(r"[sr]k_(live|test)_[A-Za-z0-9]{16,}", REDACTION_SENTINEL),
284        // Anthropic keys (more specific than the generic sk- rule; run first).
285        p(r"sk-ant-[A-Za-z0-9_\-]{16,}", REDACTION_SENTINEL),
286        // OpenAI project/service-account/admin keys carry a hyphenated prefix
287        // and `_`/`-` in the body, which the plain sk- rule cannot span.
288        p(
289            r"sk-(proj|svcacct|admin)-[A-Za-z0-9_\-]{20,}",
290            REDACTION_SENTINEL,
291        ),
292        // OpenAI-style secret keys.
293        p(r"sk-[A-Za-z0-9]{20,}", REDACTION_SENTINEL),
294        // JWTs (header.payload.signature, base64url).
295        p(
296            r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
297            REDACTION_SENTINEL,
298        ),
299        // Authorization: Bearer <token>.
300        p(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}", "Bearer [REDACTED]"),
301        // Credentials embedded in a connection URL: scheme://user:pass@host,
302        // including the password-only form (redis://:pass@host).
303        p(
304            r"([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s:]*:[^/@\s]+@",
305            "${1}[REDACTED]@",
306        ),
307        // Home/user path segments anywhere in the text.
308        p(r#"(/home/|/Users/)[^/\s:"']+"#, "${1}<redacted>"),
309    ]
310});
311
312/// Comprehensively redact secrets from free text (logs, captured output,
313/// diagnostics, JSONL artifacts).
314///
315/// Applies, in order: env/argv key masking ([`mask_sensitive_command`]), then
316/// provider-shaped key detection, credential URLs, PEM blocks, and home/user
317/// path segments. Safe (idempotent-ish) to apply more than once. Errs toward
318/// over-masking value-shaped secrets while leaving ordinary text intact.
319#[must_use]
320pub fn redact_secrets(text: &str) -> String {
321    // First mask `KEY=value` and `--flag value` forms (handles secrets that are
322    // not value-shaped on their own, e.g. a short password).
323    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/// Mask the home/user segment of a single structured path field so it is safe
331/// to print. Pure and environment-independent for stable golden output:
332/// `/home/<user>/x` → `/home/<redacted>/x`, `/Users/<user>/x` →
333/// `/Users/<redacted>/x`; a leading `~` is preserved.
334#[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/// A short, stable, irreversible reference for a value that must be correlated
350/// but never revealed (the [`RedactionMode::Hashed`] treatment).
351///
352/// Returns `blake3:<first 16 hex chars>`. Deterministic for a given input.
353#[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        // Every rule has a non-empty id + description.
373        for r in &p.rules {
374            assert!(!r.id.is_empty());
375            assert!(!r.description.is_empty());
376        }
377        // Core categories the bead enumerates are all governed.
378        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        // Criterion: distinguishes irreversible hashing, reversible local-only
397        // references, and (omitted/)masked treatment.
398        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        // One representative of each category the bead names. Each fixture is
416        // assembled at runtime from split parts so no contiguous secret literal
417        // ever appears in source — that keeps GitHub secret-scanning push
418        // protection from flagging our own test data. The redactor still sees
419        // the fully-assembled secret.
420        let cases = [
421            format!("AKIA{}", "IOSFODNN7EXAMPLE"), // AWS
422            format!("ghp_{}", "1234567890abcdefghijklmnopqrstuvwxyz"), // GitHub
423            format!("sk-{}", "abcdefghijklmnopqrstuvwxyz0123456789"), // OpenAI
424            format!("sk-ant-{}", "api03abcdefghijklmnopqrstuvwxyz"), // Anthropic
425            format!("AIza{}", "SyA1234567890abcdefghijklmnopqrstuvw"), // Google
426            format!("xox{}-{}-{}", "b", "123456789012", "abcdefghijklmnop"), // Slack
427            format!("sk_{}_{}", "live", "abcdefghijklmnop12345678"), // Stripe
428            format!("sk-proj-{}", "Ab3_cd-EfGhIjKlMnOpQrStUvWx"), // OpenAI project
429            format!("github_pat_{}", "11ABCDEFG0123456789_abcdefghijklmnop"), // GitHub fine-grained
430        ];
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        // Token assembled from parts (see representative_secrets_are_all_masked).
447        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        // The host/db are preserved for debugging.
463        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        // JWT assembled from its three parts so no contiguous token is in source.
481        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        // Single-field helper.
503        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        // Ordinary build output must be left intact.
513        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}