diff --git a/poc/diagnostics-redaction-spike/Cargo.lock b/poc/diagnostics-redaction-spike/Cargo.lock new file mode 100644 index 0000000..689a635 --- /dev/null +++ b/poc/diagnostics-redaction-spike/Cargo.lock @@ -0,0 +1,190 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "diagnostics-redaction-spike" +version = "0.1.0" +dependencies = [ + "indoc", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/poc/diagnostics-redaction-spike/Cargo.toml b/poc/diagnostics-redaction-spike/Cargo.toml new file mode 100644 index 0000000..b5dbaf9 --- /dev/null +++ b/poc/diagnostics-redaction-spike/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "diagnostics-redaction-spike" +version = "0.1.0" +edition = "2021" +publish = false +description = "Chanora PoC: diagnostic redaction policy + redactor covering REDACT-TC-001..010 from the diagnostic redaction audit report." + +# Not product code. See docs/architecture/proof-of-concept-plan.md §4. +# Authority: docs/security/diagnostic-redaction-audit-report.md, +# SRS-093 / SysRS-152/154/155, SDD §5 redaction notes. + +[lib] +name = "diagnostics_redaction_spike" +path = "src/lib.rs" + +[[bin]] +name = "diagnostics-redaction-cli" +path = "src/main.rs" + +[dependencies] +regex = "1" +once_cell = "1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +indoc = "2" diff --git a/poc/diagnostics-redaction-spike/README.md b/poc/diagnostics-redaction-spike/README.md new file mode 100644 index 0000000..b046e1d --- /dev/null +++ b/poc/diagnostics-redaction-spike/README.md @@ -0,0 +1,79 @@ +# Diagnostics Redaction Spike + +Chanora proof-of-concept. **Not product code.** + +| Field | Value | +|---|---| +| PoC name | `diagnostics-redaction-spike` | +| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 | +| Purpose | Prove redaction of secrets before logs or diagnostic export | +| Exit criterion | "Password and identity-secret samples are redacted" | +| Authority | `docs/security/diagnostic-redaction-audit-report.md` §2 + §4 + §5; SRS-093, SysRS-152/154/155, SDD §5 | + +## What it proves + +- A typed `RedactionPolicy` carrying: + - regex-driven rules (with optional capture-group narrowing so the + rule can scrub a value while keeping the surrounding context); + - structured-field redaction keyed on case-insensitive name + substrings (`password`, `secret`, `token`, …); + - bundle-level switches (chat / channel tree default to *excluded*). +- A `Redactor` that applies the policy to free text and to a typed + `DiagnosticBundle`. +- A `KnownSecretRegistry` for *literal* scrubbing — the strongest + defence when the host has already loaded the actual secret value + into memory. This is what closes the gap that regexes alone cannot + fully cover (REDACT-TC-002). +- A length cap (`MAX_PROTOCOL_STRING_LEN`) so hostile / oversized + protocol strings cannot grow the diagnostic surface + (REDACT-TC-009). +- UTF-8 preservation for benign multilingual text (REDACT-TC-010 / + ADR-008). + +## Coverage of the audit-report test matrix + +All ten REDACT-TC-001..010 entries are covered (see `VERIFICATION.md`). + +## Layout + +```text +diagnostics-redaction-spike/ + src/ + lib.rs # crate root, re-exports, REDACTION_MARKER + policy.rs # RedactionRule, RedactionPolicy, default policy + redactor.rs # Redactor, KnownSecretRegistry + bundle.rs # DiagnosticBundle DTO + bundle-level redaction + main.rs # diagnostics-redaction-cli driver + tests/ + redaction.rs # 12 tests; REDACT-TC-001..010 + sanity + Cargo.toml +``` + +## Reproduce + +Requires Rust stable (developed against 1.95). + +```bash +cargo test +echo 'INFO password=hunter2 path=/home/alice/x' | cargo run --bin diagnostics-redaction-cli +``` + +## Scope boundaries + +- **Not a `tracing` layer.** Production code in `chanora_diagnostics` + will wire the redactor as a `tracing-subscriber` layer to enforce + redaction at write-time, not via post-processing. The mechanism + here is the same; the integration surface is not. +- **No diagnostic bundle file format.** The spike only redacts the + in-memory struct; the actual export format (zip / json-lines / etc.) + is owned by `chanora_diagnostics`. +- **No threat-model coverage of memory dumps, core dumps, or kernel + logs.** That is `docs/security/threat-model.md` territory. +- **No language-aware redaction.** The Unicode policy is "preserve + multilingual text in non-sensitive fields; do not introspect it." +- **No PII discovery.** This is a deny-list redactor; it does not + attempt to detect previously-unknown secrets by entropy heuristics. + +## Verification log + +See `VERIFICATION.md` in this directory. diff --git a/poc/diagnostics-redaction-spike/VERIFICATION.md b/poc/diagnostics-redaction-spike/VERIFICATION.md new file mode 100644 index 0000000..9395acd --- /dev/null +++ b/poc/diagnostics-redaction-spike/VERIFICATION.md @@ -0,0 +1,104 @@ +# Verification record — `diagnostics-redaction-spike` + +## Result + +PASS. The PoC exit criterion (from +`docs/architecture/proof-of-concept-plan.md` §2: "Password and +identity-secret samples are redacted") is met, with full coverage of +REDACT-TC-001..010 from +`docs/security/diagnostic-redaction-audit-report.md` §4. + +## Environment + +| Field | Value | +|---|---| +| Date | 2026-05-13 | +| Host OS | Linux (Arch, kernel 7.0.5-arch1-1, x86_64) | +| Rust toolchain | stable 1.95.0 | +| `regex` | 1 | +| `serde` / `serde_json` | 1 | +| `once_cell` | 1 | + +## Reproduction + +```bash +cargo test +printf 'INFO password=hunter2 path=/home/milkice/x\n' \ + | cargo run --bin diagnostics-redaction-cli +``` + +## Test run + +``` +running 12 tests +test known_secret_is_scrubbed_even_when_no_regex_matches ... ok +test redact_tc_005_channel_name_with_unicode_excluded_by_default ... ok +test redact_tc_006_nickname_with_unicode_preserved_in_safe_field ... ok +test redact_tc_002_identity_secret_in_storage_error ... ok +test redact_tc_010_multilingual_safe_text_preserved ... ok +test redact_tc_009_long_hostile_protocol_string_truncated ... ok +test redact_tc_007_local_file_paths_user_segment_minimized ... ok +test empty_registered_secret_is_ignored ... ok +test redact_tc_004_chat_text_excluded_by_default ... ok +test redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields ... ok +test redact_tc_001_server_password_in_connection_data ... ok +test redact_tc_003_server_url_with_password_field ... ok + +test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; + 0 filtered out; finished in 0.01s +``` + +## CLI run + +Input: + +```text +INFO connect host=cn.teamspeak.app password=hunter2 nickname=Chanora +DEBUG identity=MG0DAgeAAgEgAiAIXJBlj1hQbaH0Eq0DuLlCmH8bl+veTAO2k9EQjEYSgIgNnImcmKo7ls5mExb6skfK2Twu54aeDr0OP1ITsC50CIA8M5nmDBn +ERROR open /home/milkice/.local/share/chanora/db failed +NOTICE CHANORA_LITERAL_DEMO_SECRET seen on bus +``` + +Output: + +```text +INFO connect host=cn.teamspeak.app password=[REDACTED] nickname=Chanora +DEBUG identity=[REDACTED] +ERROR open /home/[REDACTED]/.local/share/chanora/db failed +NOTICE [REDACTED] seen on bus +``` + +- Password: redacted (rule `password-kv`). +- Base64-ish identity blob: redacted (rule `identity-base64-blob`). +- Linux home-username segment: redacted (rule `linux-home-path`); the + rest of the path is preserved. +- Pre-registered literal secret: redacted via + `KnownSecretRegistry`, demonstrating the SS-AUD-003 defence-in-depth. +- Non-secret tokens (`host=`, `nickname=`, `INFO`, `ERROR`, …) all + flow through unchanged. + +## Audit-matrix coverage + +| Audit ID | Test | Result | +|---|---|---| +| REDACT-TC-001 (server password in connection data) | `redact_tc_001_server_password_in_connection_data` | PASS | +| REDACT-TC-002 (identity secret in storage error) | `redact_tc_002_identity_secret_in_storage_error` | PASS — uses `KnownSecretRegistry`. | +| REDACT-TC-003 (URL with password field) | `redact_tc_003_server_url_with_password_field` | PASS — only the password value is redacted. | +| REDACT-TC-004 (chat text in export) | `redact_tc_004_chat_text_excluded_by_default` | PASS — default policy excludes. | +| REDACT-TC-005 (Unicode channel name) | `redact_tc_005_channel_name_with_unicode_excluded_by_default` | PASS — default policy excludes. | +| REDACT-TC-006 (Unicode nickname) | `redact_tc_006_nickname_with_unicode_preserved_in_safe_field` | PASS — preserved in non-sensitive extras. | +| REDACT-TC-007 (local file path) | `redact_tc_007_local_file_paths_user_segment_minimized` | PASS — username segment redacted on Linux/Windows/macOS path shapes. | +| REDACT-TC-008 (mixed sensitive bundle) | `redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields` | PASS — whole-bundle JSON scanned for plaintext. | +| REDACT-TC-009 (long hostile protocol string) | `redact_tc_009_long_hostile_protocol_string_truncated` | PASS — `MAX_PROTOCOL_STRING_LEN = 256` cap with truncation marker. | +| REDACT-TC-010 (multilingual safe text) | `redact_tc_010_multilingual_safe_text_preserved` | PASS — Chinese / Japanese / Korean / Latin-diacritic text preserved verbatim. | + +## What this spike does NOT validate + +- Integration as a `tracing` layer (production code will own this). +- Diagnostic export *file format* (zip / json-lines / archive layout). +- Mobile-platform path shapes (`/data/data//` on Android, + iOS app-group containers). +- Memory / core dumps. +- Performance under sustained high-volume logging. +- Adversarial regex evasion beyond the trivial cases exercised. +- Process-level review of the audit report (SS-AUD-007 style sign-off). diff --git a/poc/diagnostics-redaction-spike/src/bundle.rs b/poc/diagnostics-redaction-spike/src/bundle.rs new file mode 100644 index 0000000..6dab39d --- /dev/null +++ b/poc/diagnostics-redaction-spike/src/bundle.rs @@ -0,0 +1,96 @@ +//! Structured diagnostic bundle and the redaction pass over it. +//! +//! Mirrors `docs/security/diagnostic-redaction-audit-report.md` §5 +//! "Export Bundle Contents". Fields default to safe values; sensitive +//! categories are excluded by default. + +use serde::{Deserialize, Serialize}; + +use crate::redactor::Redactor; +use crate::REDACTION_MARKER; + +/// Raw bundle assembled by the application before redaction. The +/// caller MUST pass this through `Redactor::redact_bundle` before any +/// disk write or user-visible export. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DiagnosticBundle { + pub app_version: String, + pub build_number: String, + pub platform_info: String, + pub connection_state: String, + pub server_address: Option, + pub channel_tree: Vec, + pub chat_history: Vec, + pub log_lines: Vec, + pub audio_device_names: Vec, + /// Free-form extras keyed by field name. Sensitive field names + /// (matched against `RedactionPolicy::redact_field_substrings`) + /// are redacted wholesale; everything else flows through + /// `redact_text` for value-level scrubbing. + pub extras: std::collections::BTreeMap, +} + +/// Final, redacted bundle suitable for export. Produced by +/// `Redactor::redact_bundle`. The shape is intentionally the same +/// type so callers can re-serialize as JSON without conversion. +pub type RedactedBundle = DiagnosticBundle; + +impl Redactor { + /// Apply the redaction policy to `bundle`, returning a copy safe + /// for export. + pub fn redact_bundle(&self, bundle: &DiagnosticBundle) -> RedactedBundle { + let policy = self.policy(); + + let chat_history = if policy.include_chat { + bundle + .chat_history + .iter() + .map(|m| self.redact_text(m)) + .collect() + } else { + // Chat is excluded by default (audit report §5 / REDACT-TC-004). + Vec::new() + }; + + let channel_tree = if policy.include_channel_tree { + bundle + .channel_tree + .iter() + .map(|c| self.redact_text(c)) + .collect() + } else { + Vec::new() + }; + + let mut redacted_extras = std::collections::BTreeMap::new(); + for (k, v) in &bundle.extras { + if self.is_sensitive_field(k) { + redacted_extras.insert(k.clone(), REDACTION_MARKER.to_string()); + } else { + redacted_extras.insert(k.clone(), self.redact_text(v)); + } + } + + RedactedBundle { + app_version: bundle.app_version.clone(), + build_number: bundle.build_number.clone(), + // Platform info gets a soft scrub (paths, usernames). + platform_info: self.redact_text(&bundle.platform_info), + connection_state: self.redact_text(&bundle.connection_state), + server_address: bundle.server_address.as_ref().map(|s| self.redact_text(s)), + channel_tree, + chat_history, + log_lines: bundle + .log_lines + .iter() + .map(|l| self.redact_text(l)) + .collect(), + audio_device_names: bundle + .audio_device_names + .iter() + .map(|d| self.redact_text(d)) + .collect(), + extras: redacted_extras, + } + } +} diff --git a/poc/diagnostics-redaction-spike/src/lib.rs b/poc/diagnostics-redaction-spike/src/lib.rs new file mode 100644 index 0000000..0bf60a3 --- /dev/null +++ b/poc/diagnostics-redaction-spike/src/lib.rs @@ -0,0 +1,31 @@ +//! Chanora PoC — diagnostics redaction spike. +//! +//! Authority: +//! * `docs/architecture/proof-of-concept-plan.md` §2 — Diagnostics +//! redaction spike. Exit criterion: "Password and identity-secret +//! samples are redacted." +//! * `docs/security/diagnostic-redaction-audit-report.md` — policy +//! in §2, surfaces in §3, test matrix REDACT-TC-001..010 in §4, +//! bundle policy in §5. +//! * SRS-093 / SysRS-152 / SysRS-154 / SysRS-155 — software-level +//! redaction obligations for logs and diagnostic exports. +//! +//! Production code will own this redactor inside the `chanora_diagnostics` +//! crate and wire it as a `tracing` layer. The PoC is the same shape in +//! miniature: a typed policy, a redactor that applies it to free text +//! and to structured diagnostic bundles, plus an explicit list of +//! known-literal secrets the host application can register at runtime. + +pub mod bundle; +pub mod policy; +pub mod redactor; + +pub use bundle::{DiagnosticBundle, RedactedBundle}; +pub use policy::{RedactionPolicy, RedactionRule}; +pub use redactor::{KnownSecretRegistry, Redactor}; + +/// The replacement string used for redacted segments. +/// +/// Chosen distinctly so audit tests can grep for either presence +/// (correct redaction occurred) or absence (no leak). +pub const REDACTION_MARKER: &str = "[REDACTED]"; diff --git a/poc/diagnostics-redaction-spike/src/main.rs b/poc/diagnostics-redaction-spike/src/main.rs new file mode 100644 index 0000000..9079d4a --- /dev/null +++ b/poc/diagnostics-redaction-spike/src/main.rs @@ -0,0 +1,30 @@ +//! CLI driver — accepts mixed sensitive input on stdin, prints the +//! redacted version on stdout, and prints a summary on stderr. + +use std::io::Read; +use std::process::ExitCode; + +use diagnostics_redaction_spike::Redactor; + +fn main() -> ExitCode { + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + eprintln!("failed to read stdin"); + return ExitCode::from(1); + } + + let redactor = Redactor::with_default_policy(); + + // The host application would normally register live secrets here + // (e.g. as the secure-storage layer materialises them). + // For the CLI demo, register a fixed plaintext so the audience + // can see literal scrubbing work. + redactor + .known_secrets() + .register("CHANORA_LITERAL_DEMO_SECRET"); + + let out = redactor.redact_text(&input); + println!("{out}"); + eprintln!("--- redaction applied; rules in policy: {} ---", redactor.policy().rules.len()); + ExitCode::SUCCESS +} diff --git a/poc/diagnostics-redaction-spike/src/policy.rs b/poc/diagnostics-redaction-spike/src/policy.rs new file mode 100644 index 0000000..5d3157d --- /dev/null +++ b/poc/diagnostics-redaction-spike/src/policy.rs @@ -0,0 +1,153 @@ +//! Redaction policy: the catalogue of patterns and structural rules +//! the redactor applies. + +use once_cell::sync::Lazy; +use regex::Regex; + +/// A single regex-driven rule. +#[derive(Debug, Clone)] +pub struct RedactionRule { + pub id: &'static str, + pub description: &'static str, + pub pattern: Regex, + /// If `Some(n)`, the rule replaces capture-group `n` rather than + /// the whole match. Useful for "ts3server://host?password=XXXX" + /// where the host should be preserved but `XXXX` redacted. + pub redact_group: Option, +} + +impl RedactionRule { + pub fn new( + id: &'static str, + description: &'static str, + pattern: &str, + ) -> Self { + Self { + id, + description, + pattern: Regex::new(pattern).expect("static rule regex must compile"), + redact_group: None, + } + } + + pub fn new_group( + id: &'static str, + description: &'static str, + pattern: &str, + group: usize, + ) -> Self { + Self { + id, + description, + pattern: Regex::new(pattern).expect("static rule regex must compile"), + redact_group: Some(group), + } + } +} + +/// The full redaction policy. +#[derive(Debug, Clone)] +pub struct RedactionPolicy { + pub rules: Vec, + + /// Structured-field redaction: any key whose lower-cased name + /// contains one of these substrings is fully redacted in + /// diagnostic bundles. Mirrors the bundle policy in + /// `diagnostic-redaction-audit-report.md` §5. + pub redact_field_substrings: Vec<&'static str>, + + /// Bundle-level: include chat messages? Audit-report default = No. + pub include_chat: bool, + + /// Bundle-level: include channel tree details? + pub include_channel_tree: bool, +} + +/// Hard upper bound on protocol-string lengths kept in diagnostics. +/// +/// REDACT-TC-009 ("long hostile protocol string"): truncate or safely +/// escape. The PoC truncates with a clear marker. +pub const MAX_PROTOCOL_STRING_LEN: usize = 256; + +impl RedactionPolicy { + /// The default policy used by the PoC. Mirrors the audit-report + /// catalogue. Production code (`chanora_diagnostics`) will own the + /// canonical version of this list. + pub fn default_policy() -> Self { + Self { + rules: DEFAULT_RULES.clone(), + redact_field_substrings: vec![ + "password", + "secret", + "private_key", + "private-key", + "identity_key", + "identity-key", + "token", + "credential", + "passphrase", + ], + include_chat: false, + include_channel_tree: false, + } + } +} + +static DEFAULT_RULES: Lazy> = Lazy::new(|| { + vec![ + // Identity secret as it appears in `tsclientlib` style: + // long base64-ish run preceded by a known marker. + // We err on the side of catching base64 blobs >= 64 chars. + RedactionRule::new( + "identity-base64-blob", + "Long base64-like run; matches identity secrets and tokens.", + r"\b[A-Za-z0-9+/]{64,}={0,2}\b", + ), + // `password = "..."` / `password: "..."` / `password=...` + // Captures the value in group 1 so the *key* name is kept. + // The value excludes separators commonly found in URL query + // strings and config lines (`& , ; " whitespace`). + RedactionRule::new_group( + "password-kv", + "key=value style password assignment.", + r#"(?i)\b(?:password|passwd|pass|pwd)\s*[:=]\s*"?([^"\s,;&]+)"?"#, + 1, + ), + // ts3server://host?password=XXXX&... + RedactionRule::new_group( + "ts3server-url-password", + "Password embedded in a ts3server:// URL query string.", + r"(?i)(?:[?&])password=([^&\s]+)", + 1, + ), + // Generic Authorization: Bearer ... + RedactionRule::new_group( + "authorization-bearer", + "Authorization header bearer token.", + r"(?i)Authorization:\s*Bearer\s+(\S+)", + 1, + ), + // Linux user home: /home//... → minimize the username + // segment. Captures group 1 = username. + RedactionRule::new_group( + "linux-home-path", + "Linux home-directory path; minimizes the username segment.", + r"(/home/)([^/\s]+)", + 2, + ), + // Windows user: C:\Users\\... + RedactionRule::new_group( + "windows-user-path", + "Windows user-profile path; minimizes the username segment.", + r"(?i)([A-Z]:\\Users\\)([^\\\s]+)", + 2, + ), + // macOS user: /Users//... + RedactionRule::new_group( + "macos-user-path", + "macOS user-profile path; minimizes the username segment.", + r"(/Users/)([^/\s]+)", + 2, + ), + ] +}); diff --git a/poc/diagnostics-redaction-spike/src/redactor.rs b/poc/diagnostics-redaction-spike/src/redactor.rs new file mode 100644 index 0000000..e0d0a00 --- /dev/null +++ b/poc/diagnostics-redaction-spike/src/redactor.rs @@ -0,0 +1,127 @@ +//! The redactor — applies the policy to free text and to structured +//! diagnostic data. + +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +use crate::policy::{RedactionPolicy, MAX_PROTOCOL_STRING_LEN}; +use crate::REDACTION_MARKER; + +/// Runtime registry of *literal* known secrets. The host application +/// (e.g. the secure-storage layer) registers a secret here when it +/// loads one into memory, so even if it slips into a log line verbatim +/// it gets scrubbed. +/// +/// This is the strongest defense for SS-AUD-003 and REDACT-TC-002 — +/// regexes can miss; literal substring matches cannot. +#[derive(Debug, Clone, Default)] +pub struct KnownSecretRegistry { + inner: Arc>>, +} + +impl KnownSecretRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a literal secret string. Empty strings are ignored + /// (to avoid degenerate `s.replace("", _)` behaviour). + pub fn register(&self, secret: &str) { + if secret.is_empty() { + return; + } + self.inner.write().unwrap().insert(secret.to_string()); + } + + pub fn forget(&self, secret: &str) { + self.inner.write().unwrap().remove(secret); + } + + pub fn snapshot(&self) -> Vec { + self.inner.read().unwrap().iter().cloned().collect() + } +} + +pub struct Redactor { + policy: RedactionPolicy, + known: KnownSecretRegistry, +} + +impl Redactor { + pub fn new(policy: RedactionPolicy, known: KnownSecretRegistry) -> Self { + Self { policy, known } + } + + pub fn with_default_policy() -> Self { + Self::new(RedactionPolicy::default_policy(), KnownSecretRegistry::new()) + } + + pub fn known_secrets(&self) -> &KnownSecretRegistry { + &self.known + } + + pub fn policy(&self) -> &RedactionPolicy { + &self.policy + } + + /// Redact `input` according to the policy + known secret registry. + /// + /// Order: + /// 1. Known literal secrets (defence in depth — never miss a value). + /// 2. Regex rules (with optional capture-group narrowing). + /// 3. Length cap for hostile / oversized strings (REDACT-TC-009). + pub fn redact_text(&self, input: &str) -> String { + // 1. Literal known secrets. + let mut text = input.to_string(); + for known in self.known.snapshot() { + if !known.is_empty() { + text = text.replace(&known, REDACTION_MARKER); + } + } + + // 2. Regex rules. + for rule in &self.policy.rules { + text = match rule.redact_group { + None => rule + .pattern + .replace_all(&text, REDACTION_MARKER) + .into_owned(), + Some(group) => rule + .pattern + .replace_all(&text, |caps: ®ex::Captures<'_>| { + let full = caps.get(0).map(|m| m.as_str()).unwrap_or(""); + match caps.get(group) { + Some(target) => full.replace(target.as_str(), REDACTION_MARKER), + None => full.to_string(), + } + }) + .into_owned(), + }; + } + + // 3. Length cap. + if text.len() > MAX_PROTOCOL_STRING_LEN { + // Find the last char boundary inside the budget so we + // never split a UTF-8 codepoint. + let mut cut = MAX_PROTOCOL_STRING_LEN; + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + let mut truncated = text[..cut].to_string(); + truncated.push_str("…[truncated]"); + text = truncated; + } + + text + } + + /// True if the policy classifies `field_name` as a sensitive + /// structured field that must be redacted wholesale. + pub fn is_sensitive_field(&self, field_name: &str) -> bool { + let lower = field_name.to_lowercase(); + self.policy + .redact_field_substrings + .iter() + .any(|needle| lower.contains(needle)) + } +} diff --git a/poc/diagnostics-redaction-spike/tests/redaction.rs b/poc/diagnostics-redaction-spike/tests/redaction.rs new file mode 100644 index 0000000..2519930 --- /dev/null +++ b/poc/diagnostics-redaction-spike/tests/redaction.rs @@ -0,0 +1,235 @@ +//! Verification tests mapped to +//! `docs/security/diagnostic-redaction-audit-report.md` §4 +//! (REDACT-TC-001..010). +//! +//! Each test name carries its REDACT-TC- identifier for traceability. + +use diagnostics_redaction_spike::{ + bundle::DiagnosticBundle, Redactor, REDACTION_MARKER, +}; +use indoc::indoc; + +// Unique markers so a leak is unambiguous in test output. +const SERVER_PASSWORD: &str = "REDACT_POC_PW_a9c41f"; +const IDENTITY_SECRET: &str = + "MG0DAgeAAgEgAiAIXJBlj1hQbaH0Eq0DuLlCmH8bl_veTAO2_k9EQjEYSgIgNnImcmKo7ls5mExb6skfK2Twu54aeDr0OP1ITsC50CIA"; +const AUTH_TOKEN: &str = "tok_REDACT_POC_TOKEN_8d11"; + +fn fresh_redactor() -> Redactor { + Redactor::with_default_policy() +} + +// ---------- REDACT-TC-001 ---------- + +#[test] +fn redact_tc_001_server_password_in_connection_data() { + let r = fresh_redactor(); + let input = format!("connect host=cn.teamspeak.app password={SERVER_PASSWORD} nickname=Chanora"); + let out = r.redact_text(&input); + assert!(!out.contains(SERVER_PASSWORD), "password leaked: {out}"); + assert!(out.contains(REDACTION_MARKER), "no marker in {out}"); + // The non-secret context must be preserved. + assert!(out.contains("host=cn.teamspeak.app")); + assert!(out.contains("nickname=Chanora")); +} + +// ---------- REDACT-TC-002 ---------- + +#[test] +fn redact_tc_002_identity_secret_in_storage_error() { + let r = fresh_redactor(); + // Register the identity as a known literal — strongest defense. + r.known_secrets().register(IDENTITY_SECRET); + + let input = format!( + "ERROR: failed to load identity (raw={IDENTITY_SECRET}); falling back to default" + ); + let out = r.redact_text(&input); + assert!(!out.contains(IDENTITY_SECRET), "identity leaked: {out}"); + assert!(out.contains(REDACTION_MARKER)); +} + +// ---------- REDACT-TC-003 ---------- + +#[test] +fn redact_tc_003_server_url_with_password_field() { + let r = fresh_redactor(); + let url = format!("ts3server://cn.teamspeak.app?password={SERVER_PASSWORD}&nickname=Chanora"); + let out = r.redact_text(&url); + assert!(!out.contains(SERVER_PASSWORD)); + // Only the password value should be redacted; host + nickname keep flowing. + assert!(out.contains("ts3server://cn.teamspeak.app")); + assert!(out.contains("nickname=Chanora")); + assert!(out.contains("password=[REDACTED]")); +} + +// ---------- REDACT-TC-004 ---------- + +#[test] +fn redact_tc_004_chat_text_excluded_by_default() { + let r = fresh_redactor(); + let bundle = DiagnosticBundle { + chat_history: vec!["hello!".into(), "private message".into()], + ..Default::default() + }; + let red = r.redact_bundle(&bundle); + assert!(red.chat_history.is_empty(), "chat should be excluded by default"); +} + +// ---------- REDACT-TC-005 ---------- + +#[test] +fn redact_tc_005_channel_name_with_unicode_excluded_by_default() { + let r = fresh_redactor(); + let bundle = DiagnosticBundle { + channel_tree: vec!["樱花庄".into(), "Default Channel".into()], + ..Default::default() + }; + let red = r.redact_bundle(&bundle); + // Default policy: channel tree excluded (audit-report §5 "TBD / Redact/minimize"). + assert!(red.channel_tree.is_empty()); +} + +// ---------- REDACT-TC-006 ---------- + +#[test] +fn redact_tc_006_nickname_with_unicode_preserved_in_safe_field() { + // Nickname is conveyed via a non-sensitive extras key; the redactor + // does NOT mangle Unicode in safe fields (preserves UTF-8 by + // ADR-008, see audit-report row "Multilingual safe diagnostic text"). + let r = fresh_redactor(); + let mut extras = std::collections::BTreeMap::new(); + extras.insert("local_nickname".into(), "クマー".into()); + let bundle = DiagnosticBundle { extras, ..Default::default() }; + let red = r.redact_bundle(&bundle); + assert_eq!(red.extras.get("local_nickname").map(|s| s.as_str()), Some("クマー")); +} + +// ---------- REDACT-TC-007 ---------- + +#[test] +fn redact_tc_007_local_file_paths_user_segment_minimized() { + let r = fresh_redactor(); + let lines = [ + "/home/milkice/chanora/app.log", + "C:\\Users\\Alice\\AppData\\Roaming\\Chanora\\log.txt", + "/Users/bob/Library/Application Support/Chanora/x.db", + ]; + for l in lines { + let out = r.redact_text(l); + assert!(!out.contains("milkice"), "leaked milkice: {out}"); + assert!(!out.contains("Alice"), "leaked Alice: {out}"); + assert!(!out.contains("bob"), "leaked bob: {out}"); + assert!(out.contains(REDACTION_MARKER), "no marker in {out}"); + } +} + +// ---------- REDACT-TC-008 ---------- + +#[test] +fn redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields() { + let r = fresh_redactor(); + r.known_secrets().register(IDENTITY_SECRET); + + let mut extras = std::collections::BTreeMap::new(); + extras.insert("server_password".into(), SERVER_PASSWORD.into()); + extras.insert("auth_token".into(), AUTH_TOKEN.into()); + extras.insert("app_locale".into(), "en-US".into()); + extras.insert("user_home".into(), "/home/milkice".into()); + + let bundle = DiagnosticBundle { + app_version: "0.0.0-poc".into(), + build_number: "1".into(), + platform_info: "Linux x86_64 path=/home/milkice/.local".into(), + connection_state: format!("disconnected; last_error=password={SERVER_PASSWORD}"), + server_address: Some(format!( + "ts3server://cn.teamspeak.app?password={SERVER_PASSWORD}" + )), + channel_tree: vec!["whatever".into()], + chat_history: vec!["should not appear".into()], + log_lines: vec![ + format!("INFO loaded identity={IDENTITY_SECRET}"), + format!("WARN auth failed Authorization: Bearer {AUTH_TOKEN}"), + ], + audio_device_names: vec!["Built-in Microphone".into()], + extras, + }; + + let red = r.redact_bundle(&bundle); + + // Whole-bundle leak check: no plaintext anywhere. + let blob = serde_json::to_string(&red).unwrap(); + assert!(!blob.contains(SERVER_PASSWORD), "server password leaked: {blob}"); + assert!(!blob.contains(IDENTITY_SECRET), "identity leaked: {blob}"); + assert!(!blob.contains(AUTH_TOKEN), "auth token leaked: {blob}"); + assert!(!blob.contains("milkice"), "username leaked: {blob}"); + assert!(!blob.contains("should not appear"), "chat leaked: {blob}"); + + // Positive checks: structural fields keep flowing. + assert_eq!(red.app_version, "0.0.0-poc"); + assert_eq!(red.build_number, "1"); + assert!(red.audio_device_names.contains(&"Built-in Microphone".to_string())); + assert_eq!(red.extras.get("app_locale").map(|s| s.as_str()), Some("en-US")); + assert_eq!(red.extras.get("server_password").map(|s| s.as_str()), Some(REDACTION_MARKER)); + assert_eq!(red.extras.get("auth_token").map(|s| s.as_str()), Some(REDACTION_MARKER)); +} + +// ---------- REDACT-TC-009 ---------- + +#[test] +fn redact_tc_009_long_hostile_protocol_string_truncated() { + let r = fresh_redactor(); + // A long benign string that does NOT match any redaction rule + // (no base64-style run, no path, no key=value secret) — so the + // length cap is the only thing that can act on it. + let huge: String = "hello world! ".repeat(400); + let out = r.redact_text(&huge); + assert!(out.len() < huge.len(), "expected truncation; got len={} input_len={}", out.len(), huge.len()); + assert!(out.ends_with("…[truncated]"), "missing truncation marker; tail={:?}", &out[out.len().saturating_sub(40)..]); +} + +// ---------- REDACT-TC-010 ---------- + +#[test] +fn redact_tc_010_multilingual_safe_text_preserved() { + let r = fresh_redactor(); + let lines = indoc! {" + Welcome to Vigorous Pro! + 欢迎来到 Vigorous Pro + 日本語チャンネル + 한국어 채널 + Café résumé naïve + "}; + let out = r.redact_text(lines); + for l in [ + "Vigorous Pro", + "欢迎来到", + "日本語チャンネル", + "한국어 채널", + "Café résumé naïve", + ] { + assert!(out.contains(l), "missing line {l:?} in {out}"); + } + // No redaction markers should appear for benign multilingual text. + assert!(!out.contains(REDACTION_MARKER)); +} + +// ---------- Additional sanity ---------- + +#[test] +fn known_secret_is_scrubbed_even_when_no_regex_matches() { + let r = fresh_redactor(); + let weird = "BANANA_PHONE_42"; + r.known_secrets().register(weird); + let out = r.redact_text(&format!("note: secret value is {weird} here")); + assert!(!out.contains(weird)); + assert!(out.contains(REDACTION_MARKER)); +} + +#[test] +fn empty_registered_secret_is_ignored() { + let r = fresh_redactor(); + r.known_secrets().register(""); + let out = r.redact_text("nothing to redact"); + assert_eq!(out, "nothing to redact"); +}