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