Files
agent-manager/src/commands/audit_cmd.rs
T
bruno d9ffc34156 feat: phase 2 — audit config + update --rollback (closes #63 #64)
Axe 8 (sécurité) :
- am audit : checksums SHA-256 des configs (embarquée, utilisateur,
  locale) stockés dans state.json, croisement avec events.jsonl pour
  dater les changements, détection des modifications manuelles hors de
  am (statut MANUAL), rapport lisible + --json stable (contrat)
- backup automatique avant chaque am update (agent ou --all) : état +
  config utilisateur + répertoires d'installation + shims dans
  <state>/backups/<ts>/ avec manifest.json
- am update --rollback [point] : restaure le dernier backup (ou un id
  précis, préfixe accepté), arrête les agents concernés d'abord,
  événement EventKind::Rollback journalisé
- am update --rollback list : liste les points de restauration
- rétention settings.backups_keep (défaut 5) appliquée après chaque backup
- événements EventKind::Backup / EventKind::Rollback pour l'audit

REPL : commande audit + update --rollback (parse, complétion), help specs,
tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6,
ROADMAP cases cochées, 272 tests verts.
2026-08-18 09:44:16 -04:00

333 lines
11 KiB
Rust

//! audit: who changed what, when (issue #63, axe 8). Config checksums are
//! recorded in state.json; 'am audit' compares them with the current files,
//! cross-references the event journal to date the changes, and flags edits
//! made outside of agent-manager.
use super::*;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use crate::state::ConfigChecksum;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Labels of the tracked config files.
const EMBEDDED: &str = "embedded";
const USER: &str = "user";
const LOCAL: &str = "local";
/// SHA-256 (hex) of a file's content, plus its mtime.
fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(text.as_bytes());
ConfigChecksum {
sha256: format!("{:x}", h.finalize()),
mtime_secs,
recorded_at: crate::installers::now_rfc3339(),
}
}
/// mtime in seconds since epoch.
fn mtime_secs(path: &std::path::Path) -> Option<u64> {
std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
/// The tracked config files: label -> (display path, content).
fn tracked_files(app: &App) -> Vec<(&'static str, String, Option<u64>)> {
let mut out = Vec::new();
// Embedded default catalog: content is baked into the binary.
out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None));
// User config file.
if let Some(dir) = &app.paths.config_dir {
let p = dir.join(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&p) {
out.push((USER, text, mtime_secs(&p)));
}
}
// Local project config (agent-manager.yaml in the current directory).
let local = PathBuf::from(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&local) {
out.push((LOCAL, text, mtime_secs(&local)));
}
out
}
/// Kinds of events that represent a modification of the configuration or
/// the fleet (they explain a checksum change).
fn is_mutating_kind(k: &EventKind) -> bool {
matches!(
k,
EventKind::Config
| EventKind::Install
| EventKind::Update
| EventKind::Uninstall
| EventKind::Annotate
| EventKind::Catalog
| EventKind::Model
| EventKind::Backup
| EventKind::Rollback
)
}
/// One audit row: a tracked file and its verdict.
#[derive(serde::Serialize)]
struct FileRow {
file: String,
label: &'static str,
status: &'static str, // ok | new | changed | deleted
changed_at: Option<String>,
via: Option<String>, // "am <kind>" or "manual (outside am)"
}
/// One audited journal event.
#[derive(serde::Serialize)]
struct EventRow {
when: String,
who: String, // "am <kind>"
what: String,
agent: Option<String>,
}
pub fn run(app: &App) -> Result<i32> {
let mut sf = app.state.load()?;
let baseline = std::mem::take(&mut sf.config_checksums);
// 1. Current checksums of every tracked file.
let files = tracked_files(app);
let mut rows: Vec<FileRow> = Vec::new();
let mut next_baseline: BTreeMap<String, ConfigChecksum> = BTreeMap::new();
for (label, text, mtime) in &files {
let current = checksum_of(text, mtime.unwrap_or(0));
let (status, via, changed_at) = match baseline.get(*label) {
None => ("new", Some("baseline recorded now".to_string()), None),
Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None),
Some(prev) => {
let at = mtime
.and_then(|t| {
chrono::DateTime::from_timestamp(t as i64, 0)
.map(|d| d.to_rfc3339())
})
.unwrap_or_else(|| "unknown".to_string());
(status_changed(prev, &current, app, &at), Some(at.clone()), Some(at))
}
};
let display = match *label {
EMBEDDED => "config.yaml (embedded default)".to_string(),
USER => user_config_display(app),
LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME),
_ => label.to_string(),
};
rows.push(FileRow {
file: display,
label,
status,
changed_at,
via,
});
next_baseline.insert(label.to_string(), current);
}
// 2. Cross-reference the journal: recent mutating events.
let events = crate::events::read_events(&app.events_dir(), 200);
let event_rows: Vec<EventRow> = events
.iter()
.filter(|e| is_mutating_kind(&e.kind))
.map(|e| EventRow {
when: e.ts.clone(),
who: format!("am {}", e.kind.as_str()),
what: format!(
"{}{}",
e.reason.clone().unwrap_or_default(),
if e.args.is_empty() {
String::new()
} else {
format!(" ({})", e.args.join(", "))
}
),
agent: e.agent.clone(),
})
.collect();
// 3. Persist the new baseline so the next audit compares against now.
sf.config_checksums = next_baseline;
app.state.save(&sf)?;
if app.json() {
print_json(&serde_json::json!({
"files": rows,
"events": event_rows,
"generated_at": crate::installers::now_rfc3339(),
}));
return Ok(0);
}
// 4. Human-readable report.
println!("config audit — who changed what, when\n");
let mut header = crate::tables::DataTable {
columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()],
rows: Vec::new(),
};
for r in &rows {
header.rows.push(vec![
crate::tables::Cell::str(&r.file),
crate::tables::Cell::str(status_style(r.status)),
crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")),
crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")),
]);
}
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", header.render_themed(app.theme(), app.color(), width));
println!("\nrecent changes (event journal):");
if event_rows.is_empty() {
println!(" none");
} else {
for e in event_rows.iter().take(15) {
println!(
" {} {} {}{}",
e.when,
e.who,
e.agent.as_deref().unwrap_or("-"),
if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) }
);
}
}
let changed = rows.iter().filter(|r| r.status == "changed").count();
if changed == 0 {
app.log.success("no configuration change detected since the last audit");
} else {
app.log
.warn(&format!("{changed} configuration file(s) changed since the last audit"));
}
Ok(0)
}
fn status_changed(
prev: &ConfigChecksum,
current: &ConfigChecksum,
app: &App,
at: &str,
) -> &'static str {
// A change can be explained by am itself (an event after the baseline).
let events = crate::events::read_events(&app.events_dir(), 100);
let explained = events.iter().any(|e| {
is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str()
});
// 'via' is filled by the caller from this: manual vs am.
let _ = (current, at);
if explained {
"changed"
} else {
"manual"
}
}
fn status_style(s: &str) -> String {
match s {
"ok" => "ok".to_string(),
"new" => "new".to_string(),
"changed" => "CHANGED".to_string(),
"manual" => "MANUAL".to_string(),
_ => s.to_string(),
}
}
fn user_config_display(app: &App) -> String {
app.paths
.config_dir
.as_ref()
.map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string())
.unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::{StateFile, StateStore};
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> (App, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
let state_file = dir.path().join("state.json");
app.paths.state_file = state_file.clone();
app.paths.config_dir = Some(dir.path().to_path_buf());
app.state = StateStore::new(state_file);
(app, dir)
}
#[test]
fn checksum_changes_when_content_changes() {
let a = checksum_of("version: \"1.0\"\n", 0);
let b = checksum_of("version: \"1.0\"\n# extra\n", 0);
assert_ne!(a.sha256, b.sha256, "different content -> different hash");
assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256);
}
#[test]
fn audit_records_baseline_and_detects_change() {
let (app, dir) = test_app("audit");
let user_cfg = dir.path().join("config.yaml");
// First run: everything is recorded as baseline.
run(&app).unwrap();
let sf = app.state.load().unwrap();
assert!(sf.config_checksums.contains_key(EMBEDDED));
assert!(sf.config_checksums.contains_key(USER));
// Touch the user config outside of am.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&user_cfg)
.unwrap();
writeln!(f, "# modified by hand").unwrap();
drop(f);
// Second run: the change is flagged (status != ok).
run(&app).unwrap();
let sf2 = app.state.load().unwrap();
// The baseline was re-recorded, so a third run is clean.
run(&app).unwrap();
let sf3 = app.state.load().unwrap();
assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len());
assert!(sf3.config_checksums[USER].sha256.len() == 64);
}
#[test]
fn state_file_roundtrips_checksums() {
let mut sf = StateFile::default();
sf.config_checksums.insert(
"user".to_string(),
ConfigChecksum {
sha256: "ab".repeat(32),
mtime_secs: 42,
recorded_at: "2026-08-18T00:00:00Z".to_string(),
},
);
let text = serde_json::to_string(&sf).unwrap();
let back: StateFile = serde_json::from_str(&text).unwrap();
assert_eq!(back.config_checksums["user"].mtime_secs, 42);
}
}