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.
128 lines
4.0 KiB
Rust
128 lines
4.0 KiB
Rust
//! 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: ®ex::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))
|
|
}
|
|
}
|