Skip to content

Commit 5d23c02

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(cli): emit audit events for box + image lifecycle (operability audit #5) (#131)
* fix(cli): emit audit events for box lifecycle (operability audit #5, core lifecycle) The audit log (reader + 'a3s-box audit' command + AuditLog writer) was fully built, defaults to enabled, but NO production code ever emitted an event — so the trail an operator relies on for forensics/compliance was always empty, silently. The AuditSink doc even claimed 'the runtime calls record whenever a security-relevant action occurs', which was false. Add a best-effort cli::audit helper (record/record_to) and wire it into the core box-lifecycle commands: create -> BoxCreate, stop -> BoxStop, rm -> BoxDestroy. Emission is best-effort (a failed audit write never fails the operation) and no-ops when AuditConfig.enabled is false. So 'a3s-box audit' now surfaces who created/stopped/removed boxes. run->BoxStart, exec->ExecCommand, and pull-> ImagePull have more complex multi-path flows and are a tracked follow-up. Tests: record_to_appends_a_readable_event (writer -> reader round-trip), record_to_is_silent_when_disabled. * fix(cli): also audit run->BoxStart and pull->ImagePull (operability audit #5) Extends the box-lifecycle audit wiring with the box-start and image-pull events — the other key forensic actions an operator audits. exec->ExecCommand (fg/pty multi-path) remains a tracked follow-up. * fix(cli): also audit exec->ExecCommand (operability audit #5) Wire the non-pty exec path so 'who exec'd into a box' is captured in the audit trail (recorded after the exec is delivered, before the container exit-code may std::process::exit). The pty exec path remains a tracked follow-up. * fix(cli): also audit the pty (exec -it) path -> ExecCommand (operability audit #5) Completes the exec audit: an interactive shell session (exec -it) now also emits an ExecCommand event, recorded once the pty request is delivered. The full audit trail is now create/run/stop/rm/pull/exec (both non-pty and pty). --------- Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 6cfd59b commit 5d23c02

8 files changed

Lines changed: 132 additions & 0 deletions

File tree

src/cli/src/audit.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! Best-effort audit-trail emission for box lifecycle events.
2+
//!
3+
//! The audit log (read with `a3s-box audit`) records security-relevant actions —
4+
//! who created/started/stopped/removed a box, and what was exec'd or pulled. The
5+
//! reader + CLI command and the `AuditLog` writer were fully built, but no
6+
//! production code ever emitted an event, so the trail was always empty. These
7+
//! helpers wire the writer into the lifecycle commands.
8+
//!
9+
//! Emission is **best-effort**: a failure to record the audit trail must never
10+
//! fail the operation it describes. Auditing is on by default
11+
//! (`AuditConfig::default().enabled == true`); `AuditLog::log` no-ops when it is
12+
//! disabled.
13+
14+
use a3s_box_core::audit::{AuditAction, AuditEvent, AuditOutcome};
15+
use a3s_box_runtime::AuditLog;
16+
17+
/// Emit one audit event to `log`, best-effort. Separated from [`record`] so the
18+
/// emission can be unit-tested against a temporary log.
19+
pub(crate) fn record_to(
20+
log: &AuditLog,
21+
action: AuditAction,
22+
outcome: AuditOutcome,
23+
box_id: &str,
24+
message: &str,
25+
) {
26+
let event = AuditEvent::new(action, outcome)
27+
.with_box_id(box_id)
28+
.with_message(message);
29+
let _ = log.log(&event);
30+
}
31+
32+
/// Emit one audit event to the default audit log (`~/.a3s/audit/audit.jsonl`),
33+
/// best-effort. A log that can't be opened is silently skipped.
34+
pub(crate) fn record(action: AuditAction, outcome: AuditOutcome, box_id: &str, message: &str) {
35+
if let Ok(log) = AuditLog::default_path() {
36+
record_to(&log, action, outcome, box_id, message);
37+
}
38+
}
39+
40+
#[cfg(test)]
41+
mod tests {
42+
use super::*;
43+
use a3s_box_core::audit::AuditConfig;
44+
use a3s_box_runtime::{read_audit_log, AuditQuery};
45+
46+
#[test]
47+
fn record_to_appends_a_readable_event() {
48+
let dir = tempfile::tempdir().unwrap();
49+
let path = dir.path().join("audit.jsonl");
50+
let log = AuditLog::new(&path, AuditConfig::default()).unwrap();
51+
52+
record_to(
53+
&log,
54+
AuditAction::BoxStop,
55+
AuditOutcome::Success,
56+
"box-123",
57+
"stopped via a3s-box stop",
58+
);
59+
60+
// The reader (a3s-box audit) must now surface the event — previously the
61+
// writer was never called so this list was always empty.
62+
let events = read_audit_log(&path, &AuditQuery::default()).unwrap();
63+
assert_eq!(events.len(), 1);
64+
assert_eq!(events[0].box_id.as_deref(), Some("box-123"));
65+
assert!(matches!(events[0].action, AuditAction::BoxStop));
66+
}
67+
68+
#[test]
69+
fn record_to_is_silent_when_disabled() {
70+
let dir = tempfile::tempdir().unwrap();
71+
let path = dir.path().join("audit.jsonl");
72+
let disabled = AuditConfig {
73+
enabled: false,
74+
..AuditConfig::default()
75+
};
76+
let log = AuditLog::new(&path, disabled).unwrap();
77+
78+
record_to(&log, AuditAction::BoxStart, AuditOutcome::Success, "b", "x");
79+
80+
// Disabled: nothing is written (no file, or an empty read).
81+
let events = read_audit_log(&path, &AuditQuery::default()).unwrap_or_default();
82+
assert!(events.is_empty());
83+
}
84+
}

src/cli/src/commands/create.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box<dyn std::error::Error>>
185185
return Err(error);
186186
}
187187

188+
crate::audit::record(
189+
a3s_box_core::audit::AuditAction::BoxCreate,
190+
a3s_box_core::audit::AuditOutcome::Success,
191+
&box_id,
192+
&format!("created box {name}"),
193+
);
188194
println!("{box_id}");
189195
Ok(())
190196
}

src/cli/src/commands/exec.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ pub async fn execute(args: ExecArgs) -> Result<(), Box<dyn std::error::Error>> {
151151
};
152152

153153
let output = client.exec_command(&request).await?;
154+
// Record that an exec happened (best-effort) before the exit-code branch
155+
// below may std::process::exit. The container command's own exit code is
156+
// separate from whether the exec was delivered.
157+
crate::audit::record(
158+
a3s_box_core::audit::AuditAction::ExecCommand,
159+
a3s_box_core::audit::AuditOutcome::Success,
160+
&record.id,
161+
&format!("exec command in box {}", record.name),
162+
);
154163

155164
if !output.stdout.is_empty() {
156165
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -203,6 +212,14 @@ async fn execute_pty(
203212
rows,
204213
};
205214
client.send_request(&request).await?;
215+
// Record the interactive (pty) exec once the request is delivered — opening
216+
// a shell in a box is a key forensic event.
217+
crate::audit::record(
218+
a3s_box_core::audit::AuditAction::ExecCommand,
219+
a3s_box_core::audit::AuditOutcome::Success,
220+
&record.id,
221+
&format!("exec (pty) in box {}", record.name),
222+
);
206223

207224
// Split the PTY client stream for concurrent read/write
208225
let (read_half, write_half) = client.into_split();

src/cli/src/commands/pull.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ pub async fn execute(args: PullArgs) -> Result<(), Box<dyn std::error::Error>> {
8989
}));
9090
}
9191
let image = puller.pull(&args.image).await?;
92+
crate::audit::record(
93+
a3s_box_core::audit::AuditAction::ImagePull,
94+
a3s_box_core::audit::AuditOutcome::Success,
95+
&args.image,
96+
&format!("pulled image {}", args.image),
97+
);
9298

9399
if args.quiet {
94100
println!("{}", image.root_dir().display());

src/cli/src/commands/rm.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ fn rm_one(
7171
// in-memory handle consistent without a second persisting write.
7272
StateFile::remove_record(&box_id)?;
7373
state.forget(&box_id);
74+
crate::audit::record(
75+
a3s_box_core::audit::AuditAction::BoxDestroy,
76+
a3s_box_core::audit::AuditOutcome::Success,
77+
&box_id,
78+
&format!("removed box {name}"),
79+
);
7480
println!("{name}");
7581

7682
Ok(())

src/cli/src/commands/run.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ pub async fn execute(args: RunArgs) -> Result<(), Box<dyn std::error::Error>> {
9191
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
9292

9393
let ctx = setup_and_boot(&args).await?;
94+
crate::audit::record(
95+
a3s_box_core::audit::AuditAction::BoxStart,
96+
a3s_box_core::audit::AuditOutcome::Success,
97+
&ctx.box_id,
98+
&format!("started box from image {}", args.common.image),
99+
);
94100
if args.detach {
95101
println!("{}", ctx.box_id);
96102
return Ok(());

src/cli/src/commands/stop.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ async fn stop_one(
102102
}
103103
Ok::<(), std::io::Error>(())
104104
})?;
105+
crate::audit::record(
106+
a3s_box_core::audit::AuditAction::BoxStop,
107+
a3s_box_core::audit::AuditOutcome::Success,
108+
&box_id,
109+
&format!("stopped box {name}"),
110+
);
105111
println!("{name}");
106112

107113
Ok(())

src/cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! A3S Box CLI - Docker-like MicroVM runtime.
22
3+
pub mod audit;
34
pub mod boot;
45
pub mod cleanup;
56
pub mod commands;

0 commit comments

Comments
 (0)