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.
236 lines
8.5 KiB
Rust
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");
|
|
}
|