use std::future::Future;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::Serialize;
use trusty_common::memory_core::filter::{check_secret, FilterReject};
use trusty_common::memory_core::PalaceRegistry;
use super::store_snapshot::{sweep_stale_copies, with_store_copy, SCRATCH_PREFIX, STALE_COPY_AGE};
#[derive(Debug, Args)]
pub struct AuditArgs {
#[command(subcommand)]
pub action: AuditAction,
}
#[derive(Debug, Subcommand)]
pub enum AuditAction {
Secrets {
#[arg(long = "count-only", required = true)]
count_only: bool,
#[arg(long, value_name = "ID")]
palace: Option<String>,
#[arg(long)]
json: bool,
},
}
pub async fn dispatch(args: AuditArgs) -> Result<()> {
match args.action {
AuditAction::Secrets { palace, json, .. } => {
handle_audit_secrets(AuditSecretsOptions { palace, json }).await
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuditSecretsOptions {
pub palace: Option<String>,
pub json: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct RejectCounts {
pub potential_secret: usize,
pub too_short: usize,
pub noise_pattern: usize,
pub non_alphabetic: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreState {
#[default]
Read,
Absent,
Error,
}
impl StoreState {
fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Absent => "absent",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PalaceSecretCounts {
pub palace: String,
pub store: StoreState,
pub drawers_scanned: usize,
pub drawers_unreadable: usize,
pub drawers_refused: usize,
pub by_variant: RejectCounts,
pub key_value_first: usize,
pub key_value_only: usize,
pub error: Option<String>,
}
fn tally_reject(counts: &mut RejectCounts, reject: FilterReject) {
match reject {
FilterReject::PotentialSecret { .. } => counts.potential_secret += 1,
FilterReject::TooShort { .. } => counts.too_short += 1,
FilterReject::NoisePattern { .. } => counts.noise_pattern += 1,
FilterReject::NonAlphabetic { .. } => counts.non_alphabetic += 1,
}
}
fn secret_tokens(content: &str) -> impl Iterator<Item = &str> {
content
.split(|c: char| c.is_whitespace() || c == '`')
.map(|raw| {
raw.trim_matches(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_')))
})
.filter(|tok| check_secret(tok).is_err())
}
fn is_key_value_shaped(token: &str) -> bool {
let Some((key, value)) = token.split_once('=') else {
return false;
};
!key.is_empty()
&& key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
&& !value.is_empty()
&& !value.bytes().all(|b| b == b'=')
}
fn screen_drawer(counts: &mut PalaceSecretCounts, content: &str) {
counts.drawers_scanned += 1;
let Err(reject) = check_secret(content) else {
return;
};
counts.drawers_refused += 1;
tally_reject(&mut counts.by_variant, reject);
let mut flagged = secret_tokens(content);
let Some(first) = flagged.next() else {
return;
};
if is_key_value_shaped(first) {
counts.key_value_first += 1;
if flagged.all(is_key_value_shaped) {
counts.key_value_only += 1;
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("audit secrets: scan stopped after {scanned} palace(s)")]
pub struct ScanInterrupted {
pub scanned: usize,
}
pub fn scan_palaces(
registry_dir: &Path,
palace_filter: Option<&str>,
scratch_parent: &Path,
stop: &dyn Fn() -> bool,
) -> Result<Vec<PalaceSecretCounts>> {
let palaces = PalaceRegistry::list_palaces(registry_dir)
.with_context(|| format!("list palaces under {}", registry_dir.display()))?;
let mut out = Vec::new();
for palace in palaces {
let id = palace.id.0.clone();
if palace_filter.is_some_and(|f| f != id) {
continue;
}
if stop() {
return Err(ScanInterrupted { scanned: out.len() }.into());
}
let mut counts = PalaceSecretCounts {
palace: id,
..Default::default()
};
let read = with_store_copy(&palace.data_dir, scratch_parent, |store| {
let (drawers, unreadable) =
store.load_drawers_with_skipped().context("load drawers")?;
counts.drawers_unreadable = unreadable;
for drawer in drawers {
screen_drawer(&mut counts, drawer.content());
}
Ok(())
});
match read {
Ok(Some(())) => {}
Ok(None) => counts.store = StoreState::Absent,
Err(e) => {
counts = PalaceSecretCounts {
palace: counts.palace,
store: StoreState::Error,
error: Some(e.to_string()),
..Default::default()
};
}
}
out.push(counts);
}
if let Some(name) = palace_filter {
if out.is_empty() {
anyhow::bail!("no palace named `{name}` under {}", registry_dir.display());
}
}
Ok(out)
}
#[derive(Debug, Default, Serialize)]
struct Totals {
palaces: usize,
absent: usize,
drawers_scanned: usize,
drawers_unreadable: usize,
drawers_refused: usize,
key_value_first: usize,
key_value_only: usize,
errors: usize,
}
fn totals(rows: &[PalaceSecretCounts]) -> Totals {
let mut t = Totals {
palaces: rows.len(),
..Default::default()
};
for r in rows {
t.absent += usize::from(r.store == StoreState::Absent);
t.drawers_scanned += r.drawers_scanned;
t.drawers_unreadable += r.drawers_unreadable;
t.drawers_refused += r.drawers_refused;
t.key_value_first += r.key_value_first;
t.key_value_only += r.key_value_only;
t.errors += usize::from(r.error.is_some());
}
t
}
pub fn scan_verdict(rows: &[PalaceSecretCounts]) -> Result<()> {
let t = totals(rows);
if t.errors == 0 && t.drawers_unreadable == 0 {
return Ok(());
}
let partial = rows.iter().filter(|r| r.drawers_unreadable > 0).count();
anyhow::bail!(
"audit secrets: incomplete scan — {} palace(s) could not be read; \
{} unreadable drawer row(s) in {partial} palace(s) were not screened",
t.errors,
t.drawers_unreadable,
)
}
pub fn render(
out: &mut dyn Write,
err: &mut dyn Write,
rows: &[PalaceSecretCounts],
json: bool,
) -> Result<()> {
let t = totals(rows);
if json {
let doc = serde_json::json!({ "palaces": rows, "totals": t });
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
return Ok(());
}
writeln!(
out,
"audit secrets: COUNT ONLY — read-only scan of a private copy of each palace store; \
no drawer text or token preview is printed"
)?;
for r in rows {
if let Some(e) = &r.error {
writeln!(err, "[error] palace={} error={e}", r.palace)?;
continue;
}
let v = &r.by_variant;
writeln!(
out,
"palace={} store={} scanned={} unreadable={} refused={} potential_secret={} \
too_short={} noise_pattern={} non_alphabetic={} key_value_first={} \
key_value_only={}",
r.palace,
r.store.as_str(),
r.drawers_scanned,
r.drawers_unreadable,
r.drawers_refused,
v.potential_secret,
v.too_short,
v.noise_pattern,
v.non_alphabetic,
r.key_value_first,
r.key_value_only,
)?;
}
writeln!(
out,
"total: palaces={} absent={} scanned={} unreadable={} refused={} key_value_first={} \
key_value_only={} errors={}",
t.palaces,
t.absent,
t.drawers_scanned,
t.drawers_unreadable,
t.drawers_refused,
t.key_value_first,
t.key_value_only,
t.errors
)?;
Ok(())
}
pub async fn handle_audit_secrets(opts: AuditSecretsOptions) -> Result<()> {
let data_dir = trusty_common::resolve_data_dir("trusty-memory")
.context("resolve trusty-memory data dir")?;
let registry_dir = crate::resolve_palace_registry_dir(data_dir);
let tmp = std::env::temp_dir();
sweep_stale_copies(&tmp, STALE_COPY_AGE);
let run_dir = tempfile::TempDir::with_prefix_in(SCRATCH_PREFIX, &tmp)
.context("create scratch dir for the audit run")?;
let rows = scan_until_interrupted(
registry_dir,
opts.palace.clone(),
run_dir,
tokio::signal::ctrl_c(),
)
.await?;
render(
&mut std::io::stdout().lock(),
&mut std::io::stderr().lock(),
&rows,
opts.json,
)?;
scan_verdict(&rows)
}
async fn scan_until_interrupted(
registry_dir: PathBuf,
palace: Option<String>,
run_dir: tempfile::TempDir,
interrupt: impl Future<Output = std::io::Result<()>>,
) -> Result<Vec<PalaceSecretCounts>> {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let run_path = run_dir.path().to_path_buf();
let mut scan = tokio::task::spawn_blocking(move || {
let stopped = || worker_stop.load(Ordering::Relaxed);
scan_palaces(®istry_dir, palace.as_deref(), &run_path, &stopped)
});
tokio::select! {
biased;
Ok(()) = interrupt => {}
joined = &mut scan => return joined.context("audit scan task")?,
}
stop.store(true, Ordering::Relaxed);
let _ = scan.await;
let dir = run_dir.path().display().to_string();
match run_dir.close() {
Ok(()) => {
anyhow::bail!("audit secrets: interrupted — store copies deleted, no counts reported")
}
Err(e) => anyhow::bail!(
"audit secrets: interrupted — could not delete store copies under {dir}: {e}; \
no counts reported"
),
}
}
#[cfg(test)]
#[path = "audit_secrets_tests.rs"]
mod tests;