Files
chanora/poc/diagnostics-redaction-spike/tests/redaction.rs
T
EdisonJwa 06ec6f2965 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.
2026-05-14 12:26:49 +08:00

236 lines
8.5 KiB
Rust

//! 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");
}