feat(core): redaction policy, Redacted<T>, and no-secrets-in-logs canary #378
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user