Skip to main content

trusty_memory/commands/
audit_secrets.rs

1//! `trusty-memory audit secrets --count-only` — count stored drawers the
2//! current secret filter would refuse.
3//!
4//! Why: #8645. `check_secret` runs at write time only, so a drawer stored
5//! before a filter fix is never re-screened. trusty-common 0.52.3 (#8589)
6//! started screening the VALUE half of a `KEY=value` token; drawers written
7//! earlier may hold values the filter now refuses. This command measures how
8//! many, without printing any of them — remediation is a separate,
9//! owner-gated step.
10//! What: enumerates palaces the way `kg-rebuild` does
11//! (`PalaceRegistry::list_palaces`), reads each drawer table through
12//! [`with_store_copy`] (a private copy of `kg.redb`, never the live file), runs
13//! `check_secret` on each drawer's content in memory, and reports counts only.
14//! The `PotentialSecret { token }` preview is matched away at the call site
15//! and never stored, logged or printed.
16//!
17//! Daemon safety: the live store is only ever `std::fs::copy`-read. No redb
18//! open, lock, WAL or rename touches it, so a running daemon neither blocks the
19//! scan nor sees it. `ReadOnlyRedb` was rejected: its live variant takes a
20//! shared lock, and a daemon reopening an idle-evicted palace during the scan
21//! would get `DatabaseAlreadyOpen` and fail its writes loud. The cost of the
22//! copy is a point-in-time read: a copy torn by a concurrent commit fails for
23//! that palace and is reported as an error. Anything the scan could not see —
24//! an unreadable store, an undecodable drawer row — fails the run.
25//! Test: `counts_refusals_per_palace_and_variant`,
26//! `output_and_tracing_carry_no_drawer_content`,
27//! `undecodable_drawer_row_is_counted_and_fails_the_run`,
28//! `scan_leaves_palace_files_byte_identical_under_a_live_writer`.
29
30use std::future::Future;
31use std::io::Write;
32use std::path::{Path, PathBuf};
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Arc;
35
36use anyhow::{Context, Result};
37use clap::{Args, Subcommand};
38use serde::Serialize;
39use trusty_common::memory_core::filter::{check_secret, FilterReject};
40use trusty_common::memory_core::PalaceRegistry;
41
42use super::store_snapshot::{sweep_stale_copies, with_store_copy, SCRATCH_PREFIX, STALE_COPY_AGE};
43
44/// Arguments of `trusty-memory audit` (#8645).
45///
46/// Why: lives beside its handler, as `PalaceAction` does, so `main.rs` stays
47/// under the SLOC cap — `main.rs` holds only the newtype variant.
48/// What: wraps the required [`AuditAction`] subcommand.
49/// Test: `count_only_flag_is_required`.
50#[derive(Debug, Args)]
51pub struct AuditArgs {
52    #[command(subcommand)]
53    pub action: AuditAction,
54}
55
56/// Actions under `trusty-memory audit` (#8645).
57///
58/// What: `Secrets` is the only action and is read-only.
59/// Test: `count_only_flag_is_required`.
60#[derive(Debug, Subcommand)]
61pub enum AuditAction {
62    /// Count stored drawers the current secret filter would refuse.
63    ///
64    /// READ-ONLY and COUNT ONLY. Each palace's store is copied to a private
65    /// temp dir and read from the copy, so this is safe with the daemon
66    /// running and writes nothing to any palace. No drawer text, id or token
67    /// preview is printed — only counts per palace and per refusal kind.
68    ///
69    ///   trusty-memory audit secrets --count-only
70    ///   trusty-memory audit secrets --count-only --palace trusty-tools --json
71    Secrets {
72        /// Required. Names the only mode: counts, never content.
73        #[arg(long = "count-only", required = true)]
74        count_only: bool,
75        /// Restrict the scan to one palace id.
76        #[arg(long, value_name = "ID")]
77        palace: Option<String>,
78        /// Emit JSON instead of text.
79        #[arg(long)]
80        json: bool,
81    },
82}
83
84/// Run one [`AuditAction`].
85///
86/// What: one arm per variant; `count_only` is enforced by clap.
87/// Test: `count_only_flag_is_required` (parsing); the handler is covered
88/// through [`scan_palaces`] and [`render`].
89pub async fn dispatch(args: AuditArgs) -> Result<()> {
90    match args.action {
91        AuditAction::Secrets { palace, json, .. } => {
92            handle_audit_secrets(AuditSecretsOptions { palace, json }).await
93        }
94    }
95}
96
97/// What one `audit secrets` invocation was asked to do.
98#[derive(Debug, Clone, Default)]
99pub struct AuditSecretsOptions {
100    /// Restrict the scan to one palace id. `None` scans every palace.
101    pub palace: Option<String>,
102    /// Emit JSON instead of text.
103    pub json: bool,
104}
105
106/// Refusals broken down by [`FilterReject`] variant.
107///
108/// Why: the issue asks for a per-variant breakdown. `check_secret` only
109/// returns `PotentialSecret` today; the exhaustive match in [`tally_reject`]
110/// makes a new variant a compile error here rather than an uncounted refusal.
111/// What: one counter per variant. The quality gates (`TooShort`,
112/// `NoisePattern`, `NonAlphabetic`) are not run by this audit, so they stay 0.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
114pub struct RejectCounts {
115    pub potential_secret: usize,
116    pub too_short: usize,
117    pub noise_pattern: usize,
118    pub non_alphabetic: usize,
119}
120
121/// What happened to a palace's store during the scan.
122///
123/// Why: #8645 — a palace with no `kg.redb` and a palace whose store was read
124/// and found clean must never render alike.
125/// What: `Read` — the copy was opened and its drawer table read; `Absent` — the
126/// palace has no store file; `Error` — the store could not be read (see
127/// [`PalaceSecretCounts::error`]).
128/// Test: `unstattable_palace_dir_is_an_error_row_not_an_absent_store`.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "lowercase")]
131pub enum StoreState {
132    #[default]
133    Read,
134    Absent,
135    Error,
136}
137
138impl StoreState {
139    fn as_str(self) -> &'static str {
140        match self {
141            Self::Read => "read",
142            Self::Absent => "absent",
143            Self::Error => "error",
144        }
145    }
146}
147
148/// One palace's counts. Carries no drawer text, id or token preview.
149///
150/// Why: the output contract is counts only (#8645).
151/// What: `drawers_unreadable` counts drawer rows present in the table that
152/// could not be decoded, so were never screened; any non-zero value fails the
153/// run (see [`scan_verdict`]). `drawers_refused` is the number of drawers
154/// `check_secret` refuses. `key_value_first` counts refusals whose refusing
155/// token — the first flagged token, the one `check_secret` names — is
156/// `KEY=value`-shaped. `key_value_only` counts refusals where every flagged token is
157/// `KEY=value`-shaped: the best cheap estimate of drawers only the #8589 fix
158/// catches, since a drawer that also holds a bare flagged token was refused
159/// before it too. Both are shape classifications, not a replay of the old
160/// filter. `error` holds only the outermost error message, which this module
161/// writes itself, so a store error can never echo stored bytes.
162/// Test: `counts_refusals_per_palace_and_variant`,
163/// `undecodable_drawer_row_is_counted_and_fails_the_run`.
164#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
165pub struct PalaceSecretCounts {
166    pub palace: String,
167    pub store: StoreState,
168    pub drawers_scanned: usize,
169    pub drawers_unreadable: usize,
170    pub drawers_refused: usize,
171    pub by_variant: RejectCounts,
172    pub key_value_first: usize,
173    pub key_value_only: usize,
174    pub error: Option<String>,
175}
176
177/// Count one refusal against its variant, dropping any payload unread.
178///
179/// Why: the `PotentialSecret` preview is a partial secret; it must be
180/// discarded where it is produced.
181/// What: increments the matching counter. Every arm binds with `{ .. }`.
182/// Test: `counts_refusals_per_palace_and_variant`.
183fn tally_reject(counts: &mut RejectCounts, reject: FilterReject) {
184    match reject {
185        FilterReject::PotentialSecret { .. } => counts.potential_secret += 1,
186        FilterReject::TooShort { .. } => counts.too_short += 1,
187        FilterReject::NoisePattern { .. } => counts.noise_pattern += 1,
188        FilterReject::NonAlphabetic { .. } => counts.non_alphabetic += 1,
189    }
190}
191
192/// Split `content` into the tokens `find_secret_token` classifies.
193///
194/// Why: telling the `KEY=value` path apart needs the flagged tokens, and
195/// trusty-common returns only a redacted preview of the first.
196/// What: mirrors `find_secret_token`'s tokenizer in
197/// `trusty-common/src/memory_core/filter/secret.rs` — split on whitespace or a
198/// backtick, then trim everything but ASCII alphanumerics, `-` and `_` from
199/// both ends. `check_secret` on one such token is exactly the per-token test
200/// `find_secret_token` applies, because the split and trim are idempotent.
201/// Test: `every_refused_drawer_has_a_flagged_token`.
202fn secret_tokens(content: &str) -> impl Iterator<Item = &str> {
203    content
204        .split(|c: char| c.is_whitespace() || c == '`')
205        .map(|raw| {
206            raw.trim_matches(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_')))
207        })
208        .filter(|tok| check_secret(tok).is_err())
209}
210
211/// True when `token` has the `KEY=value` shape #8589 started screening.
212///
213/// What: a non-empty identifier-like key (`[A-Za-z0-9_.-]+`), one `=`, and a
214/// value that is not pure `=` padding (so a padded base64 blob is not a key).
215/// Test: `key_value_shape_boundaries`.
216fn is_key_value_shaped(token: &str) -> bool {
217    let Some((key, value)) = token.split_once('=') else {
218        return false;
219    };
220    !key.is_empty()
221        && key
222            .bytes()
223            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
224        && !value.is_empty()
225        && !value.bytes().all(|b| b == b'=')
226}
227
228/// Screen one drawer's content and add it to `counts`.
229///
230/// What: counts the drawer as scanned; on a refusal, tallies the variant and
231/// classifies the flagged tokens by shape. Nothing derived from `content`
232/// outlives this call.
233/// Test: `counts_refusals_per_palace_and_variant`.
234fn screen_drawer(counts: &mut PalaceSecretCounts, content: &str) {
235    counts.drawers_scanned += 1;
236    let Err(reject) = check_secret(content) else {
237        return;
238    };
239    counts.drawers_refused += 1;
240    tally_reject(&mut counts.by_variant, reject);
241    let mut flagged = secret_tokens(content);
242    let Some(first) = flagged.next() else {
243        return;
244    };
245    if is_key_value_shaped(first) {
246        counts.key_value_first += 1;
247        if flagged.all(is_key_value_shaped) {
248            counts.key_value_only += 1;
249        }
250    }
251}
252
253/// A scan halted by its `stop` signal before every palace was read (#8645).
254///
255/// What: `scanned` is how many palaces finished before the stop was seen.
256/// Test: `stop_signal_halts_the_scan_between_palaces`.
257#[derive(Debug, thiserror::Error)]
258#[error("audit secrets: scan stopped after {scanned} palace(s)")]
259pub struct ScanInterrupted {
260    pub scanned: usize,
261}
262
263/// Scan every palace (or one) under `registry_dir`.
264///
265/// Why: the testable core — the CLI handler only resolves the data root and
266/// renders. `scratch_parent` is where the per-palace store copies live and die,
267/// so a test can prove nothing is left behind.
268/// What: lists palaces from disk, skips all but `palace_filter` when set, and
269/// screens each palace's drawers from a private copy of its store. A palace
270/// that cannot be read is recorded with an error and the scan continues; one
271/// with no store is recorded as `Absent`; undecodable drawer rows are counted
272/// in `drawers_unreadable`, never dropped. A `palace_filter` naming no palace
273/// is an error, not an empty report. `stop` is polled before each palace; once
274/// it returns true no further palace is copied, and the scan returns
275/// [`ScanInterrupted`] instead of partial counts.
276/// Test: `counts_refusals_per_palace_and_variant`,
277/// `palace_filter_scans_one_and_rejects_an_unknown_name`,
278/// `scan_leaves_palace_files_byte_identical_under_a_live_writer`,
279/// `undecodable_drawer_row_is_counted_and_fails_the_run`,
280/// `truncated_store_copy_is_an_error_row`,
281/// `stop_signal_halts_the_scan_between_palaces`.
282pub fn scan_palaces(
283    registry_dir: &Path,
284    palace_filter: Option<&str>,
285    scratch_parent: &Path,
286    stop: &dyn Fn() -> bool,
287) -> Result<Vec<PalaceSecretCounts>> {
288    let palaces = PalaceRegistry::list_palaces(registry_dir)
289        .with_context(|| format!("list palaces under {}", registry_dir.display()))?;
290    let mut out = Vec::new();
291    for palace in palaces {
292        let id = palace.id.0.clone();
293        if palace_filter.is_some_and(|f| f != id) {
294            continue;
295        }
296        if stop() {
297            return Err(ScanInterrupted { scanned: out.len() }.into());
298        }
299        let mut counts = PalaceSecretCounts {
300            palace: id,
301            ..Default::default()
302        };
303        let read = with_store_copy(&palace.data_dir, scratch_parent, |store| {
304            let (drawers, unreadable) =
305                store.load_drawers_with_skipped().context("load drawers")?;
306            // #8645: a skipped row was never screened; count it, never drop it.
307            counts.drawers_unreadable = unreadable;
308            for drawer in drawers {
309                screen_drawer(&mut counts, drawer.content());
310            }
311            Ok(())
312        });
313        match read {
314            Ok(Some(())) => {}
315            Ok(None) => counts.store = StoreState::Absent,
316            Err(e) => {
317                // #8645: outermost message only — the chain below it comes
318                // from the store and could quote stored bytes.
319                counts = PalaceSecretCounts {
320                    palace: counts.palace,
321                    store: StoreState::Error,
322                    error: Some(e.to_string()),
323                    ..Default::default()
324                };
325            }
326        }
327        out.push(counts);
328    }
329    if let Some(name) = palace_filter {
330        if out.is_empty() {
331            anyhow::bail!("no palace named `{name}` under {}", registry_dir.display());
332        }
333    }
334    Ok(out)
335}
336
337/// Totals across palaces, for the last text line and the JSON `totals`.
338#[derive(Debug, Default, Serialize)]
339struct Totals {
340    palaces: usize,
341    absent: usize,
342    drawers_scanned: usize,
343    drawers_unreadable: usize,
344    drawers_refused: usize,
345    key_value_first: usize,
346    key_value_only: usize,
347    errors: usize,
348}
349
350fn totals(rows: &[PalaceSecretCounts]) -> Totals {
351    let mut t = Totals {
352        palaces: rows.len(),
353        ..Default::default()
354    };
355    for r in rows {
356        t.absent += usize::from(r.store == StoreState::Absent);
357        t.drawers_scanned += r.drawers_scanned;
358        t.drawers_unreadable += r.drawers_unreadable;
359        t.drawers_refused += r.drawers_refused;
360        t.key_value_first += r.key_value_first;
361        t.key_value_only += r.key_value_only;
362        t.errors += usize::from(r.error.is_some());
363    }
364    t
365}
366
367/// Decide whether a finished scan may exit zero.
368///
369/// Why: #8645 — a scan that could not see everything must never look like a
370/// clean scan. A palace that errored, or one with undecodable rows, hid drawers
371/// from `check_secret`.
372/// What: `Ok(())` when no row carries an error or an unreadable drawer;
373/// otherwise an `Err` naming both counts. An `Absent` store is not a failure:
374/// the palace has no drawers, and the output says so.
375/// Test: `verdict_fails_on_error_or_unreadable_rows_and_passes_a_clean_set`.
376pub fn scan_verdict(rows: &[PalaceSecretCounts]) -> Result<()> {
377    let t = totals(rows);
378    if t.errors == 0 && t.drawers_unreadable == 0 {
379        return Ok(());
380    }
381    let partial = rows.iter().filter(|r| r.drawers_unreadable > 0).count();
382    anyhow::bail!(
383        "audit secrets: incomplete scan — {} palace(s) could not be read; \
384         {} unreadable drawer row(s) in {partial} palace(s) were not screened",
385        t.errors,
386        t.drawers_unreadable,
387    )
388}
389
390/// Render the counts as text (errors to `err`) or JSON (all to `out`).
391///
392/// What: text mode prints one `key=value` line per palace, carrying its
393/// `store=` state and `unreadable=` count, and a total line;
394/// JSON mode prints `{ "palaces": [...], "totals": {...} }`. Only counts,
395/// palace ids and this module's own error messages are ever written.
396/// Test: `output_and_tracing_carry_no_drawer_content`,
397/// `json_output_is_counts_only`.
398pub fn render(
399    out: &mut dyn Write,
400    err: &mut dyn Write,
401    rows: &[PalaceSecretCounts],
402    json: bool,
403) -> Result<()> {
404    let t = totals(rows);
405    if json {
406        let doc = serde_json::json!({ "palaces": rows, "totals": t });
407        writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
408        return Ok(());
409    }
410    writeln!(
411        out,
412        "audit secrets: COUNT ONLY — read-only scan of a private copy of each palace store; \
413         no drawer text or token preview is printed"
414    )?;
415    for r in rows {
416        if let Some(e) = &r.error {
417            writeln!(err, "[error] palace={} error={e}", r.palace)?;
418            continue;
419        }
420        let v = &r.by_variant;
421        writeln!(
422            out,
423            "palace={} store={} scanned={} unreadable={} refused={} potential_secret={} \
424             too_short={} noise_pattern={} non_alphabetic={} key_value_first={} \
425             key_value_only={}",
426            r.palace,
427            r.store.as_str(),
428            r.drawers_scanned,
429            r.drawers_unreadable,
430            r.drawers_refused,
431            v.potential_secret,
432            v.too_short,
433            v.noise_pattern,
434            v.non_alphabetic,
435            r.key_value_first,
436            r.key_value_only,
437        )?;
438    }
439    writeln!(
440        out,
441        "total: palaces={} absent={} scanned={} unreadable={} refused={} key_value_first={} \
442         key_value_only={} errors={}",
443        t.palaces,
444        t.absent,
445        t.drawers_scanned,
446        t.drawers_unreadable,
447        t.drawers_refused,
448        t.key_value_first,
449        t.key_value_only,
450        t.errors
451    )?;
452    Ok(())
453}
454
455/// CLI entry point for `trusty-memory audit secrets --count-only`.
456///
457/// Why: a thin shim over [`scan_palaces`] and [`render`], matching
458/// `backfill-report`'s shape.
459/// What: resolves the data root, sweeps store copies a dead run left in the
460/// system temp dir, then scans through [`scan_until_interrupted`] with Ctrl-C
461/// as the interrupt. Renders, then exits through [`scan_verdict`], so a partial
462/// scan never passes as a complete one.
463/// Test: not unit-tested (process-level entry point); `scan_until_interrupted`,
464/// `render`, `scan_verdict` and `sweep_stale_copies` are the testable surfaces
465/// (`sweep_removes_only_stale_scratch_dirs`).
466pub async fn handle_audit_secrets(opts: AuditSecretsOptions) -> Result<()> {
467    let data_dir = trusty_common::resolve_data_dir("trusty-memory")
468        .context("resolve trusty-memory data dir")?;
469    let registry_dir = crate::resolve_palace_registry_dir(data_dir);
470    let tmp = std::env::temp_dir();
471    // #8645: copies hold drawers in plaintext; remove any a killed run left.
472    sweep_stale_copies(&tmp, STALE_COPY_AGE);
473    let run_dir = tempfile::TempDir::with_prefix_in(SCRATCH_PREFIX, &tmp)
474        .context("create scratch dir for the audit run")?;
475    let rows = scan_until_interrupted(
476        registry_dir,
477        opts.palace.clone(),
478        run_dir,
479        tokio::signal::ctrl_c(),
480    )
481    .await?;
482    render(
483        &mut std::io::stdout().lock(),
484        &mut std::io::stderr().lock(),
485        &rows,
486        opts.json,
487    )?;
488    scan_verdict(&rows)
489}
490
491/// Run [`scan_palaces`] on a blocking thread until it finishes or `interrupt`
492/// fires.
493///
494/// Why: #8645 — each store copy holds drawers in plaintext. On Ctrl-C the scan
495/// must stop copying, and the copies must be gone before the process exits.
496/// The runtime waits for a running blocking task at shutdown, so an
497/// uncancelled worker would hold the exit until every palace was scanned.
498/// What: every copy lives under `run_dir`. If `interrupt` resolves `Ok` first,
499/// sets the stop flag, waits for the worker (it finishes at most the in-flight
500/// palace), removes `run_dir`, and returns an error that says whether the
501/// removal succeeded. An `Err` from `interrupt` — the handler could not be
502/// registered — disables that arm and the scan runs to completion.
503/// Test: `interrupt_stops_the_scan_and_removes_the_run_dir`,
504/// `failed_interrupt_registration_lets_the_scan_finish`.
505async fn scan_until_interrupted(
506    registry_dir: PathBuf,
507    palace: Option<String>,
508    run_dir: tempfile::TempDir,
509    interrupt: impl Future<Output = std::io::Result<()>>,
510) -> Result<Vec<PalaceSecretCounts>> {
511    let stop = Arc::new(AtomicBool::new(false));
512    let worker_stop = Arc::clone(&stop);
513    let run_path = run_dir.path().to_path_buf();
514    let mut scan = tokio::task::spawn_blocking(move || {
515        let stopped = || worker_stop.load(Ordering::Relaxed);
516        scan_palaces(&registry_dir, palace.as_deref(), &run_path, &stopped)
517    });
518    tokio::select! {
519        biased;
520        Ok(()) = interrupt => {}
521        joined = &mut scan => return joined.context("audit scan task")?,
522    }
523    stop.store(true, Ordering::Relaxed);
524    // #8645: the worker's result is moot; waiting means no copy is in flight
525    // when the run dir goes.
526    let _ = scan.await;
527    let dir = run_dir.path().display().to_string();
528    match run_dir.close() {
529        Ok(()) => {
530            anyhow::bail!("audit secrets: interrupted — store copies deleted, no counts reported")
531        }
532        Err(e) => anyhow::bail!(
533            "audit secrets: interrupted — could not delete store copies under {dir}: {e}; \
534             no counts reported"
535        ),
536    }
537}
538
539#[cfg(test)]
540#[path = "audit_secrets_tests.rs"]
541mod tests;