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
9 changed files with 826 additions and 44 deletions

No files matched your search

+11 -19
View File
@@ -2,6 +2,7 @@
use async_trait::async_trait;
use nuncio_core::model::CalendarEvent;
use nuncio_core::redact::Redacted;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, instrument, warn};
@@ -14,7 +15,7 @@ use crate::parser::{CalendarError, IcalParserAdapter};
/// `caldav_url` must already resolve to a specific calendar collection (e.g.
/// `https://caldav.example.com/dav/calendars/user/jmaes/work/`) -- PROPFIND-based
/// `calendar-home-set` auto-discovery is out of scope for this client.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CalDavAccountConfig {
/// Nuncio account identifier this calendar collection belongs to.
pub account_id: String,
@@ -23,22 +24,10 @@ pub struct CalDavAccountConfig {
/// Basic-auth username (or app-specific username, per provider).
pub username: String,
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
/// caller -- never stored anywhere else in plaintext.
pub auth_token: String,
}
impl std::fmt::Debug for CalDavAccountConfig {
/// Manual `Debug` impl that redacts `auth_token` -- a derived impl would print the raw
/// secret verbatim the moment anything (a `debug!` log, an assertion failure message,
/// a panic payload) formats this config with `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CalDavAccountConfig")
.field("account_id", &self.account_id)
.field("caldav_url", &self.caldav_url)
.field("username", &self.username)
.field("auth_token", &"<redacted>")
.finish()
}
/// caller -- never stored anywhere else in plaintext. Wrapped in [`Redacted`] so a derived
/// `Debug`, a `Display`, or `serde` can never disclose it to a log; the raw value is
/// reachable only through an explicit `expose_secret` call.
pub auth_token: Redacted<String>,
}
/// Best-effort extraction of the `UID` property from a raw (possibly malformed) VEVENT/
@@ -202,7 +191,10 @@ impl CalDavClient {
.request(report_method, &self.config.caldav_url)
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.basic_auth(&self.config.username, Some(&self.config.auth_token))
.basic_auth(
&self.config.username,
Some(self.config.auth_token.expose_secret()),
)
.body(body)
.send()
.await
@@ -258,7 +250,7 @@ mod tests {
account_id: "acct-1".to_string(),
caldav_url: "https://caldav.example.com/calendars/work/".to_string(),
username: "jmaes".to_string(),
auth_token: "app-token-secret".to_string(),
auth_token: Redacted::new("app-token-secret".to_string()),
}
}
@@ -60,7 +60,7 @@ async fn wiremock_caldav_report_fetches_and_resolves_tzid_events() {
account_id: "acct-wm-1".to_string(),
caldav_url: format!("{}/calendars/work/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
let client = CalDavClient::new(config);
@@ -106,7 +106,7 @@ async fn wiremock_caldav_report_server_error_surfaces_as_transport_failure() {
account_id: "acct-wm-2".to_string(),
caldav_url: format!("{}/calendars/broken/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
let client = CalDavClient::new(config);
+11 -19
View File
@@ -1,6 +1,7 @@
//! CardDAV (RFC 6352) `REPORT` client and multistatus response parser.
use async_trait::async_trait;
use nuncio_core::redact::Redacted;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, info, instrument};
@@ -28,7 +29,7 @@ pub enum CardDavError {
/// `carddav_url` must already resolve to a specific address book collection (e.g.
/// `https://carddav.example.com/dav/addressbooks/user/jmaes/contacts/`) -- PROPFIND-based
/// `addressbook-home-set` auto-discovery is out of scope for this client.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CardDavAccountConfig {
/// Nuncio account identifier this address book collection belongs to.
pub account_id: String,
@@ -37,22 +38,10 @@ pub struct CardDavAccountConfig {
/// Basic-auth username (or app-specific username, per provider).
pub username: String,
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
/// caller -- never stored anywhere else in plaintext.
pub auth_token: String,
}
impl std::fmt::Debug for CardDavAccountConfig {
/// Manual `Debug` impl that redacts `auth_token` -- a derived impl would print the raw
/// secret verbatim the moment anything (a `debug!` log, an assertion failure message,
/// a panic payload) formats this config with `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CardDavAccountConfig")
.field("account_id", &self.account_id)
.field("carddav_url", &self.carddav_url)
.field("username", &self.username)
.field("auth_token", &"<redacted>")
.finish()
}
/// caller -- never stored anywhere else in plaintext. Wrapped in [`Redacted`] so a derived
/// `Debug`, a `Display`, or `serde` can never disclose it to a log; the raw value is
/// reachable only through an explicit `expose_secret` call.
pub auth_token: Redacted<String>,
}
/// CardDAV client protocol engine managing address book `REPORT` queries against a real server.
@@ -134,7 +123,10 @@ impl CardDavClient {
.request(report_method, &self.config.carddav_url)
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.basic_auth(&self.config.username, Some(&self.config.auth_token))
.basic_auth(
&self.config.username,
Some(self.config.auth_token.expose_secret()),
)
.body(body)
.send()
.await
@@ -186,7 +178,7 @@ mod tests {
account_id: "acct-1".to_string(),
carddav_url: "https://carddav.example.com/addressbooks/contacts/".to_string(),
username: "jmaes".to_string(),
auth_token: "app-token-secret".to_string(),
auth_token: Redacted::new("app-token-secret".to_string()),
}
}
@@ -57,7 +57,7 @@ async fn wiremock_carddav_report_fetches_and_parses_vcards() {
account_id: "acct-wm-1".to_string(),
carddav_url: format!("{}/addressbooks/contacts/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
let client = CardDavClient::new(config);
@@ -102,7 +102,7 @@ async fn wiremock_carddav_report_server_error_surfaces_as_transport_failure() {
account_id: "acct-wm-2".to_string(),
carddav_url: format!("{}/addressbooks/broken/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
let client = CardDavClient::new(config);
@@ -143,7 +143,7 @@ END:VCARD</card:address-data>
account_id: "acct-wm-3".to_string(),
carddav_url: format!("{}/addressbooks/malformed/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
let client = CardDavClient::new(config);
+2
View File
@@ -7,6 +7,7 @@ pub mod config;
pub mod export;
pub mod mcp_policy;
pub mod model;
pub mod redact;
pub mod update;
pub mod worm_audit;
@@ -17,6 +18,7 @@ pub use config::{
pub use export::{ExportEngine, ExportError, ExportFormat, ExportSummary};
pub use mcp_policy::{AgentPermissions, DataType, McpAgentPolicy};
pub use model::{Attachment, CalendarEvent, Contact, DaemonTelemetry, Email, Folder};
pub use redact::Redacted;
pub use update::{ReleaseInfo, UpdateCheckResult, UpdateEngine, UpdateError};
pub use worm_audit::{verify_worm_chain, WormAuditError, WormAuditRecord};
+203
View File
@@ -0,0 +1,203 @@
//! Secret-safe value wrapper and the logging redaction policy it enforces.
//!
//! # Logging redaction policy
//!
//! Telemetry (`tracing` spans, events, and any `Debug`/`Display`/`serde`
//! rendering that can reach a log sink) is a plaintext side channel. The
//! following values MUST NEVER appear in it, in any encoding:
//!
//! - message bodies, subjects, and snippets;
//! - sender/recipient email addresses and any other message correspondents;
//! - account credentials (passwords, app-specific passwords/tokens);
//! - gRPC bearer tokens;
//! - keyring secret *values* (the material stored in the OS vault) and any
//! derived key material (encryption keys, HMAC signing keys);
//! - vCard / iCal personal data (contact names, phones, addresses; event
//! summaries, locations, attendees, descriptions).
//!
//! The following identifying, non-sensitive values ARE allowed in telemetry,
//! because operating the daemon is impossible without them and none of them
//! reveals content or grants access:
//!
//! - opaque ids (account id, message id, rule id, folder id) and UIDs;
//! - counts, sizes, and durations;
//! - operation / rule / transport / action *kinds* (the discriminant, never
//! its payload);
//! - folder names, hostnames, ports, and the per-request `request_id`;
//! - the keyring *lookup key* (e.g. `nuncio/<account-id>`) — an identifier
//! that names a vault entry but is not itself the stored secret.
//!
//! [`Redacted<T>`] is the mechanical enforcement point for the first list: a
//! field whose type is `Redacted<T>` cannot leak through a derived `Debug`, a
//! `Display`, or `serde` serialization, because each of those renders a fixed
//! placeholder instead of the inner value. The secret is reachable only via an
//! explicit [`Redacted::expose_secret`] / [`Redacted::into_inner`] call, which
//! is easy to audit and never happens implicitly.
use std::fmt;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
/// The single placeholder rendered wherever a redacted value would otherwise
/// appear (`Debug`, `Display`, and `serde`).
const PLACEHOLDER: &str = "<redacted>";
/// A wrapper for a sensitive value that never renders its contents through
/// `Debug`, `Display`, or `serde`.
///
/// Wrapping a credential/token/key-material field in `Redacted<T>` makes it
/// impossible to leak that field into telemetry by accident: the compiler still
/// lets a struct derive `Debug`/`Serialize`, but the wrapped field renders as
/// `<redacted>`. Reaching the real value is deliberate and greppable — it
/// requires calling [`expose_secret`](Self::expose_secret) or
/// [`into_inner`](Self::into_inner).
///
/// Equality is derived over the inner value so wrapped fields remain comparable
/// in tests and config diffs; it is intentionally not constant-time and is not
/// meant for authenticating a secret against an attacker-supplied guess.
#[derive(Clone, PartialEq, Eq)]
pub struct Redacted<T>(T);
impl<T> Redacted<T> {
/// Wrap `value` so it is protected from accidental disclosure in telemetry.
pub fn new(value: T) -> Self {
Self(value)
}
/// Borrow the wrapped secret. Naming the call `expose_secret` keeps every
/// deliberate disclosure of the value greppable and reviewable.
pub fn expose_secret(&self) -> &T {
&self.0
}
/// Consume the wrapper and return the owned secret.
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> From<T> for Redacted<T> {
fn from(value: T) -> Self {
Self(value)
}
}
/// Renders `<redacted>` regardless of the inner value, so a derived `Debug` on
/// any containing struct can never print the secret.
impl<T> fmt::Debug for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(PLACEHOLDER)
}
}
/// Renders `<redacted>`, so interpolating the value with `{}` (e.g. into a
/// log message or error) discloses nothing.
impl<T> fmt::Display for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(PLACEHOLDER)
}
}
/// Serializes the placeholder, never the inner value. This closes the
/// `serde_json`/structured-logging leak path: even serializing a whole struct
/// that contains a `Redacted<T>` field emits `<redacted>` for it. As a result
/// serialization is deliberately lossy — a `Redacted<T>` must never be used for
/// a field whose real value has to survive a serialization round trip.
impl<T> Serialize for Redacted<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(PLACEHOLDER)
}
}
/// Deserializes transparently into `Redacted<T>` by deserializing the inner
/// `T`. This exists so structs that hold a `Redacted<T>` can still derive
/// `Deserialize`; it does not reverse [`Serialize`], which is lossy by design.
impl<'de, T> Deserialize<'de> for Redacted<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Self(T::deserialize(deserializer)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SENTINEL: &str = "super-secret-app-token-9f3a";
#[test]
fn debug_renders_placeholder_not_the_secret() {
let wrapped = Redacted::new(SENTINEL.to_string());
let rendered = format!("{wrapped:?}");
assert_eq!(rendered, PLACEHOLDER);
assert!(!rendered.contains(SENTINEL));
}
#[test]
fn display_renders_placeholder_not_the_secret() {
let wrapped = Redacted::new(SENTINEL.to_string());
let rendered = format!("{wrapped}");
assert_eq!(rendered, PLACEHOLDER);
assert!(!rendered.contains(SENTINEL));
}
#[test]
fn derived_debug_of_a_containing_struct_does_not_leak() {
// Fields are exercised only through the derived `Debug`, which
// dead-code analysis deliberately ignores; the test asserts on that
// rendering, so silence the false-positive unread-field lint.
#[derive(Debug)]
#[allow(dead_code)]
struct Config {
username: String,
auth_token: Redacted<String>,
}
let cfg = Config {
username: "jmaes".to_string(),
auth_token: Redacted::new(SENTINEL.to_string()),
};
let rendered = format!("{cfg:?}");
assert!(rendered.contains("jmaes"));
assert!(!rendered.contains(SENTINEL));
assert!(rendered.contains(PLACEHOLDER));
}
#[test]
fn serialize_emits_placeholder_not_the_secret() {
let wrapped = Redacted::new(SENTINEL.to_string());
let json = serde_json::to_string(&wrapped).expect("serialize");
assert!(!json.contains(SENTINEL));
assert_eq!(json, format!("\"{PLACEHOLDER}\""));
}
#[test]
fn expose_and_into_inner_return_the_real_value() {
let wrapped = Redacted::new(SENTINEL.to_string());
assert_eq!(wrapped.expose_secret(), SENTINEL);
assert_eq!(wrapped.into_inner(), SENTINEL);
}
#[test]
fn deserialize_wraps_the_inner_value() {
let wrapped: Redacted<String> =
serde_json::from_str(&format!("\"{SENTINEL}\"")).expect("deserialize");
assert_eq!(wrapped.expose_secret(), SENTINEL);
}
#[test]
fn from_and_equality() {
let a: Redacted<String> = SENTINEL.to_string().into();
let b = Redacted::new(SENTINEL.to_string());
assert_eq!(a, b);
assert_ne!(a, Redacted::new("other".to_string()));
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ pub async fn sync_caldav_account(
account_id: account.id.clone(),
caldav_url: account.dav_collection_url().unwrap_or_default().to_string(),
username: account.email_address.clone(),
auth_token: password.to_string(),
auth_token: nuncio_core::redact::Redacted::new(password.to_string()),
});
sync_with_backend(db, &client, calendar_id, start_window, end_window).await
}
@@ -0,0 +1,537 @@
//! Cross-crate "no secrets in logs" canary.
//!
//! This is the regression net for the logging redaction policy documented in
//! `nuncio_core::redact`. It installs a capturing `tracing` subscriber, seeds a
//! set of unmistakable SENTINEL secrets/PII, then drives a representative slice
//! of the now-instrumented code paths across the daemon and the library crates
//! using only mock backends and loopback wiremock servers (no live network):
//!
//! - gRPC auth reject (a wrong bearer token) and authenticated calls;
//! - an account add carrying an account password;
//! - a mock inbound mail sync carrying a sentinel subject/body/sender/recipient;
//! - a real JMAP protocol sync (wiremock) carrying the same sentinel content
//! plus sentinel credentials;
//! - an outbox-affecting mark-read and an outbound send carrying sentinel content;
//! - a filter rule firing on a sentinel message;
//! - a webhook dispatch signed with a sentinel HMAC key;
//! - a CalDAV and a CardDAV sync authenticated with a sentinel token and
//! carrying sentinel event/contact PII.
//!
//! It then asserts that NONE of the sentinel strings appear anywhere in the
//! captured telemetry, while also proving the log-emitting paths actually ran
//! (so the test can never pass by capturing nothing).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::{Arc, Mutex};
use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend};
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, ContactsBackend};
use nuncio_core::model::{Email, Folder};
use nuncio_core::EventBus;
use nuncio_filter::{FilterEngine, NsqlParser, ValidationOptions, WebhookDispatcher};
use nuncio_mail::{JmapEngine, MailBackend, MockMailBackend, MockMessageSender};
use nuncio_proto::v1::{
AccountConfig, AddAccountRequest, GetStatusRequest, MarkReadRequest, SendMessageRequest,
SyncRequest, TlsMode,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
ContactsEngineOverrides, MailEngineOverrides,
};
use tokio::net::TcpListener;
use tracing::field::{Field, Visit};
use tracing::span::Attributes;
use tracing::{Event, Id, Subscriber};
use tracing_subscriber::layer::Context as LayerContext;
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// ---- Sentinels: none of these may EVER appear in captured telemetry. ----
const SENTINEL_BEARER: &str = "SENTINELbearerTokenAaBbCc001";
const SENTINEL_PASSWORD: &str = "SENTINELaccountPasswordDdEe002";
const SENTINEL_SUBJECT: &str = "SENTINELmessageSubjectFfGg003";
const SENTINEL_BODY: &str = "SENTINELmessageBodyHhIi004";
const SENTINEL_SENDER: &str = "sentinel-sender-jjkk005@example.invalid";
const SENTINEL_RECIPIENT: &str = "sentinel-recipient-llmm006@example.invalid";
const SENTINEL_WEBHOOK_KEY: &str = "SENTINELwebhookHmacKeyNnOo007";
const SENTINEL_DAV_TOKEN: &str = "SENTINELdavAuthTokenPpQq008";
const SENTINEL_EVENT_SUMMARY: &str = "SENTINELeventSummaryRrSs009";
const SENTINEL_CONTACT_NAME: &str = "SENTINELcontactNameTtUu010";
const ACCOUNT_ID: &str = "acct-canary-1";
const ACCOUNT_EMAIL: &str = "canary@nuncio.mx";
const KEYRING_KEY: &str = "nuncio/acct-canary-1";
/// Thread-safe sink recording every rendered field value from every captured
/// span and event, so a single global subscriber can collect telemetry emitted
/// on any daemon/runtime thread.
#[derive(Default)]
struct Sink(Mutex<Vec<String>>);
impl Sink {
fn push_all(&self, values: Vec<String>) {
if let Ok(mut guard) = self.0.lock() {
guard.extend(values);
}
}
fn snapshot(&self) -> Vec<String> {
self.0.lock().map(|g| g.clone()).unwrap_or_default()
}
}
struct FieldVisitor(Vec<String>);
impl Visit for FieldVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.0.push(format!("{}={}", field.name(), value));
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.0.push(format!("{}={:?}", field.name(), value));
}
}
struct CaptureLayer(Arc<Sink>);
impl<S> tracing_subscriber::Layer<S> for CaptureLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: LayerContext<'_, S>) {
let mut visitor = FieldVisitor(vec![format!("span={}", attrs.metadata().name())]);
attrs.record(&mut visitor);
self.0.push_all(visitor.0);
}
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
let mut visitor = FieldVisitor(vec![format!("event={}", event.metadata().target())]);
event.record(&mut visitor);
self.0.push_all(visitor.0);
}
}
fn sample_proto_config() -> AccountConfig {
AccountConfig {
id: ACCOUNT_ID.to_string(),
name: "Canary Account".to_string(),
email_address: ACCOUNT_EMAIL.to_string(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval: Some(nuncio_proto::time::duration_from_secs(60)),
transport: Some(nuncio_proto::v1::account_config::Transport::ImapSmtp(
nuncio_proto::v1::ImapSmtpTransport {
imap_host: "imap.nuncio.mx".to_string(),
imap_port: 993,
imap_tls_mode: TlsMode::ImplicitTls.into(),
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
smtp_tls_mode: TlsMode::ImplicitTls.into(),
},
)),
}
}
fn sentinel_inbound_email() -> Email {
Email {
id: "msg-canary-1".to_string(),
account_id: ACCOUNT_ID.to_string(),
folder_id: "inbox".to_string(),
remote_id: "msg-canary-1".to_string(),
uid_validity: "1".to_string(),
subject: SENTINEL_SUBJECT.to_string(),
sender: SENTINEL_SENDER.to_string(),
recipient: SENTINEL_RECIPIENT.to_string(),
received_at: 1_700_000_000,
read: false,
body_plain: Some(SENTINEL_BODY.to_string()),
body_html: None,
attachments: Vec::new(),
}
}
/// Boots the real daemon gRPC server (all services, bearer-authenticated) on an
/// ephemeral loopback port, backed by a real temp file DB and the given mail
/// overrides + filter engine.
async fn boot_daemon(
secrets: Arc<SecretManager>,
overrides: MailEngineOverrides,
filter_engine: Arc<FilterEngine>,
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir for db");
let db_path = dir.path().join("canary.db");
let db = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open temp file db"),
);
let token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mint bearer token from mock vault"),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has addr");
let event_bus = Arc::new(EventBus::new());
let server_secrets = secrets.clone();
let server_token = token.clone();
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
filter_engine,
server_secrets,
server_token,
overrides,
CalendarEngineOverrides::default(),
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await;
});
(addr, token, dir)
}
/// Drives the daemon over authenticated gRPC: an auth-reject with a sentinel
/// bearer token, an account add with a sentinel password, an inbound sync
/// carrying sentinel content, a mark-read, and an outbound send.
async fn drive_daemon() {
let secrets = Arc::new(SecretManager::mock());
let mock_backend = MockMailBackend::new();
mock_backend.add_folder(Folder {
id: "inbox".to_string(),
name: "Inbox".to_string(),
total_messages: 1,
unread_messages: 1,
});
mock_backend.add_message(sentinel_inbound_email());
let mock_sender = MockMessageSender::new();
let overrides = MailEngineOverrides {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: Some(Arc::new(mock_sender)),
};
// A rule that fires on the sentinel subject, so the daemon's filter path is
// exercised against sentinel content during any evaluation it performs.
let rule = NsqlParser::parse_rule(
"Canary Rule",
1,
&format!("WHERE subject CONTAINS '{SENTINEL_SUBJECT}' ACTION MARK READ"),
)
.expect("parse canary rule");
let filter_engine = Arc::new(FilterEngine::new(vec![rule]).expect("compile rule"));
let (addr, token, _db_dir) = boot_daemon(secrets, overrides, filter_engine).await;
let addr_str = addr.to_string();
// (1) Auth reject: a call bearing the WRONG (sentinel) token is rejected,
// and that token must never be echoed to a log.
let mut unauth =
nuncio_proto::v1::system_client::SystemClient::connect(format!("http://{addr}"))
.await
.expect("system client connects");
let mut bad = tonic::Request::new(GetStatusRequest {});
bad.metadata_mut().insert(
"authorization",
format!("Bearer {SENTINEL_BEARER}")
.parse()
.expect("ascii metadata"),
);
let err = unauth
.get_status(bad)
.await
.expect_err("wrong bearer token must be rejected");
assert_eq!(err.code(), tonic::Code::Unauthenticated);
let mut accounts = nuncio_proto::client::connect_accounts(&addr_str, &token)
.await
.expect("accounts client");
let mut mail = nuncio_proto::client::connect_mail(&addr_str, &token)
.await
.expect("mail client");
// (2) Account add carrying a sentinel password.
accounts
.add_account(AddAccountRequest {
config: Some(sample_proto_config()),
password: SENTINEL_PASSWORD.to_string(),
})
.await
.expect("add_account succeeds");
// (3) Inbound sync over the mock backend carrying sentinel content.
let synced = mail
.sync(SyncRequest { account_id: None })
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(synced.synced_count, 1);
// (4) Outbox-affecting mark-read.
mail.mark_read(MarkReadRequest {
message_id: "msg-canary-1".to_string(),
read: true,
})
.await
.expect("mark_read succeeds");
// (5) Outbound send carrying sentinel content.
mail.send_message(SendMessageRequest {
to: SENTINEL_RECIPIENT.to_string(),
cc: None,
subject: SENTINEL_SUBJECT.to_string(),
body_text: SENTINEL_BODY.to_string(),
body_html: None,
attachments: Vec::new(),
account_id: None,
})
.await
.expect("send_message succeeds");
}
/// Drives the filter engine (a rule fires on the sentinel email) and a webhook
/// dispatch signed with a sentinel HMAC key against a loopback wiremock server.
async fn drive_filter_and_webhook() {
let rule = NsqlParser::parse_rule(
"Canary Webhook Rule",
1,
&format!("WHERE subject CONTAINS '{SENTINEL_SUBJECT}' ACTION MARK READ"),
)
.expect("parse rule");
let engine = FilterEngine::new(vec![rule]).expect("compile rule");
let matches = engine.evaluate(&sentinel_inbound_email());
assert_eq!(matches.len(), 1, "rule must fire on the sentinel message");
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let dispatcher = WebhookDispatcher::new(SENTINEL_WEBHOOK_KEY);
let opts = ValidationOptions {
available_folders: None,
allowed_forward_domains: None,
block_private_webhooks: false,
};
let url = format!("{}/hook", mock_server.uri());
let status = dispatcher
.dispatch_with_options(
&url,
"rule-canary",
"msg-canary-1",
SENTINEL_SUBJECT,
SENTINEL_SENDER,
&opts,
)
.await
.expect("webhook dispatch to loopback mock succeeds");
assert_eq!(status, 200);
}
/// Drives a CalDAV and a CardDAV sync authenticated with the sentinel token and
/// carrying sentinel PII, against loopback wiremock servers.
async fn drive_dav() {
// CalDAV.
let cal_server = MockServer::start().await;
let cal_body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/calendars/work/e.ics</d:href>
<d:propstat><d:prop><c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:{SENTINEL_EVENT_SUMMARY}
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data></d:prop></d:propstat>
</d:response>
</d:multistatus>"#
);
Mock::given(method("REPORT"))
.and(path("/calendars/work/"))
.respond_with(ResponseTemplate::new(207).set_body_string(cal_body))
.mount(&cal_server)
.await;
let cal_client = CalDavClient::new(CalDavAccountConfig {
account_id: ACCOUNT_ID.to_string(),
caldav_url: format!("{}/calendars/work/", cal_server.uri()),
username: ACCOUNT_EMAIL.to_string(),
auth_token: SENTINEL_DAV_TOKEN.to_string().into(),
});
let events =
CalendarBackend::fetch_events(&cal_client, "cal-work", 1_700_000_000, 1_800_000_000)
.await
.expect("caldav fetch succeeds");
assert_eq!(events.len(), 1);
// CardDAV.
let card_server = MockServer::start().await;
let card_body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/addressbooks/contacts/c.vcf</d:href>
<d:propstat><d:prop><card:address-data>BEGIN:VCARD
VERSION:4.0
FN:{SENTINEL_CONTACT_NAME}
EMAIL:{SENTINEL_SENDER}
END:VCARD</card:address-data></d:prop></d:propstat>
</d:response>
</d:multistatus>"#
);
Mock::given(method("REPORT"))
.and(path("/addressbooks/contacts/"))
.respond_with(ResponseTemplate::new(207).set_body_string(card_body))
.mount(&card_server)
.await;
let card_client = CardDavClient::new(CardDavAccountConfig {
account_id: ACCOUNT_ID.to_string(),
carddav_url: format!("{}/addressbooks/contacts/", card_server.uri()),
username: ACCOUNT_EMAIL.to_string(),
auth_token: SENTINEL_DAV_TOKEN.to_string().into(),
});
let contacts = ContactsBackend::fetch_contacts(&card_client, ACCOUNT_ID)
.await
.expect("carddav fetch succeeds");
assert_eq!(contacts.len(), 1);
}
/// Drives a real JMAP protocol sync (session discovery + Mailbox/get +
/// Email/query + Email/get) with sentinel credentials, carrying sentinel
/// content in the fetched message.
async fn drive_jmap() {
let server = MockServer::start().await;
let session_body = serde_json::json!({
"username": "canary@nuncio.mx",
"primaryAccounts": { "urn:ietf:params:jmap:mail": "acct-100" },
"apiUrl": format!("{}/jmap/api", server.uri()),
"state": "session-state-1"
});
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(&session_body))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/jmap/api"))
.and(body_partial_json(serde_json::json!({
"methodCalls": [["Mailbox/get", {}, "c1"]]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"methodResponses": [["Mailbox/get", {
"accountId": "acct-100",
"list": [{"id": "mb-inbox", "name": "Inbox", "totalEmails": 1, "unreadEmails": 1}]
}, "c1"]]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/jmap/api"))
.and(body_partial_json(serde_json::json!({
"methodCalls": [["Email/query", {}, "c1"]]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"methodResponses": [["Email/query", {"ids": ["msg-jmap-1"]}, "c1"]]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/jmap/api"))
.and(body_partial_json(serde_json::json!({
"methodCalls": [["Email/get", {}, "c1"]]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"methodResponses": [["Email/get", {
"accountId": "acct-100",
"state": "sync-state-1",
"list": [{
"id": "msg-jmap-1",
"subject": SENTINEL_SUBJECT,
"from": [{ "email": SENTINEL_SENDER }],
"to": [{ "email": SENTINEL_RECIPIENT }],
"receivedAt": 1700002000i64,
"isUnread": true,
"bodySnippet": SENTINEL_BODY
}]
}, "c1"]]
})))
.mount(&server)
.await;
let engine =
JmapEngine::with_credentials("acct-100", &server.uri(), ACCOUNT_EMAIL, SENTINEL_PASSWORD);
let folders = engine.sync_folders().await.expect("jmap sync_folders");
assert_eq!(folders.len(), 1);
let (emails, _state) = engine
.sync_messages("mb-inbox", None)
.await
.expect("jmap sync_messages");
assert_eq!(emails.len(), 1);
}
#[tokio::test]
async fn no_sentinel_secret_appears_in_any_captured_telemetry() {
let sink = Arc::new(Sink::default());
let subscriber = tracing_subscriber::registry().with(CaptureLayer(sink.clone()));
tracing::subscriber::set_global_default(subscriber)
.expect("install the global capturing subscriber");
drive_daemon().await;
drive_filter_and_webhook().await;
drive_dav().await;
drive_jmap().await;
let captured = sink.snapshot();
// Prove the log-emitting paths actually ran: the test must never pass by
// capturing nothing.
assert!(
!captured.is_empty(),
"no telemetry captured -- the canary exercised nothing"
);
let haystack = captured.join("\n");
assert!(
haystack.contains("webhook"),
"expected the webhook dispatch path to have logged; got:\n{haystack}"
);
assert!(
haystack.contains("rule matched") || haystack.contains("dispatching actions"),
"expected the filter rule-fire path to have logged; got:\n{haystack}"
);
// The core assertion: no sentinel secret or PII may appear anywhere.
let sentinels = [
("bearer token", SENTINEL_BEARER),
("account password", SENTINEL_PASSWORD),
("message subject", SENTINEL_SUBJECT),
("message body", SENTINEL_BODY),
("sender address", SENTINEL_SENDER),
("recipient address", SENTINEL_RECIPIENT),
("webhook signing key", SENTINEL_WEBHOOK_KEY),
("DAV auth token", SENTINEL_DAV_TOKEN),
("calendar event summary", SENTINEL_EVENT_SUMMARY),
("contact name", SENTINEL_CONTACT_NAME),
];
for (label, sentinel) in sentinels {
let leaked: Vec<&String> = captured
.iter()
.filter(|line| line.contains(sentinel))
.collect();
assert!(
leaked.is_empty(),
"SECRET LEAK: the {label} sentinel appeared in telemetry: {leaked:?}"
);
}
}
+56
View File
@@ -0,0 +1,56 @@
# Logging & redaction policy
Nuncio's telemetry (`tracing` spans and events, plus anything that reaches a log
sink through a `Debug` / `Display` / `serde` rendering) is a plaintext side
channel. This document is the authoritative statement of what may and may not
appear in it. It is enforced mechanically by two things:
- **`nuncio_core::redact::Redacted<T>`** — a newtype whose `Debug`, `Display`,
and `serde` all render the fixed placeholder `<redacted>`, so a secret field
wrapped in it cannot leak even through a *derived* `Debug` on the containing
struct. The inner value is reachable only via an explicit, greppable
`expose_secret()` / `into_inner()` call. The module doc comment restates this
policy next to the type.
- **the no-secrets canary** — `crates/nunciod/tests/no_secrets_canary_test.rs`,
a workspace test that drives a representative slice of the instrumented code
paths with known sentinel secrets and asserts none of them appear in captured
telemetry. It is the regression net: if a future change starts logging a
secret, this test fails.
## MUST NEVER be logged (in any encoding)
- Message bodies, subjects, and snippets.
- Sender / recipient email addresses and any other message correspondents.
- Account credentials: passwords and app-specific passwords/tokens.
- gRPC bearer tokens.
- Keyring secret **values** (the material stored in the OS vault) and any
derived key material (encryption keys, HMAC signing keys).
- vCard / iCal personal data: contact names, phone numbers, addresses; event
summaries, locations, attendees, and descriptions.
## MAY be logged (identifying but non-sensitive)
Operating the daemon is impossible without correlation identifiers, and none of
the following reveals content or grants access:
- Opaque ids: account id, message id, rule id, folder id — and UIDs.
- Counts, sizes, and durations.
- Operation / rule / transport / action **kinds** (the discriminant only, never
its payload).
- Folder names, hostnames, ports, and the per-request `request_id`.
- The keyring **lookup key** (e.g. `nuncio/<account-id>`) — an identifier that
names a vault entry but is not itself the stored secret.
## Guidance for contributors
- Prefer logging an **id or a kind** over the value it points at. If you need to
correlate a log line with a message/event/contact, log its id/UID, never its
content.
- When a struct must hold a credential/token/key, make the field type
`Redacted<T>` rather than relying on a hand-written `Debug`. Reach the value
only at the point of use, via `expose_secret()`.
- Never interpolate a whole config/request struct into a log or error with
`{:?}` unless every sensitive field it holds is `Redacted<T>` (or otherwise
self-redacting).
- If you add an instrumented path that could plausibly touch any of the
"MUST NEVER" values, extend the canary to drive it with a sentinel.