feat(core): redaction policy, Redacted<T>, and no-secrets-in-logs canary #378

Merged
KofTwentyTwo merged 1 commits from story/obs-7-redaction-canary into dev 2026-08-01 01:07:03 +00:00
KofTwentyTwo commented 2026-08-01 01:05:32 +00:00 (Migrated from github.com)

Closes #363 (OBS-7). Codifies the secret-safety discipline the observability program has been maintaining by hand, and adds an enforcement net so it can't silently regress. No proto change.

What this adds

1. Redaction policy (docs + code).

  • docs/LOGGING.md: the authoritative statement of what must NEVER be logged (message bodies/subjects, sender/recipient addresses, credentials, bearer tokens, keyring secret values, key material, vCard/iCal PII) vs what IS allowed (ids, counts, op/rule/transport kinds, folder names, hostnames, UIDs, request_id, and the keyring lookup key).
  • The same policy restated in the module doc of nuncio_core::redact.

2. Redacted<T> newtype (nuncio_core::redact).

  • Debug, Display, and Serialize all render <redacted>; inner value reachable only via explicit expose_secret() / into_inner(). Deserialize is transparent so containing structs still derive it; Serialize is deliberately lossy (guards the serde_json leak path).
  • Applied to the clearest reachable secret fields: CalDavAccountConfig::auth_token and CardDavAccountConfig::auth_token. Their hand-written redacting Debug impls are removed in favour of a derived Debug that is now leak-proof by construction. (AccountConfig.keyring_secret_key is intentionally left as-is: it is a vault lookup key / identifier, not the stored secret, and it is persisted — see policy doc.)

3. No-secrets canary (crates/nunciod/tests/no_secrets_canary_test.rs).
A single cross-crate workspace test that installs a capturing tracing subscriber, seeds sentinel secrets/PII, then drives — with mock backends and loopback wiremock only, no live network — and asserts NONE of the sentinels appear in captured telemetry:

  • gRPC auth reject (sentinel bearer token) + authenticated calls;
  • account add (sentinel password);
  • mock inbound sync carrying sentinel subject/body/sender/recipient;
  • real JMAP wiremock sync carrying the same content + sentinel credentials;
  • mark-read (outbox-affecting) and outbound send (sentinel content);
  • filter rule-fire on the sentinel message;
  • webhook dispatch signed with a sentinel HMAC key;
  • CalDAV + CardDAV sync authenticated with a sentinel token, carrying sentinel event/contact PII.

The test also asserts the log-emitting paths actually ran (webhook + rule-fire logged), so it can never pass by capturing nothing.

Real leak found?

No. The canary exercises the paths and all sentinels stay out of telemetry.

Gate (all green, 0 ignored)

  • cargo fmt --all -- --check — clean
  • cargo check-all (clippy --all-targets -D warnings) — clean
  • cargo test-all — 596 passed, 0 failed, 0 ignored

SSH-signed commit.

Closes #363 (OBS-7). Codifies the secret-safety discipline the observability program has been maintaining by hand, and adds an enforcement net so it can't silently regress. No proto change. ## What this adds **1. Redaction policy (docs + code).** - `docs/LOGGING.md`: the authoritative statement of what must NEVER be logged (message bodies/subjects, sender/recipient addresses, credentials, bearer tokens, keyring secret values, key material, vCard/iCal PII) vs what IS allowed (ids, counts, op/rule/transport kinds, folder names, hostnames, UIDs, request_id, and the keyring *lookup key*). - The same policy restated in the module doc of `nuncio_core::redact`. **2. `Redacted<T>` newtype (`nuncio_core::redact`).** - `Debug`, `Display`, and `Serialize` all render `<redacted>`; inner value reachable only via explicit `expose_secret()` / `into_inner()`. `Deserialize` is transparent so containing structs still derive it; `Serialize` is deliberately lossy (guards the serde_json leak path). - Applied to the clearest reachable secret fields: `CalDavAccountConfig::auth_token` and `CardDavAccountConfig::auth_token`. Their hand-written redacting `Debug` impls are removed in favour of a derived `Debug` that is now leak-proof by construction. (`AccountConfig.keyring_secret_key` is intentionally left as-is: it is a vault *lookup key* / identifier, not the stored secret, and it is persisted — see policy doc.) **3. No-secrets canary (`crates/nunciod/tests/no_secrets_canary_test.rs`).** A single cross-crate workspace test that installs a capturing `tracing` subscriber, seeds sentinel secrets/PII, then drives — with mock backends and loopback wiremock only, no live network — and asserts NONE of the sentinels appear in captured telemetry: - gRPC **auth reject** (sentinel bearer token) + authenticated calls; - **account add** (sentinel password); - **mock inbound sync** carrying sentinel subject/body/sender/recipient; - **real JMAP wiremock sync** carrying the same content + sentinel credentials; - **mark-read** (outbox-affecting) and **outbound send** (sentinel content); - **filter rule-fire** on the sentinel message; - **webhook dispatch** signed with a sentinel HMAC key; - **CalDAV + CardDAV sync** authenticated with a sentinel token, carrying sentinel event/contact PII. The test also asserts the log-emitting paths actually ran (webhook + rule-fire logged), so it can never pass by capturing nothing. ## Real leak found? No. The canary exercises the paths and all sentinels stay out of telemetry. ## Gate (all green, 0 ignored) - `cargo fmt --all -- --check` — clean - `cargo check-all` (clippy `--all-targets -D warnings`) — clean - `cargo test-all` — 596 passed, 0 failed, 0 ignored SSH-signed commit.
Sign in to join this conversation.