877 lines
28 KiB
Rust
877 lines
28 KiB
Rust
//! # `chanora_diagnostics`
|
|
//!
|
|
//! Application diagnostics. Owns:
|
|
//!
|
|
//! * the redaction policy and [`Redactor`] (REDACT-TC-001..010 per
|
|
//! `docs/security/diagnostic-redaction-audit-report.md` §4)
|
|
//! * the diagnostic-export bundle (audit report §5)
|
|
//! * the `KnownSecretRegistry` cross-spike contract (SS-AUD-003
|
|
//! defence in depth)
|
|
//!
|
|
//! Per DEC-016 diagnostics export is **user-initiated only**; there
|
|
//! is no automatic upload.
|
|
//!
|
|
//! ## Beta status (A.3)
|
|
//!
|
|
//! 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)]
|
|
pub enum DiagnosticsError {
|
|
/// Failed to build or serialise a diagnostic export.
|
|
#[error("export failed: {0}")]
|
|
Export(String),
|
|
/// I/O error while writing logs or exports.
|
|
#[error("io: {0}")]
|
|
Io(String),
|
|
}
|
|
|
|
/// 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]";
|
|
|
|
/// 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 {
|
|
secrets: KnownSecretRegistry,
|
|
}
|
|
|
|
impl Redactor {
|
|
/// Construct a redactor with the default policy and no
|
|
/// registered secrets.
|
|
pub fn with_default_policy() -> Self {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Field names that name a raw key value or key-press timing
|
|
/// sequence. Records carrying any of these names are dropped before
|
|
/// they reach the log sink (REDACT-PTT-001..006 in
|
|
/// `docs/security/diagnostic-redaction-audit-report.md`; SDD-090).
|
|
///
|
|
/// We compare by exact field name rather than a content scan —
|
|
/// partial-redaction false negatives are riskier than a missing
|
|
/// log line, and the audio-engine and PTT-backend code paths emit
|
|
/// records with stable field names that we control.
|
|
const PTT_BANNED_FIELDS: &[&str] = &[
|
|
"key_code",
|
|
"scan_code",
|
|
"virtual_key",
|
|
"vk",
|
|
"keysym",
|
|
"keysym_string",
|
|
"key_sequence",
|
|
"key_press_history",
|
|
"key_timing",
|
|
];
|
|
|
|
/// A `tracing` Layer that funnels records into an
|
|
/// [`InMemoryLogSink`]. Install during process init.
|
|
///
|
|
/// Per DEC-027 the records that carry raw key data must never reach
|
|
/// the in-memory sink. Per SDD-090 that responsibility lives in a
|
|
/// separate decorator Layer — [`PttSanitizer`] — so the
|
|
/// privacy boundary is its own named software unit. Constructors
|
|
/// that want the decorated stack should call
|
|
/// [`PttSanitizer::wrap`] (or use `RedactingLogLayer::with_sanitizer`
|
|
/// for the canonical pairing).
|
|
///
|
|
/// `RedactingLogLayer` retains the same structural ban check for
|
|
/// callers that install it bare, so existing wiring continues to
|
|
/// honour DEC-027 even without `PttSanitizer`. The two checks are
|
|
/// idempotent — a sanitiser-wrapped layer never sees a banned
|
|
/// record so the inner check is a no-op on that path.
|
|
#[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 }
|
|
}
|
|
|
|
/// Convenience: wrap `self` in a [`PttSanitizer`] decorator
|
|
/// (SDD-090). Equivalent to `PttSanitizer::wrap(self)`.
|
|
pub fn with_sanitizer(self) -> PttSanitizer<Self> {
|
|
PttSanitizer::wrap(self)
|
|
}
|
|
}
|
|
|
|
impl<S> Layer<S> for RedactingLogLayer
|
|
where
|
|
S: Subscriber + for<'a> LookupSpan<'a>,
|
|
{
|
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
// Defence-in-depth: a `RedactingLogLayer` installed without
|
|
// a surrounding `PttSanitizer` still drops banned records.
|
|
// When wrapped by `PttSanitizer` this check is unreachable
|
|
// (the sanitiser short-circuits first).
|
|
let mut ban_check = PttBanCheckVisitor::default();
|
|
event.record(&mut ban_check);
|
|
if ban_check.banned {
|
|
return;
|
|
}
|
|
|
|
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>) {}
|
|
}
|
|
|
|
/// PTT sanitiser Layer (SAD-077 / SDD-090).
|
|
///
|
|
/// Decorates an inner `tracing-subscriber::Layer` (typically
|
|
/// [`RedactingLogLayer`]). On each `on_event` the sanitiser performs
|
|
/// a single allocation-free pass over the event's field set and
|
|
/// drops the record if any field name matches the
|
|
/// [`PTT_BANNED_FIELDS`] list. Records without banned fields are
|
|
/// forwarded verbatim to the inner Layer's `on_event`.
|
|
///
|
|
/// The implementation is allocation-free on the success path (the
|
|
/// typical "no banned field" case): the visitor holds a single
|
|
/// `bool` on the stack and exits early once a banned name is seen.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PttSanitizer<L> {
|
|
inner: L,
|
|
}
|
|
|
|
impl<L> PttSanitizer<L> {
|
|
/// Wrap an inner Layer with the PTT sanitiser. Use
|
|
/// [`RedactingLogLayer::with_sanitizer`] for the canonical
|
|
/// pairing.
|
|
pub fn wrap(inner: L) -> Self {
|
|
Self { inner }
|
|
}
|
|
|
|
/// Borrow the wrapped inner Layer (read-only).
|
|
pub fn inner(&self) -> &L {
|
|
&self.inner
|
|
}
|
|
}
|
|
|
|
impl<S, L> Layer<S> for PttSanitizer<L>
|
|
where
|
|
S: Subscriber + for<'a> LookupSpan<'a>,
|
|
L: Layer<S>,
|
|
{
|
|
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
|
|
let mut ban_check = PttBanCheckVisitor::default();
|
|
event.record(&mut ban_check);
|
|
if ban_check.banned {
|
|
return;
|
|
}
|
|
self.inner.on_event(event, ctx);
|
|
}
|
|
|
|
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
|
|
self.inner.on_new_span(attrs, id, ctx);
|
|
}
|
|
}
|
|
|
|
/// Lightweight `tracing::field::Visit` implementation that only
|
|
/// notes whether any visited field name matches the PTT banned
|
|
/// list. Allocation-free.
|
|
#[derive(Default)]
|
|
struct PttBanCheckVisitor {
|
|
banned: bool,
|
|
}
|
|
|
|
impl PttBanCheckVisitor {
|
|
fn check(&mut self, name: &str) {
|
|
if !self.banned && PTT_BANNED_FIELDS.iter().any(|b| *b == name) {
|
|
self.banned = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Visit for PttBanCheckVisitor {
|
|
fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_str(&mut self, field: &Field, _value: &str) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_i64(&mut self, field: &Field, _value: i64) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_u64(&mut self, field: &Field, _value: u64) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_bool(&mut self, field: &Field, _value: bool) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_f64(&mut self, field: &Field, _value: f64) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_i128(&mut self, field: &Field, _value: i128) {
|
|
self.check(field.name());
|
|
}
|
|
fn record_u128(&mut self, field: &Field, _value: u128) {
|
|
self.check(field.name());
|
|
}
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
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));
|
|
}
|
|
|
|
#[test]
|
|
fn ptt_ban_check_visitor_flags_banned_fields() {
|
|
// Direct test of the visitor (we don't spin up a full
|
|
// tracing subscriber for this).
|
|
let mut v = PttBanCheckVisitor::default();
|
|
v.check("backend_id"); // safe
|
|
assert!(!v.banned);
|
|
v.check("key_code"); // banned
|
|
assert!(v.banned);
|
|
|
|
let mut v2 = PttBanCheckVisitor::default();
|
|
for name in [
|
|
"scan_code",
|
|
"virtual_key",
|
|
"vk",
|
|
"keysym",
|
|
"keysym_string",
|
|
"key_sequence",
|
|
"key_press_history",
|
|
"key_timing",
|
|
] {
|
|
v2 = PttBanCheckVisitor::default();
|
|
v2.check(name);
|
|
assert!(v2.banned, "expected {name} to be banned");
|
|
}
|
|
|
|
// Allowed PTT fields stay safe.
|
|
let mut v3 = PttBanCheckVisitor::default();
|
|
for name in ["capability_level", "backend_id", "bound_input_class"] {
|
|
v3.check(name);
|
|
}
|
|
assert!(!v3.banned);
|
|
}
|
|
|
|
#[test]
|
|
fn ptt_banned_list_is_non_empty_and_stable() {
|
|
// Lightweight regression catch: the audit document
|
|
// REDACT-PTT-001..006 enumerates these exact names.
|
|
assert!(PTT_BANNED_FIELDS.contains(&"key_code"));
|
|
assert!(PTT_BANNED_FIELDS.contains(&"scan_code"));
|
|
assert!(PTT_BANNED_FIELDS.contains(&"virtual_key"));
|
|
assert!(PTT_BANNED_FIELDS.contains(&"keysym"));
|
|
assert!(PTT_BANNED_FIELDS.contains(&"key_sequence"));
|
|
// No accidental additions of safe field names.
|
|
for safe in ["capability_level", "backend_id", "bound_input_class"] {
|
|
assert!(!PTT_BANNED_FIELDS.contains(&safe));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ptt_sanitizer_drops_banned_records_before_inner_layer() {
|
|
// End-to-end test through a real tracing subscriber: a
|
|
// sanitiser-wrapped RedactingLogLayer must drop records
|
|
// that name banned fields and must forward records that
|
|
// do not. Uses `with_default` so the subscriber is scoped
|
|
// to the closure (no global state mutation across tests).
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
let secrets = KnownSecretRegistry::default();
|
|
let redactor = Redactor::with_secrets(secrets);
|
|
let sink = InMemoryLogSink::new(16, redactor);
|
|
let inner = RedactingLogLayer::new(sink.clone());
|
|
let sanitised = PttSanitizer::wrap(inner);
|
|
let subscriber = tracing_subscriber::registry().with(sanitised);
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::info!(key_code = 42, "banned record must drop");
|
|
tracing::info!(backend_id = "linux-portal", "safe record must pass");
|
|
});
|
|
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
|
|
let text = exported.to_text();
|
|
assert!(
|
|
!text.contains("banned record must drop"),
|
|
"sanitiser must have dropped the banned record"
|
|
);
|
|
assert!(
|
|
text.contains("safe record must pass"),
|
|
"sanitiser must forward the safe record to the inner layer"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn banned_field_f64_is_caught() {
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
|
|
let secrets = KnownSecretRegistry::default();
|
|
let redactor = Redactor::with_secrets(secrets);
|
|
let sink = InMemoryLogSink::new(16, redactor);
|
|
let inner = RedactingLogLayer::new(sink.clone());
|
|
let sanitised = PttSanitizer::wrap(inner);
|
|
let subscriber = tracing_subscriber::registry().with(sanitised);
|
|
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::info!(key_code = 42.5_f64, "f64 banned record must drop");
|
|
tracing::info!(backend_id = "linux-portal", "safe record must pass");
|
|
});
|
|
|
|
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
|
|
let text = exported.to_text();
|
|
assert!(!text.contains("f64 banned record must drop"));
|
|
assert!(text.contains("safe record must pass"));
|
|
}
|
|
|
|
#[test]
|
|
fn banned_field_i128_is_caught() {
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
|
|
let secrets = KnownSecretRegistry::default();
|
|
let redactor = Redactor::with_secrets(secrets);
|
|
let sink = InMemoryLogSink::new(16, redactor);
|
|
let inner = RedactingLogLayer::new(sink.clone());
|
|
let sanitised = PttSanitizer::wrap(inner);
|
|
let subscriber = tracing_subscriber::registry().with(sanitised);
|
|
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::info!(scan_code = 42_i128, "i128 banned record must drop");
|
|
tracing::info!(backend_id = "linux-portal", "safe record must pass");
|
|
});
|
|
|
|
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
|
|
let text = exported.to_text();
|
|
assert!(!text.contains("i128 banned record must drop"));
|
|
assert!(text.contains("safe record must pass"));
|
|
}
|
|
|
|
#[test]
|
|
fn banned_field_u128_is_caught() {
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
|
|
let secrets = KnownSecretRegistry::default();
|
|
let redactor = Redactor::with_secrets(secrets);
|
|
let sink = InMemoryLogSink::new(16, redactor);
|
|
let inner = RedactingLogLayer::new(sink.clone());
|
|
let sanitised = PttSanitizer::wrap(inner);
|
|
let subscriber = tracing_subscriber::registry().with(sanitised);
|
|
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::info!(virtual_key = 42_u128, "u128 banned record must drop");
|
|
tracing::info!(backend_id = "linux-portal", "safe record must pass");
|
|
});
|
|
|
|
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
|
|
let text = exported.to_text();
|
|
assert!(!text.contains("u128 banned record must drop"));
|
|
assert!(text.contains("safe record must pass"));
|
|
}
|
|
}
|