use std::path::Path;
use anyhow::Result;
use colored::Colorize;
use tsafe_core::{
audit::AuditStatus,
audit_explain::{
AuditSession, AuditTimeline, ExecutionAuthoritySummary, ExecutionGap,
ExplainedAuditOperation, ExplainedOperationKind, SessionBoundary,
},
contracts::AuthorityTargetDecision,
};
use crate::helpers::*;
pub(crate) fn cmd_audit(
profile: &str,
limit: usize,
hibp: bool,
explain: bool,
json: bool,
cell_id: Option<&str>,
) -> Result<()> {
if hibp {
return cmd_audit_hibp(profile);
}
if explain {
return cmd_audit_explain(profile, limit, json);
}
let mut entries = audit(profile).read(Some(limit))?;
if let Some(cid) = cell_id {
entries.retain(|e| {
e.context
.as_ref()
.and_then(|ctx| ctx.cellos.as_ref())
.map(|c| c.cellos_cell_id == cid)
.unwrap_or(false)
});
}
if entries.is_empty() {
match cell_id {
Some(cid) => println!(
"{} No audit entries for profile '{profile}' with cell-id '{cid}'",
"i".blue()
),
None => println!("{} No audit entries for profile '{profile}'", "i".blue()),
}
return Ok(());
}
if let Some(cid) = cell_id {
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!(
"{} Audit chain for cell '{}' ({})",
"i".blue(),
cid,
entries.len()
);
}
for e in &entries {
let status = match e.status {
AuditStatus::Success => "OK ".green(),
AuditStatus::Failure => "FAIL".red(),
};
let key = e.key.as_deref().unwrap_or("-");
let msg = e.message.as_deref().unwrap_or("");
println!(
"{} [{}] {:8} {:30} {}",
e.timestamp.format("%Y-%m-%d %H:%M:%S"),
status,
e.operation.cyan(),
key,
msg
);
if let Some(ctx) = &e.context {
if let Some(cellos) = &ctx.cellos {
println!(
" cell_id={} token={}",
cellos.cellos_cell_id.dimmed(),
cellos.cell_token.as_deref().unwrap_or("-").dimmed()
);
}
}
}
Ok(())
}
fn cmd_audit_explain(profile: &str, limit: usize, json: bool) -> Result<()> {
let entries = audit(profile).read(Some(limit))?;
let timeline = tsafe_core::audit_explain::explain_entries(&entries);
if json {
println!("{}", serde_json::to_string_pretty(&timeline)?);
return Ok(());
}
if entries.is_empty() {
println!("{} No audit entries for profile '{profile}'", "i".blue());
return Ok(());
}
print_audit_timeline_human(profile, entries.len(), limit, &timeline);
Ok(())
}
fn print_audit_timeline_human(
profile: &str,
entry_count: usize,
limit: usize,
timeline: &AuditTimeline,
) {
println!(
"{} Audit explanation — profile '{}' — {} session(s), {} audit line(s) loaded (limit {})",
"i".blue(),
profile,
timeline.sessions.len(),
entry_count,
limit
);
for session in &timeline.sessions {
print_session(session);
}
}
fn print_session(session: &AuditSession) {
let boundary = session_boundary_label(session.boundary);
println!();
println!(
"{} Session {} — {} — {} → {}",
"▸".cyan().bold(),
session.session_index + 1,
session.profile,
session.start.format("%Y-%m-%d %H:%M:%S UTC"),
session.end.format("%Y-%m-%d %H:%M:%S UTC")
);
println!(
" Boundary: {} ops: {} exec: {} failures: {}",
boundary, session.operation_count, session.exec_count, session.failure_count
);
for op in &session.operations {
print_operation(op);
}
}
fn print_operation(op: &ExplainedAuditOperation) {
let status = match op.status {
AuditStatus::Success => "OK ".green(),
AuditStatus::Failure => "FAIL".red(),
};
let kind = explained_kind_display(op.kind);
println!(
" • {} [{}] {:12} {:24} {}",
op.timestamp.format("%Y-%m-%d %H:%M:%S"),
status,
op.operation.cyan(),
kind.dimmed(),
op.message.as_deref().unwrap_or("")
);
if let Some(ref k) = op.key_ref {
println!(" key_ref: {}", k.dimmed());
}
if let Some(ref auth) = op.authority {
print_authority(auth);
}
}
fn print_authority(a: &ExecutionAuthoritySummary) {
let mut grant_parts: Vec<String> = Vec::new();
if let Some(ref c) = a.contract_name {
grant_parts.push(format!("contract={}", c.cyan()));
}
if let Some(ref p) = a.authority_profile {
grant_parts.push(format!("profile={p}"));
}
if let Some(ref n) = a.authority_namespace {
grant_parts.push(format!("ns={n}"));
}
if let Some(t) = a.trust_level {
grant_parts.push(format!("trust={}", t.as_str()));
}
if let Some(i) = a.inherit {
grant_parts.push(format!("inherit={}", i.as_str()));
}
if a.deny_dangerous_env == Some(true) {
grant_parts.push("deny_dangerous_env".into());
}
if a.redact_output == Some(true) {
grant_parts.push("redact_output".into());
}
if let Some(n) = a.network {
grant_parts.push(format!("network={}", n.as_str()));
}
if !grant_parts.is_empty() {
println!(" {} {}", "granted:".bold(), grant_parts.join(" "));
}
if !a.injected_secret_refs.is_empty() {
println!(
" {} {}",
"injected:".bold(),
a.injected_secret_refs.join(", ").green()
);
} else if a.gaps.contains(&ExecutionGap::MissingInjectedSecretSet) {
println!(
" {} (not recorded — old audit entry)",
"injected:".bold()
);
} else {
println!(" {} (none)", "injected:".bold());
}
if !a.required_secret_refs.is_empty() {
println!(
" {} {}",
"required:".bold(),
a.required_secret_refs.join(", ")
);
}
if !a.allowed_secret_refs.is_empty() {
println!(
" {} {} (ceiling)",
"allowed:".bold(),
a.allowed_secret_refs.join(", ").dimmed()
);
}
if let (Some(ref t), Some(d)) = (&a.target, a.target_decision) {
let decision = target_decision_label(d);
let verdict = match d {
AuthorityTargetDecision::Unconstrained => decision.normal(),
AuthorityTargetDecision::AllowedExact | AuthorityTargetDecision::AllowedBasename => {
decision.green()
}
AuthorityTargetDecision::MissingTarget | AuthorityTargetDecision::Denied => {
decision.red()
}
};
let matched = a
.matched_target
.as_deref()
.map(|m| format!(" (matched '{m}')"))
.unwrap_or_default();
println!(" {} {t} → {verdict}{matched}", "target:".bold());
} else if let Some(ref t) = a.target {
println!(" {} {t}", "target:".bold());
}
let diff = &a.contract_diff;
let mut denied: Vec<String> = Vec::new();
if !diff.unexpected_injected_secret_refs.is_empty() {
denied.push(format!(
"injected outside contract: {}",
diff.unexpected_injected_secret_refs.join(", ")
));
}
if !diff.missing_required_secret_refs.is_empty() {
denied.push(format!(
"required but missing: {}",
diff.missing_required_secret_refs.join(", ")
));
}
if diff.target_mismatch {
denied.push("target did not match contract".into());
}
if !diff.dropped_env_names.is_empty() {
denied.push(format!("stripped: {}", diff.dropped_env_names.join(", ")));
}
if !denied.is_empty() {
println!(
" {} {}",
"denied/stripped:".bold(),
denied.join(" | ").yellow()
);
}
let notable_gaps: Vec<&str> = a
.gaps
.iter()
.copied()
.filter(|g| !matches!(g, ExecutionGap::MissingInjectedSecretSet))
.map(gap_label)
.collect();
if !notable_gaps.is_empty() {
println!(
" {} {}",
"audit gaps:".bold(),
notable_gaps.join(", ").dimmed()
);
}
}
fn session_boundary_label(b: SessionBoundary) -> &'static str {
match b {
SessionBoundary::StartOfLog => "start of log",
SessionBoundary::UnlockBoundary => "unlock",
SessionBoundary::TimeGap => "idle gap",
SessionBoundary::ProfileChange => "profile change",
}
}
fn explained_kind_label(k: ExplainedOperationKind) -> &'static str {
match k {
ExplainedOperationKind::SecretLifecycle => "secret",
ExplainedOperationKind::VaultLifecycle => "vault",
ExplainedOperationKind::Execution => "exec",
ExplainedOperationKind::Session => "session",
ExplainedOperationKind::Sync => "sync",
ExplainedOperationKind::Share => "share",
ExplainedOperationKind::Team => "team",
ExplainedOperationKind::RotationPolicy => "policy",
ExplainedOperationKind::CredentialHelper => "git_cred",
ExplainedOperationKind::Other => "other",
}
}
fn explained_kind_build_note(k: ExplainedOperationKind) -> Option<&'static str> {
match k {
ExplainedOperationKind::Share if !cfg!(feature = "ots-sharing") => {
Some("compiled out in this build")
}
ExplainedOperationKind::CredentialHelper if !cfg!(feature = "git-helpers") => {
Some("compiled out in this build")
}
_ => None,
}
}
fn explained_kind_display(k: ExplainedOperationKind) -> String {
let label = explained_kind_label(k);
match explained_kind_build_note(k) {
Some(note) => format!("{label} ({note})"),
None => label.to_string(),
}
}
fn target_decision_label(d: AuthorityTargetDecision) -> &'static str {
match d {
AuthorityTargetDecision::Unconstrained => "unconstrained",
AuthorityTargetDecision::AllowedExact => "allowed_exact",
AuthorityTargetDecision::AllowedBasename => "allowed_basename",
AuthorityTargetDecision::MissingTarget => "missing_target",
AuthorityTargetDecision::Denied => "denied",
}
}
fn gap_label(g: ExecutionGap) -> &'static str {
match g {
ExecutionGap::MissingExecContext => "missing_exec_context",
ExecutionGap::MissingContractName => "missing_contract_name",
ExecutionGap::MissingTarget => "missing_target",
ExecutionGap::MissingInjectedSecretSet => "missing_injected_secret_set",
ExecutionGap::MissingTargetDecision => "missing_target_decision",
}
}
#[allow(dead_code)]
pub(crate) struct ChainCoverage {
pub total: usize,
pub chained: usize,
pub unchained: usize,
pub malformed: usize,
pub coverage_pct: Option<u8>,
}
pub(crate) fn compute_chain_coverage(log_path: &Path) -> ChainCoverage {
use tsafe_core::audit::AuditEntry;
if !log_path.exists() {
return ChainCoverage {
total: 0,
chained: 0,
unchained: 0,
malformed: 0,
coverage_pct: None,
};
}
let content = match std::fs::read_to_string(log_path) {
Ok(c) => c,
Err(_) => {
return ChainCoverage {
total: 0,
chained: 0,
unchained: 0,
malformed: 0,
coverage_pct: None,
};
}
};
let mut total: usize = 0;
let mut chained: usize = 0;
let mut malformed: usize = 0;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<AuditEntry>(trimmed) {
Ok(entry) => {
total += 1;
if entry.prev_entry_hmac.is_some() {
chained += 1;
}
}
Err(_) => {
malformed += 1;
}
}
}
let unchained = total - chained;
let coverage_pct = Some(if total == 0 {
0u8
} else {
(chained as f64 / total as f64 * 100.0) as u8
});
ChainCoverage {
total,
chained,
unchained,
malformed,
coverage_pct,
}
}
pub(crate) fn cmd_audit_verify(profile: &str, json: bool) -> Result<()> {
use tsafe_core::profile;
let log_path = profile::audit_log_path(profile);
let cov = compute_chain_coverage(&log_path);
if cov.coverage_pct.is_none() {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"total": 0,
"chained": 0,
"unchained": 0,
"chain_coverage_pct": 0.0
}))?
);
} else {
println!(
"{} Audit log: 0 entries, 0 chained (0%), 0 unchained (pre-C8 or session boundary)",
"i".blue()
);
}
return Ok(());
}
let coverage_float = if cov.total == 0 {
0.0_f64
} else {
cov.chained as f64 / cov.total as f64 * 100.0
};
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"total": cov.total,
"chained": cov.chained,
"unchained": cov.unchained,
"chain_coverage_pct": (coverage_float * 10.0).round() / 10.0
}))?
);
} else {
println!(
"{} Audit log: {} entries, {} chained ({:.0}%), {} unchained (pre-C8 or session boundary)",
"i".blue(),
cov.total,
cov.chained,
coverage_float,
cov.unchained
);
}
if cov.malformed > 0 {
eprintln!(
"{} {} malformed line(s) could not be parsed — audit log may be corrupted",
"warn:".yellow().bold(),
cov.malformed
);
std::process::exit(2);
}
Ok(())
}
#[allow(dead_code)]
pub(crate) fn cmd_audit_rotate(profile: &str, max_size_mb: u64, keep: u32) -> Result<()> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
use tsafe_core::audit::audit_log_size_bytes;
use tsafe_core::profile;
let log_path = profile::audit_log_path(profile);
let size_bytes = audit_log_size_bytes(&log_path)?;
let size_mb = size_bytes as f64 / 1_048_576.0;
let threshold_bytes = max_size_mb * 1_048_576;
if size_bytes <= threshold_bytes {
println!(
"{} Audit log is {:.2} MB, below threshold (max: {} MB). No rotation needed.",
"i".blue(),
size_mb,
max_size_mb
);
return Ok(());
}
for n in (1..=keep).rev() {
let src = archive_path(&log_path, n);
if !src.exists() {
continue;
}
if n >= keep {
std::fs::remove_file(&src)?;
} else {
let dst = archive_path(&log_path, n + 1);
std::fs::rename(&src, &dst)?;
}
}
let archive = archive_path(&log_path, 1);
{
let raw = std::fs::read(&log_path)?;
let archive_file = std::fs::File::create(&archive)?;
let mut encoder = GzEncoder::new(archive_file, Compression::default());
encoder.write_all(&raw)?;
encoder.finish()?;
}
std::fs::write(&log_path, b"")?;
println!(
"{} Audit log rotated: {:.2} MB → {}",
"✓".green(),
size_mb,
archive.display(),
);
Ok(())
}
fn archive_path(log_path: &Path, n: u32) -> std::path::PathBuf {
let mut s = log_path.as_os_str().to_os_string();
s.push(format!(".{n}.gz"));
std::path::PathBuf::from(s)
}
fn cmd_audit_hibp(profile: &str) -> Result<()> {
use sha1::{Digest, Sha1};
let vault = open_vault(profile)?;
let all = vault.export_all()?;
let mut compromised = 0u32;
let total = all.len();
let agent = build_http_agent();
println!("Checking {total} secrets against Have I Been Pwned...\n");
for (key, value) in &all {
let mut hasher = Sha1::new();
hasher.update(value.as_bytes());
let hash = format!("{:X}", hasher.finalize());
let prefix = &hash[..5];
let suffix = &hash[5..];
let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
let resp = agent.get(&url).set("User-Agent", "tsafe-HIBP-Check").call();
match resp {
Ok(r) => {
let body = r.into_string().unwrap_or_default();
let found = body.lines().any(|line| {
line.split(':')
.next()
.map(|s| s.eq_ignore_ascii_case(suffix))
.unwrap_or(false)
});
if found {
println!(
" {} {}: {}",
"✗".red().bold(),
key,
"COMPROMISED — value found in breach database".red()
);
compromised += 1;
}
}
Err(e) => {
eprintln!(" {} {}: HIBP check failed: {}", "?".yellow(), key, e);
}
}
}
println!();
if compromised > 0 {
println!(
"{} {}/{} secrets found in breach databases — rotate them immediately",
"✗".red().bold(),
compromised,
total
);
std::process::exit(1);
} else {
println!(
"{} {}/{} secrets checked — none found in breach databases",
"✓".green(),
total,
total
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{explained_kind_build_note, explained_kind_display};
use tsafe_core::audit_explain::ExplainedOperationKind;
#[test]
fn share_kind_reports_when_compiled_out() {
let note = explained_kind_build_note(ExplainedOperationKind::Share);
assert_eq!(note.is_some(), !cfg!(feature = "ots-sharing"));
if !cfg!(feature = "ots-sharing") {
assert!(explained_kind_display(ExplainedOperationKind::Share).contains("compiled out"));
}
}
#[test]
fn credential_helper_kind_reports_when_compiled_out() {
let note = explained_kind_build_note(ExplainedOperationKind::CredentialHelper);
assert_eq!(note.is_some(), !cfg!(feature = "git-helpers"));
if !cfg!(feature = "git-helpers") {
assert!(
explained_kind_display(ExplainedOperationKind::CredentialHelper)
.contains("compiled out")
);
}
}
#[test]
fn rotate_below_threshold_does_nothing() {
use super::cmd_audit_rotate;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
let log_path = tsafe_core::profile::audit_log_path("test");
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&log_path, b"small content").unwrap();
cmd_audit_rotate("test", 100, 3).expect("rotate should succeed");
let archive = super::archive_path(&log_path, 1);
assert!(
!archive.exists(),
"no archive should be created below threshold"
);
let content = std::fs::read(&log_path).unwrap();
assert_eq!(content, b"small content");
});
}
#[test]
fn rotate_above_threshold_creates_archive() {
use super::cmd_audit_rotate;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
let log_path = tsafe_core::profile::audit_log_path("test");
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let payload = b"some audit content";
std::fs::write(&log_path, payload).unwrap();
cmd_audit_rotate("test", 0, 3).expect("rotate should succeed");
let archive = super::archive_path(&log_path, 1);
assert!(archive.exists(), ".1.gz archive must be created");
let content = std::fs::read(&log_path).unwrap();
assert!(
content.is_empty(),
"original log must be emptied after rotation"
);
let gz_bytes = std::fs::read(&archive).unwrap();
assert!(gz_bytes.len() >= 2, "archive must not be trivially empty");
assert_eq!(
&gz_bytes[..2],
&[0x1f, 0x8b],
"archive must start with gzip magic"
);
});
}
#[test]
fn rotate_respects_keep_count() {
use super::cmd_audit_rotate;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
let log_path = tsafe_core::profile::audit_log_path("test");
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
for i in 0..5u8 {
std::fs::write(&log_path, format!("content {i}").as_bytes()).unwrap();
cmd_audit_rotate("test", 0, 3).expect("rotate should succeed");
}
for n in 1u32..=3 {
let p = super::archive_path(&log_path, n);
assert!(
p.exists(),
"archive .{n}.gz must exist after 5 rotations with keep=3"
);
}
for n in 4u32..=5 {
let p = super::archive_path(&log_path, n);
assert!(!p.exists(), "archive .{n}.gz must not exist (keep=3)");
}
});
}
#[test]
fn compute_chain_coverage_absent_log_returns_none() {
use super::compute_chain_coverage;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let missing = dir.path().join("missing.jsonl");
let cov = compute_chain_coverage(&missing);
assert!(cov.coverage_pct.is_none());
assert_eq!(cov.total, 0);
}
}