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
@@ -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,
}
}
}