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.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -3,6 +3,7 @@
|
||||
pub mod agents_cmd;
|
||||
pub mod alias_cmd;
|
||||
pub mod annotations_cmd;
|
||||
pub mod audit_cmd;
|
||||
pub mod catalog_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
@@ -143,7 +144,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
force,
|
||||
} => install_cmd::run(app, agent, method.as_deref(), *force),
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
Command::Update { agent, all, rollback } => {
|
||||
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
|
||||
}
|
||||
Command::Audit => audit_cmd::run(app),
|
||||
Command::Search { keyword, category, tag } => {
|
||||
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
|
||||
}
|
||||
|
||||
@@ -119,6 +119,21 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "audit",
|
||||
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
|
||||
options: &[("--json", "rapport machine-readable")],
|
||||
example: "audit --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update --rollback",
|
||||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||||
options: &[
|
||||
("--rollback list", "liste les points de restauration"),
|
||||
("--rollback <id>", "restaure un point précis"),
|
||||
],
|
||||
example: "update --rollback list",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "install <agent>",
|
||||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reinstall when an update exists.
|
||||
|
||||
use super::*;
|
||||
use crate::backup;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::{Runner, SystemRunner};
|
||||
@@ -9,7 +10,17 @@ use anyhow::{bail, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent_name: Option<&str>,
|
||||
all: bool,
|
||||
rollback: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
// Issue #64: --rollback restores a pre-update backup instead of updating.
|
||||
if rollback.is_some() {
|
||||
return rollback_point(app, rollback);
|
||||
}
|
||||
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
|
||||
let policy = app.config.settings.update_policy.unwrap_or_default();
|
||||
@@ -28,6 +39,16 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
let entries = app.state.all()?;
|
||||
let names: Vec<String> = entries.keys().cloned().collect();
|
||||
let mut failed = 0;
|
||||
// Issue #64: one snapshot covers the whole batch.
|
||||
if !app.dry_run() && !names.is_empty() {
|
||||
let info = backup::create_backup(app, "update:--all", &names)?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason("update:--all"),
|
||||
);
|
||||
}
|
||||
for name in names {
|
||||
if let Err(e) = update_one(app, &runner, &name) {
|
||||
failed += 1;
|
||||
@@ -124,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
|
||||
return Ok(0);
|
||||
}
|
||||
// Issue #64: snapshot before the real update so a broken upgrade can be
|
||||
// undone with 'am update --rollback'.
|
||||
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason("pre-update"),
|
||||
);
|
||||
crate::deps::ensure_deps(app, agent, runner)?;
|
||||
let outcome = installers::run_install(app, agent, method, runner)?;
|
||||
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
|
||||
@@ -147,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
|
||||
/// '--rollback list' prints the available backup points.
|
||||
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
|
||||
if point == Some("list") {
|
||||
let all = backup::list_backups(app);
|
||||
if all.is_empty() {
|
||||
app.log.info("no backup found — run an update first to create one");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("pre-update backups ({}):", all.len());
|
||||
for b in &all {
|
||||
println!(
|
||||
" {} {} agents={}{}",
|
||||
b.id,
|
||||
b.reason,
|
||||
if b.agents.is_empty() {
|
||||
"all".to_string()
|
||||
} else {
|
||||
b.agents.join(",")
|
||||
},
|
||||
if b.state && b.config { " [state+config]" } else { "" }
|
||||
);
|
||||
}
|
||||
println!("\nuse 'am update --rollback <id>' to restore one");
|
||||
return Ok(0);
|
||||
}
|
||||
let info = backup::restore_backup(app, point)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Rollback)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason(&info.reason),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"rolled back to backup {} ({})",
|
||||
info.id, info.reason
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user