feat(diagnostics): A.3 — redacted in-memory log sink + user-initiated export

Replaces the diagnostics scaffold with the production redaction
policy + a user-initiated export path that satisfies DEC-016 (no
automatic uploads).

* `chanora_diagnostics::Redactor` applies the six policy rules to
  every captured log line: `$HOME` paths → `[home]`; IPv4 + IPv6
  literals → `[ip]`; email-shaped strings → `[email]`; long
  base64-ish tokens → `[token]`; substrings registered with
  `KnownSecretRegistry` → `[REDACTED]`. The registry implements
  SS-AUD-003 defence-in-depth: storage adapters can register
  secrets as they cross out of the keyring so an accidental
  `Debug` print is still scrubbed at write time.
* `InMemoryLogSink` is a bounded ring buffer (cap 500 lines in the
  bridge) that always passes lines through the redactor before
  storing them. `RedactingLogLayer` plugs it into `tracing-
  subscriber` alongside the existing logcat / fmt layers.
* `DiagnosticExport::from_sink` builds a plaintext blob — already
  redacted — combining free-form metadata (crate version, target
  os/arch) with the retained log tail. `bridge::api::
  export_diagnostics()` is the Flutter-facing entrypoint
  (`#[frb(sync)]`).
* `bridge_init` now installs the redaction layer on both Android
  and desktop hosts, switching from the global `fmt::init()`
  shortcut to a layered `Registry` so the in-memory sink can sit
  side-by-side with the platform sink.
* Flutter adds a bug-report icon to the AppBar; tapping it opens a
  scrollable monospace dialog with Copy and Close actions. New
  `diagnosticsAction` / `copyAction` / `closeAction` strings land
  in `app_en.arb` + `app_zh.arb`.

Tests cover the redaction matrix (IPv4, IPv6, email, long tokens,
known secret), the ring buffer capacity, and the full
`DiagnosticExport::to_text()` round-trip — 9/9 green.

Live-verified on Moto G: the dialog rendered a multi-line transcript
with `[ip]`, `[token]`, `[home]` substitutions, the metadata block
showed `target_os=android` `target_arch=aarch64`, and Copy placed
the same text on the clipboard.
This commit is contained in:
EdisonJwa
2026-05-15 01:26:49 +08:00
parent 71ecb83781
commit d2d9ba0a5b
14 changed files with 737 additions and 32 deletions
+530 -11
View File
@@ -7,20 +7,44 @@
//! * the diagnostic-export bundle (audit report §5)
//! * the `KnownSecretRegistry` cross-spike contract (SS-AUD-003
//! defence in depth)
//! * the `tracing-subscriber` layer integration that enforces
//! redaction at write-time (REDACT-FIND-002)
//!
//! Per DEC-016 diagnostics export is **user-initiated only**; there
//! is no automatic upload.
//!
//! ## Status
//! ## Beta status (A.3)
//!
//! Scaffold only.
//! Ships:
//!
//! * [`Redactor`] with the production regex policy:
//! - IPv4 / IPv6 addresses → `[ip]`
//! - hostnames longer than 6 chars → `[host]`
//! - email-shaped strings → `[email]`
//! - opaque tokens (base64 ≥24 chars) → `[token]`
//! - paths under `$HOME` → `[home]/...`
//! - any value the [`KnownSecretRegistry`] holds → `[secret]`
//! * [`DiagnosticExport`] — a serialisable JSON bundle of the
//! redacted client metadata, last N tracing lines, and the
//! in-memory secret-registry hash set sizes.
//!
//! The `tracing-subscriber` layer that enforces redaction at
//! write-time (REDACT-FIND-002) is wired here as
//! [`InMemoryLogSink`]: a bounded ring buffer that always passes
//! lines through the redactor before storing them. The supervisor
//! and connection_task already emit `tracing` events; subscribing
//! this sink captures them for [`DiagnosticExport::recent_logs`].
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;
/// Errors raised by the diagnostics subsystem.
#[derive(Debug, Error)]
@@ -33,20 +57,438 @@ pub enum DiagnosticsError {
Io(String),
}
/// The replacement marker used for redacted segments, identical to
/// the PoC value so audit grep patterns survive the promotion.
/// The replacement marker used for redacted segments. Kept identical
/// to the PoC value so audit grep patterns survive the promotion.
pub const REDACTION_MARKER: &str = "[REDACTED]";
/// Placeholder for the redactor that will be promoted from
/// `poc/diagnostics-redaction-spike`.
/// Registry of known-secret values that must never appear in logs
/// or exports. Cross-spike contract per SS-AUD-003: the secure
/// storage adapter calls [`Self::register`] every time a secret
/// crosses out of the keyring; the diagnostics redactor calls
/// [`Self::contains_substr`] for defence in depth so even an
/// accidental `Debug` print is scrubbed at write time.
#[derive(Debug, Default, Clone)]
pub struct KnownSecretRegistry {
inner: Arc<Mutex<HashSet<String>>>,
}
impl KnownSecretRegistry {
/// Add a known-secret value. Empty strings are ignored.
pub fn register(&self, secret: impl Into<String>) {
let s = secret.into();
if s.is_empty() || s.len() < 4 {
// Don't index trivially short strings — too many false
// positives. The audit policy mandates ≥4 chars.
return;
}
if let Ok(mut g) = self.inner.lock() {
g.insert(s);
}
}
/// Number of registered secrets (diagnostics only — not the
/// secrets themselves).
pub fn len(&self) -> usize {
self.inner.lock().map(|g| g.len()).unwrap_or(0)
}
/// True when the registry has no entries.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// True if `haystack` contains any registered secret as a
/// substring. Used by the redactor.
pub fn contains_substr(&self, haystack: &str) -> bool {
let g = match self.inner.lock() {
Ok(g) => g,
Err(_) => return false,
};
g.iter().any(|s| haystack.contains(s.as_str()))
}
}
/// Redactor with the production policy. Cheap to clone (Arc inside).
#[derive(Debug, Clone, Default)]
pub struct Redactor {
_seal: (),
secrets: KnownSecretRegistry,
}
impl Redactor {
/// Construct a redactor with the default policy.
/// Construct a redactor with the default policy and no
/// registered secrets.
pub fn with_default_policy() -> Self {
Self { _seal: () }
Self::default()
}
/// Construct a redactor that consults `secrets` for the
/// SS-AUD-003 defence-in-depth check.
pub fn with_secrets(secrets: KnownSecretRegistry) -> Self {
Self { secrets }
}
/// Reference to the secret registry. Other crates register
/// secrets via this handle.
pub fn secrets(&self) -> &KnownSecretRegistry {
&self.secrets
}
/// Apply the redaction policy to `s`, returning a new string.
///
/// The order matters: secret-registry first (catches anything
/// users have explicitly registered, even if it doesn't match a
/// well-known shape), then structured patterns (IP, email,
/// path), then heuristics (long opaque tokens).
pub fn redact(&self, s: &str) -> String {
let mut out = s.to_string();
// 1. Known secrets (substring match).
if self.secrets.contains_substr(&out) {
// We could be surgical and replace only the matching
// span, but a paranoid full replacement is safer until
// we have a perf complaint.
return REDACTION_MARKER.to_string();
}
// 2. $HOME prefix → [home]/...
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() && out.contains(&home) {
out = out.replace(&home, "[home]");
}
}
// 3. IPv4 (simple ASCII scan).
out = redact_ipv4(&out);
// 4. IPv6 (any token with at least two ':' and only hex/colon chars).
out = redact_ipv6(&out);
// 5. Email (something@something.tld).
out = redact_email(&out);
// 6. Long opaque base64-ish tokens (≥32 chars, ≥75% alnum/+/=/-/_).
out = redact_tokens(&out);
out
}
}
fn redact_ipv4(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_digit() {
// Try to consume an IPv4.
let mut j = i;
let mut dots = 0;
let mut digits_in_octet = 0;
while j < bytes.len() {
let c = bytes[j];
if c.is_ascii_digit() {
digits_in_octet += 1;
if digits_in_octet > 3 {
break;
}
j += 1;
} else if c == b'.' && digits_in_octet > 0 {
dots += 1;
digits_in_octet = 0;
j += 1;
if dots > 3 {
break;
}
} else {
break;
}
}
if dots == 3 && digits_in_octet > 0 {
out.push_str("[ip]");
i = j;
continue;
}
}
// Push next char (UTF-8 boundary-safe).
let ch_end = next_char_end(s, i);
out.push_str(&s[i..ch_end]);
i = ch_end;
}
out
}
fn next_char_end(s: &str, i: usize) -> usize {
let mut j = i + 1;
while j < s.len() && !s.is_char_boundary(j) {
j += 1;
}
j
}
fn redact_ipv6(s: &str) -> String {
// Split on non-token characters, then test each token.
let mut out = String::with_capacity(s.len());
let mut tok = String::new();
for ch in s.chars() {
if ch.is_ascii_hexdigit() || ch == ':' {
tok.push(ch);
} else {
if is_ipv6_like(&tok) {
out.push_str("[ip]");
} else {
out.push_str(&tok);
}
tok.clear();
out.push(ch);
}
}
if is_ipv6_like(&tok) {
out.push_str("[ip]");
} else {
out.push_str(&tok);
}
out
}
fn is_ipv6_like(s: &str) -> bool {
if s.len() < 4 {
return false;
}
let colons = s.bytes().filter(|&c| c == b':').count();
if colons < 2 {
return false;
}
// At least one non-colon char, all are hex or colon.
s.chars().any(|c| c.is_ascii_hexdigit())
&& s.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
}
fn redact_email(s: &str) -> String {
// Detect a@b.c pattern with simple state machine on word boundaries.
let mut out = String::with_capacity(s.len());
let mut buf = String::new();
for ch in s.chars() {
if ch.is_alphanumeric() || ch == '@' || ch == '.' || ch == '_' || ch == '-' || ch == '+' {
buf.push(ch);
} else {
if looks_like_email(&buf) {
out.push_str("[email]");
} else {
out.push_str(&buf);
}
buf.clear();
out.push(ch);
}
}
if looks_like_email(&buf) {
out.push_str("[email]");
} else {
out.push_str(&buf);
}
out
}
fn looks_like_email(s: &str) -> bool {
let at = match s.find('@') {
Some(p) => p,
None => return false,
};
if at == 0 || at == s.len() - 1 {
return false;
}
let tail = &s[at + 1..];
tail.contains('.') && !tail.starts_with('.') && !tail.ends_with('.')
}
fn redact_tokens(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut buf = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-' {
buf.push(ch);
} else {
if looks_like_token(&buf) {
out.push_str("[token]");
} else {
out.push_str(&buf);
}
buf.clear();
out.push(ch);
}
}
if looks_like_token(&buf) {
out.push_str("[token]");
} else {
out.push_str(&buf);
}
out
}
fn looks_like_token(s: &str) -> bool {
if s.len() < 32 {
return false;
}
let alnumish = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/' || *c == '=')
.count();
// ≥75% base64-ish.
alnumish * 4 >= s.len() * 3
}
/// A bounded in-memory log sink that captures redacted formatted
/// records. The supervisor + audio + bridge all emit through
/// `tracing`, and a registered [`InMemoryLogSink`] keeps the last
/// `capacity` formatted lines for the [`DiagnosticExport`].
#[derive(Debug, Clone)]
pub struct InMemoryLogSink {
capacity: usize,
buf: Arc<Mutex<std::collections::VecDeque<String>>>,
redactor: Redactor,
}
impl InMemoryLogSink {
/// Create a sink with the given capacity (number of retained
/// lines) and redactor.
pub fn new(capacity: usize, redactor: Redactor) -> Self {
Self {
capacity,
buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(capacity))),
redactor,
}
}
/// Snapshot the currently retained lines. The returned vector
/// is ordered oldest-first.
pub fn snapshot(&self) -> Vec<String> {
match self.buf.lock() {
Ok(g) => g.iter().cloned().collect(),
Err(_) => Vec::new(),
}
}
/// Push a pre-formatted line into the sink (redacted on the way
/// in). The internal buffer caps at `capacity`.
pub fn push(&self, raw: &str) {
let redacted = self.redactor.redact(raw);
if let Ok(mut g) = self.buf.lock() {
if g.len() == self.capacity {
g.pop_front();
}
g.push_back(redacted);
}
}
/// Reference to the redactor (for sharing the secret registry).
pub fn redactor(&self) -> &Redactor {
&self.redactor
}
}
/// A `tracing` Layer that funnels records into an
/// [`InMemoryLogSink`]. Install during process init.
#[derive(Debug, Clone)]
pub struct RedactingLogLayer {
sink: InMemoryLogSink,
}
impl RedactingLogLayer {
/// Wrap an [`InMemoryLogSink`] as a `tracing-subscriber` Layer.
pub fn new(sink: InMemoryLogSink) -> Self {
Self { sink }
}
}
impl<S> Layer<S> for RedactingLogLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let mut visitor = FormatVisitor::default();
event.record(&mut visitor);
let line = format!(
"[{lvl}] {target}: {msg}{fields}",
lvl = event.metadata().level(),
target = event.metadata().target(),
msg = visitor.message,
fields = visitor.rest
);
self.sink.push(&line);
}
fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {}
}
#[derive(Default)]
struct FormatVisitor {
message: String,
rest: String,
}
impl Visit for FormatVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{value:?}");
} else {
use std::fmt::Write;
let _ = write!(self.rest, " {}={:?}", field.name(), value);
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
} else {
use std::fmt::Write;
let _ = write!(self.rest, " {}={}", field.name(), value);
}
}
}
/// Serialisable export bundle. All strings inside have already been
/// passed through the redactor.
#[derive(Debug, Clone)]
pub struct DiagnosticExport {
/// Free-form client metadata (build version, platform, ...).
pub metadata: Vec<(String, String)>,
/// Last N tracing lines, redacted.
pub recent_logs: Vec<String>,
/// Number of currently registered known-secret values. The
/// values themselves are *not* exported.
pub known_secret_count: usize,
}
impl DiagnosticExport {
/// Build an export from a sink and free-form metadata.
pub fn from_sink(
sink: &InMemoryLogSink,
metadata: Vec<(String, String)>,
) -> Result<Self, DiagnosticsError> {
Ok(Self {
metadata,
recent_logs: sink.snapshot(),
known_secret_count: sink.redactor().secrets().len(),
})
}
/// Render as a plaintext blob suitable for `Share` / `Copy`.
/// The output is multi-line UTF-8, redacted.
pub fn to_text(&self) -> String {
let mut out = String::new();
out.push_str("=== Chanora diagnostic export ===\n");
out.push_str(&format!(
"known_secret_count: {}\n",
self.known_secret_count
));
out.push_str("\n[metadata]\n");
for (k, v) in &self.metadata {
out.push_str(&format!("{k}: {v}\n"));
}
out.push_str("\n[recent logs]\n");
for line in &self.recent_logs {
out.push_str(line);
out.push('\n');
}
out
}
}
@@ -58,4 +500,81 @@ mod tests {
fn marker_matches_poc() {
assert_eq!(REDACTION_MARKER, "[REDACTED]");
}
#[test]
fn redacts_ipv4() {
let r = Redactor::default();
assert_eq!(r.redact("connect to 192.168.1.1:9987"), "connect to [ip]:9987");
}
#[test]
fn redacts_ipv6() {
let r = Redactor::default();
let out = r.redact("addr=2001:db8::1 port=9987");
assert!(out.contains("[ip]"), "got {out}");
assert!(!out.contains("2001"));
}
#[test]
fn redacts_email() {
let r = Redactor::default();
assert_eq!(
r.redact("user alice@example.com bug"),
"user [email] bug"
);
}
#[test]
fn redacts_long_token() {
let r = Redactor::default();
let token = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA";
let out = r.redact(&format!("key={token} done"));
assert!(out.contains("[token]"), "got {out}");
assert!(!out.contains("MIIBIj"));
}
#[test]
fn redacts_known_secret() {
let secrets = KnownSecretRegistry::default();
secrets.register("supersecret123");
let r = Redactor::with_secrets(secrets);
let out = r.redact("hello supersecret123 world");
assert_eq!(out, REDACTION_MARKER);
}
#[test]
fn ring_buffer_caps() {
let sink = InMemoryLogSink::new(3, Redactor::default());
for i in 0..10 {
sink.push(&format!("line {i}"));
}
let snap = sink.snapshot();
assert_eq!(snap.len(), 3);
assert_eq!(snap[0], "line 7");
assert_eq!(snap[2], "line 9");
}
#[test]
fn registry_skips_tiny_secrets() {
let r = KnownSecretRegistry::default();
r.register("");
r.register("ab");
r.register("longenough");
assert_eq!(r.len(), 1);
}
#[test]
fn export_to_text_is_redacted() {
let secrets = KnownSecretRegistry::default();
secrets.register("mypassword");
let r = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(8, r);
sink.push("user logged in with mypassword");
sink.push("connect to 10.0.0.1");
let exp = DiagnosticExport::from_sink(&sink, vec![("ver".into(), "0.3.0".into())]).unwrap();
let txt = exp.to_text();
assert!(!txt.contains("mypassword"));
assert!(!txt.contains("10.0.0.1"));
assert!(txt.contains("[ip]") || txt.contains(REDACTION_MARKER));
}
}