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:
+190
@@ -0,0 +1,190 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diagnostics-redaction-spike"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"indoc",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "diagnostics-redaction-spike"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
description = "Chanora PoC: diagnostic redaction policy + redactor covering REDACT-TC-001..010 from the diagnostic redaction audit report."
|
||||
|
||||
# Not product code. See docs/architecture/proof-of-concept-plan.md §4.
|
||||
# Authority: docs/security/diagnostic-redaction-audit-report.md,
|
||||
# SRS-093 / SysRS-152/154/155, SDD §5 redaction notes.
|
||||
|
||||
[lib]
|
||||
name = "diagnostics_redaction_spike"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "diagnostics-redaction-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
indoc = "2"
|
||||
@@ -0,0 +1,79 @@
|
||||
# Diagnostics Redaction Spike
|
||||
|
||||
Chanora proof-of-concept. **Not product code.**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| PoC name | `diagnostics-redaction-spike` |
|
||||
| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 |
|
||||
| Purpose | Prove redaction of secrets before logs or diagnostic export |
|
||||
| Exit criterion | "Password and identity-secret samples are redacted" |
|
||||
| Authority | `docs/security/diagnostic-redaction-audit-report.md` §2 + §4 + §5; SRS-093, SysRS-152/154/155, SDD §5 |
|
||||
|
||||
## What it proves
|
||||
|
||||
- A typed `RedactionPolicy` carrying:
|
||||
- regex-driven rules (with optional capture-group narrowing so the
|
||||
rule can scrub a value while keeping the surrounding context);
|
||||
- structured-field redaction keyed on case-insensitive name
|
||||
substrings (`password`, `secret`, `token`, …);
|
||||
- bundle-level switches (chat / channel tree default to *excluded*).
|
||||
- A `Redactor` that applies the policy to free text and to a typed
|
||||
`DiagnosticBundle`.
|
||||
- A `KnownSecretRegistry` for *literal* scrubbing — the strongest
|
||||
defence when the host has already loaded the actual secret value
|
||||
into memory. This is what closes the gap that regexes alone cannot
|
||||
fully cover (REDACT-TC-002).
|
||||
- A length cap (`MAX_PROTOCOL_STRING_LEN`) so hostile / oversized
|
||||
protocol strings cannot grow the diagnostic surface
|
||||
(REDACT-TC-009).
|
||||
- UTF-8 preservation for benign multilingual text (REDACT-TC-010 /
|
||||
ADR-008).
|
||||
|
||||
## Coverage of the audit-report test matrix
|
||||
|
||||
All ten REDACT-TC-001..010 entries are covered (see `VERIFICATION.md`).
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
diagnostics-redaction-spike/
|
||||
src/
|
||||
lib.rs # crate root, re-exports, REDACTION_MARKER
|
||||
policy.rs # RedactionRule, RedactionPolicy, default policy
|
||||
redactor.rs # Redactor, KnownSecretRegistry
|
||||
bundle.rs # DiagnosticBundle DTO + bundle-level redaction
|
||||
main.rs # diagnostics-redaction-cli driver
|
||||
tests/
|
||||
redaction.rs # 12 tests; REDACT-TC-001..010 + sanity
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
## Reproduce
|
||||
|
||||
Requires Rust stable (developed against 1.95).
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
echo 'INFO password=hunter2 path=/home/alice/x' | cargo run --bin diagnostics-redaction-cli
|
||||
```
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- **Not a `tracing` layer.** Production code in `chanora_diagnostics`
|
||||
will wire the redactor as a `tracing-subscriber` layer to enforce
|
||||
redaction at write-time, not via post-processing. The mechanism
|
||||
here is the same; the integration surface is not.
|
||||
- **No diagnostic bundle file format.** The spike only redacts the
|
||||
in-memory struct; the actual export format (zip / json-lines / etc.)
|
||||
is owned by `chanora_diagnostics`.
|
||||
- **No threat-model coverage of memory dumps, core dumps, or kernel
|
||||
logs.** That is `docs/security/threat-model.md` territory.
|
||||
- **No language-aware redaction.** The Unicode policy is "preserve
|
||||
multilingual text in non-sensitive fields; do not introspect it."
|
||||
- **No PII discovery.** This is a deny-list redactor; it does not
|
||||
attempt to detect previously-unknown secrets by entropy heuristics.
|
||||
|
||||
## Verification log
|
||||
|
||||
See `VERIFICATION.md` in this directory.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Verification record — `diagnostics-redaction-spike`
|
||||
|
||||
## Result
|
||||
|
||||
PASS. The PoC exit criterion (from
|
||||
`docs/architecture/proof-of-concept-plan.md` §2: "Password and
|
||||
identity-secret samples are redacted") is met, with full coverage of
|
||||
REDACT-TC-001..010 from
|
||||
`docs/security/diagnostic-redaction-audit-report.md` §4.
|
||||
|
||||
## Environment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Date | 2026-05-13 |
|
||||
| Host OS | Linux (Arch, kernel 7.0.5-arch1-1, x86_64) |
|
||||
| Rust toolchain | stable 1.95.0 |
|
||||
| `regex` | 1 |
|
||||
| `serde` / `serde_json` | 1 |
|
||||
| `once_cell` | 1 |
|
||||
|
||||
## Reproduction
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
printf 'INFO password=hunter2 path=/home/milkice/x\n' \
|
||||
| cargo run --bin diagnostics-redaction-cli
|
||||
```
|
||||
|
||||
## Test run
|
||||
|
||||
```
|
||||
running 12 tests
|
||||
test known_secret_is_scrubbed_even_when_no_regex_matches ... ok
|
||||
test redact_tc_005_channel_name_with_unicode_excluded_by_default ... ok
|
||||
test redact_tc_006_nickname_with_unicode_preserved_in_safe_field ... ok
|
||||
test redact_tc_002_identity_secret_in_storage_error ... ok
|
||||
test redact_tc_010_multilingual_safe_text_preserved ... ok
|
||||
test redact_tc_009_long_hostile_protocol_string_truncated ... ok
|
||||
test redact_tc_007_local_file_paths_user_segment_minimized ... ok
|
||||
test empty_registered_secret_is_ignored ... ok
|
||||
test redact_tc_004_chat_text_excluded_by_default ... ok
|
||||
test redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields ... ok
|
||||
test redact_tc_001_server_password_in_connection_data ... ok
|
||||
test redact_tc_003_server_url_with_password_field ... ok
|
||||
|
||||
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured;
|
||||
0 filtered out; finished in 0.01s
|
||||
```
|
||||
|
||||
## CLI run
|
||||
|
||||
Input:
|
||||
|
||||
```text
|
||||
INFO connect host=cn.teamspeak.app password=hunter2 nickname=Chanora
|
||||
DEBUG identity=MG0DAgeAAgEgAiAIXJBlj1hQbaH0Eq0DuLlCmH8bl+veTAO2k9EQjEYSgIgNnImcmKo7ls5mExb6skfK2Twu54aeDr0OP1ITsC50CIA8M5nmDBn
|
||||
ERROR open /home/milkice/.local/share/chanora/db failed
|
||||
NOTICE CHANORA_LITERAL_DEMO_SECRET seen on bus
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
INFO connect host=cn.teamspeak.app password=[REDACTED] nickname=Chanora
|
||||
DEBUG identity=[REDACTED]
|
||||
ERROR open /home/[REDACTED]/.local/share/chanora/db failed
|
||||
NOTICE [REDACTED] seen on bus
|
||||
```
|
||||
|
||||
- Password: redacted (rule `password-kv`).
|
||||
- Base64-ish identity blob: redacted (rule `identity-base64-blob`).
|
||||
- Linux home-username segment: redacted (rule `linux-home-path`); the
|
||||
rest of the path is preserved.
|
||||
- Pre-registered literal secret: redacted via
|
||||
`KnownSecretRegistry`, demonstrating the SS-AUD-003 defence-in-depth.
|
||||
- Non-secret tokens (`host=`, `nickname=`, `INFO`, `ERROR`, …) all
|
||||
flow through unchanged.
|
||||
|
||||
## Audit-matrix coverage
|
||||
|
||||
| Audit ID | Test | Result |
|
||||
|---|---|---|
|
||||
| REDACT-TC-001 (server password in connection data) | `redact_tc_001_server_password_in_connection_data` | PASS |
|
||||
| REDACT-TC-002 (identity secret in storage error) | `redact_tc_002_identity_secret_in_storage_error` | PASS — uses `KnownSecretRegistry`. |
|
||||
| REDACT-TC-003 (URL with password field) | `redact_tc_003_server_url_with_password_field` | PASS — only the password value is redacted. |
|
||||
| REDACT-TC-004 (chat text in export) | `redact_tc_004_chat_text_excluded_by_default` | PASS — default policy excludes. |
|
||||
| REDACT-TC-005 (Unicode channel name) | `redact_tc_005_channel_name_with_unicode_excluded_by_default` | PASS — default policy excludes. |
|
||||
| REDACT-TC-006 (Unicode nickname) | `redact_tc_006_nickname_with_unicode_preserved_in_safe_field` | PASS — preserved in non-sensitive extras. |
|
||||
| REDACT-TC-007 (local file path) | `redact_tc_007_local_file_paths_user_segment_minimized` | PASS — username segment redacted on Linux/Windows/macOS path shapes. |
|
||||
| REDACT-TC-008 (mixed sensitive bundle) | `redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields` | PASS — whole-bundle JSON scanned for plaintext. |
|
||||
| REDACT-TC-009 (long hostile protocol string) | `redact_tc_009_long_hostile_protocol_string_truncated` | PASS — `MAX_PROTOCOL_STRING_LEN = 256` cap with truncation marker. |
|
||||
| REDACT-TC-010 (multilingual safe text) | `redact_tc_010_multilingual_safe_text_preserved` | PASS — Chinese / Japanese / Korean / Latin-diacritic text preserved verbatim. |
|
||||
|
||||
## What this spike does NOT validate
|
||||
|
||||
- Integration as a `tracing` layer (production code will own this).
|
||||
- Diagnostic export *file format* (zip / json-lines / archive layout).
|
||||
- Mobile-platform path shapes (`/data/data/<pkg>/` on Android,
|
||||
iOS app-group containers).
|
||||
- Memory / core dumps.
|
||||
- Performance under sustained high-volume logging.
|
||||
- Adversarial regex evasion beyond the trivial cases exercised.
|
||||
- Process-level review of the audit report (SS-AUD-007 style sign-off).
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Chanora PoC — diagnostics redaction spike.
|
||||
//!
|
||||
//! Authority:
|
||||
//! * `docs/architecture/proof-of-concept-plan.md` §2 — Diagnostics
|
||||
//! redaction spike. Exit criterion: "Password and identity-secret
|
||||
//! samples are redacted."
|
||||
//! * `docs/security/diagnostic-redaction-audit-report.md` — policy
|
||||
//! in §2, surfaces in §3, test matrix REDACT-TC-001..010 in §4,
|
||||
//! bundle policy in §5.
|
||||
//! * SRS-093 / SysRS-152 / SysRS-154 / SysRS-155 — software-level
|
||||
//! redaction obligations for logs and diagnostic exports.
|
||||
//!
|
||||
//! Production code will own this redactor inside the `chanora_diagnostics`
|
||||
//! crate and wire it as a `tracing` layer. The PoC is the same shape in
|
||||
//! miniature: a typed policy, a redactor that applies it to free text
|
||||
//! and to structured diagnostic bundles, plus an explicit list of
|
||||
//! known-literal secrets the host application can register at runtime.
|
||||
|
||||
pub mod bundle;
|
||||
pub mod policy;
|
||||
pub mod redactor;
|
||||
|
||||
pub use bundle::{DiagnosticBundle, RedactedBundle};
|
||||
pub use policy::{RedactionPolicy, RedactionRule};
|
||||
pub use redactor::{KnownSecretRegistry, Redactor};
|
||||
|
||||
/// The replacement string used for redacted segments.
|
||||
///
|
||||
/// Chosen distinctly so audit tests can grep for either presence
|
||||
/// (correct redaction occurred) or absence (no leak).
|
||||
pub const REDACTION_MARKER: &str = "[REDACTED]";
|
||||
@@ -0,0 +1,30 @@
|
||||
//! CLI driver — accepts mixed sensitive input on stdin, prints the
|
||||
//! redacted version on stdout, and prints a summary on stderr.
|
||||
|
||||
use std::io::Read;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use diagnostics_redaction_spike::Redactor;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut input = String::new();
|
||||
if std::io::stdin().read_to_string(&mut input).is_err() {
|
||||
eprintln!("failed to read stdin");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
let redactor = Redactor::with_default_policy();
|
||||
|
||||
// The host application would normally register live secrets here
|
||||
// (e.g. as the secure-storage layer materialises them).
|
||||
// For the CLI demo, register a fixed plaintext so the audience
|
||||
// can see literal scrubbing work.
|
||||
redactor
|
||||
.known_secrets()
|
||||
.register("CHANORA_LITERAL_DEMO_SECRET");
|
||||
|
||||
let out = redactor.redact_text(&input);
|
||||
println!("{out}");
|
||||
eprintln!("--- redaction applied; rules in policy: {} ---", redactor.policy().rules.len());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Redaction policy: the catalogue of patterns and structural rules
|
||||
//! the redactor applies.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
/// A single regex-driven rule.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedactionRule {
|
||||
pub id: &'static str,
|
||||
pub description: &'static str,
|
||||
pub pattern: Regex,
|
||||
/// If `Some(n)`, the rule replaces capture-group `n` rather than
|
||||
/// the whole match. Useful for "ts3server://host?password=XXXX"
|
||||
/// where the host should be preserved but `XXXX` redacted.
|
||||
pub redact_group: Option<usize>,
|
||||
}
|
||||
|
||||
impl RedactionRule {
|
||||
pub fn new(
|
||||
id: &'static str,
|
||||
description: &'static str,
|
||||
pattern: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
description,
|
||||
pattern: Regex::new(pattern).expect("static rule regex must compile"),
|
||||
redact_group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_group(
|
||||
id: &'static str,
|
||||
description: &'static str,
|
||||
pattern: &str,
|
||||
group: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
description,
|
||||
pattern: Regex::new(pattern).expect("static rule regex must compile"),
|
||||
redact_group: Some(group),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full redaction policy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedactionPolicy {
|
||||
pub rules: Vec<RedactionRule>,
|
||||
|
||||
/// Structured-field redaction: any key whose lower-cased name
|
||||
/// contains one of these substrings is fully redacted in
|
||||
/// diagnostic bundles. Mirrors the bundle policy in
|
||||
/// `diagnostic-redaction-audit-report.md` §5.
|
||||
pub redact_field_substrings: Vec<&'static str>,
|
||||
|
||||
/// Bundle-level: include chat messages? Audit-report default = No.
|
||||
pub include_chat: bool,
|
||||
|
||||
/// Bundle-level: include channel tree details?
|
||||
pub include_channel_tree: bool,
|
||||
}
|
||||
|
||||
/// Hard upper bound on protocol-string lengths kept in diagnostics.
|
||||
///
|
||||
/// REDACT-TC-009 ("long hostile protocol string"): truncate or safely
|
||||
/// escape. The PoC truncates with a clear marker.
|
||||
pub const MAX_PROTOCOL_STRING_LEN: usize = 256;
|
||||
|
||||
impl RedactionPolicy {
|
||||
/// The default policy used by the PoC. Mirrors the audit-report
|
||||
/// catalogue. Production code (`chanora_diagnostics`) will own the
|
||||
/// canonical version of this list.
|
||||
pub fn default_policy() -> Self {
|
||||
Self {
|
||||
rules: DEFAULT_RULES.clone(),
|
||||
redact_field_substrings: vec![
|
||||
"password",
|
||||
"secret",
|
||||
"private_key",
|
||||
"private-key",
|
||||
"identity_key",
|
||||
"identity-key",
|
||||
"token",
|
||||
"credential",
|
||||
"passphrase",
|
||||
],
|
||||
include_chat: false,
|
||||
include_channel_tree: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DEFAULT_RULES: Lazy<Vec<RedactionRule>> = Lazy::new(|| {
|
||||
vec![
|
||||
// Identity secret as it appears in `tsclientlib` style:
|
||||
// long base64-ish run preceded by a known marker.
|
||||
// We err on the side of catching base64 blobs >= 64 chars.
|
||||
RedactionRule::new(
|
||||
"identity-base64-blob",
|
||||
"Long base64-like run; matches identity secrets and tokens.",
|
||||
r"\b[A-Za-z0-9+/]{64,}={0,2}\b",
|
||||
),
|
||||
// `password = "..."` / `password: "..."` / `password=...`
|
||||
// Captures the value in group 1 so the *key* name is kept.
|
||||
// The value excludes separators commonly found in URL query
|
||||
// strings and config lines (`& , ; " whitespace`).
|
||||
RedactionRule::new_group(
|
||||
"password-kv",
|
||||
"key=value style password assignment.",
|
||||
r#"(?i)\b(?:password|passwd|pass|pwd)\s*[:=]\s*"?([^"\s,;&]+)"?"#,
|
||||
1,
|
||||
),
|
||||
// ts3server://host?password=XXXX&...
|
||||
RedactionRule::new_group(
|
||||
"ts3server-url-password",
|
||||
"Password embedded in a ts3server:// URL query string.",
|
||||
r"(?i)(?:[?&])password=([^&\s]+)",
|
||||
1,
|
||||
),
|
||||
// Generic Authorization: Bearer ...
|
||||
RedactionRule::new_group(
|
||||
"authorization-bearer",
|
||||
"Authorization header bearer token.",
|
||||
r"(?i)Authorization:\s*Bearer\s+(\S+)",
|
||||
1,
|
||||
),
|
||||
// Linux user home: /home/<user>/... → minimize the username
|
||||
// segment. Captures group 1 = username.
|
||||
RedactionRule::new_group(
|
||||
"linux-home-path",
|
||||
"Linux home-directory path; minimizes the username segment.",
|
||||
r"(/home/)([^/\s]+)",
|
||||
2,
|
||||
),
|
||||
// Windows user: C:\Users\<user>\...
|
||||
RedactionRule::new_group(
|
||||
"windows-user-path",
|
||||
"Windows user-profile path; minimizes the username segment.",
|
||||
r"(?i)([A-Z]:\\Users\\)([^\\\s]+)",
|
||||
2,
|
||||
),
|
||||
// macOS user: /Users/<user>/...
|
||||
RedactionRule::new_group(
|
||||
"macos-user-path",
|
||||
"macOS user-profile path; minimizes the username segment.",
|
||||
r"(/Users/)([^/\s]+)",
|
||||
2,
|
||||
),
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
//! 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 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");
|
||||
}
|
||||
Reference in New Issue
Block a user