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.
333 lines
11 KiB
Rust
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, ¤t, 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);
|
|
}
|
|
}
|