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(®istry_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;