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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user