feat(poc/diagnostics): add diagnostics-redaction spike

Proof-of-concept proving the diagnostics-redaction exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Password and identity-secret samples are redacted."

Full coverage of the audit-report test matrix in
docs/security/diagnostic-redaction-audit-report.md §4
(REDACT-TC-001..010), plus two sanity tests.

The spike ships:
  - RedactionPolicy: typed catalogue of regex rules
    (identity-base64-blob, password-kv, ts3server-url-password,
     authorization-bearer, linux/windows/macos user-path) with
    optional capture-group narrowing.
  - Structured-field redaction keyed on case-insensitive name
    substrings (password, secret, token, ...).
  - Bundle-level switches: chat and channel tree excluded by
    default per audit-report §5.
  - KnownSecretRegistry: literal-substring scrub for secrets the
    host application has already loaded into memory (defense in
    depth that regexes alone cannot guarantee — closes the gap
    behind REDACT-TC-002).
  - Length cap (MAX_PROTOCOL_STRING_LEN = 256) with truncation
    marker for REDACT-TC-009.
  - UTF-8 preserved in non-sensitive fields per REDACT-TC-010 /
    ADR-008.

Test suite (12/12 PASS on 2026-05-13):
  REDACT-TC-001 server password in connection data
  REDACT-TC-002 identity secret in storage error (via KnownSecretRegistry)
  REDACT-TC-003 server URL with password field
  REDACT-TC-004 chat text excluded by default
  REDACT-TC-005 channel name with Unicode excluded by default
  REDACT-TC-006 nickname with Unicode preserved in safe field
  REDACT-TC-007 local file path user segment minimized
  REDACT-TC-008 mixed sensitive bundle (whole-bundle JSON scan)
  REDACT-TC-009 long hostile protocol string truncated
  REDACT-TC-010 multilingual safe text preserved
  + known-secret literal scrub
  + empty registered secret ignored

Out of scope: tracing-subscriber integration, diagnostic export
file format, memory/core dumps, performance, adversarial regex
evasion beyond trivial cases. These belong to chanora_diagnostics.

Authority: PoC plan §2, docs/security/diagnostic-redaction-audit-report.md,
SRS-093, SysRS-152/154/155.
Not product code; not promoted into chanora_diagnostics.
This commit is contained in:
EdisonJwa
2026-05-14 12:26:49 +08:00
parent 52e8d43f69
commit 06ec6f2965
10 changed files with 1073 additions and 0 deletions
+190
View File
@@ -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"
@@ -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"
+79
View File
@@ -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.
@@ -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/<pkg>/` 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).
@@ -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<String>,
pub channel_tree: Vec<String>,
pub chat_history: Vec<String>,
pub log_lines: Vec<String>,
pub audio_device_names: Vec<String>,
/// 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<String, String>,
}
/// 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,
}
}
}
@@ -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]";
@@ -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
}
@@ -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<usize>,
}
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<RedactionRule>,
/// 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<Vec<RedactionRule>> = 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/<user>/... → 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\<user>\...
RedactionRule::new_group(
"windows-user-path",
"Windows user-profile path; minimizes the username segment.",
r"(?i)([A-Z]:\\Users\\)([^\\\s]+)",
2,
),
// macOS user: /Users/<user>/...
RedactionRule::new_group(
"macos-user-path",
"macOS user-profile path; minimizes the username segment.",
r"(/Users/)([^/\s]+)",
2,
),
]
});
@@ -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<RwLock<HashSet<String>>>,
}
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<String> {
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: &regex::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))
}
}
@@ -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");
}