test(cli): audit the strace trace by syscall name, not errno

The SPEC bar for T6 is failure on a single hit from the denied set;
the errno-based check only caught attempts the snap's seccomp
allowlist had already denied, so a host mount that succeeded would
have passed. Exact-name matching over the traced call also stops
"mount" matching "umount2" lines.
This commit is contained in:
vhaudiquet committed 2026-10-30 03:26:07 +01:00
1 parent 4a9d148a4b
commit 8047106733
1 file changed
+59 -6
+59 -6
View File
@@ -328,6 +328,31 @@ fn fail(err: &anyhow::Error) -> std::process::ExitCode {
/// runs in CI (`cargo test -- --ignored`). /// runs in CI (`cargo test -- --ignored`).
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn traced_syscall_parses_strace_line_shapes() {
assert_eq!(
traced_syscall("1234 mount(\"/x\", \"/y\", 0) = 0"),
Some("mount")
);
assert_eq!(
traced_syscall("[pid 1234] umount2(\"/x\", 0) = 0"),
Some("umount2")
);
assert_eq!(
traced_syscall("1234 mount(\"/x\" <unfinished ...>"),
Some("mount")
);
// A resumed split half carries no call of its own: its '('
// was on the unfinished line above, which already named the
// syscall for the audit.
assert_eq!(
traced_syscall("[pid 1234] <... mount resumed>: \"/x\") = 0"),
None
);
assert_eq!(traced_syscall("strace: Process 1234 attached"), None);
assert_eq!(traced_syscall("++++ exited with 0 ++++"), None);
}
#[test] #[test]
#[ignore = "needs the ecrou binary, libkrun, and strace; runs in CI"] #[ignore = "needs the ecrou binary, libkrun, and strace; runs in CI"]
fn t6_no_mount_or_namespace_syscalls() { fn t6_no_mount_or_namespace_syscalls() {
@@ -355,13 +380,41 @@ mod tests {
assert!(out.success(), "the traced run failed; see the strace log"); assert!(out.success(), "the traced run failed; see the strace log");
let trace = std::fs::read_to_string(&trace_path).unwrap(); let trace = std::fs::read_to_string(&trace_path).unwrap();
let _ = std::fs::remove_file(&trace_path); let _ = std::fs::remove_file(&trace_path);
// The SPEC's bar is zero hits, not zero failures: a mount that
// succeeded is as much a violation as one the snap's seccomp
// allowlist would have denied. The trace contains only the
// traced syscalls, so an exact name at the head of a line is a
// hit; exact comparison also keeps `mount` from matching
// `umount2`. Resumed-split lines (`<... mount resumed>`) carry
// the name behind a `<... ` prefix.
let denied: Vec<&str> = denied.split(',').collect();
for line in trace.lines() { for line in trace.lines() {
let denied_call = denied.split(',').any(|syscall| line.contains(syscall)); let head = traced_syscall(line);
let failed = line.contains("= -1 EPERM") || line.contains("= -1 ENOSYS"); if let Some(head) = head {
assert!( if denied.contains(&head) {
!(denied_call && failed), panic!("denied syscall in the trace: {line}");
"denied syscall in the trace: {line}"
);
} }
} }
} }
}
/// The syscall name a strace output line traces, or None for
/// chatter. Line shapes covered: `1234 mount(…)`,
/// `[pid 1234] umount2(…)`, unfinished split halves, timestamps,
/// and lines without a call (`++++ exited with 0 ++++`). The call
/// is the last identifier-like token before the parenthesis; a
/// pid or a timestamp never qualifies (digits alone or
/// punctuation). A resumed split half has no parenthesis — the
/// unfinished half that names the call always precedes it, so
/// None loses the audit nothing.
fn traced_syscall(line: &str) -> Option<&str> {
let open = line.find('(')?;
let tokens: Vec<&str> = line[..open].split_whitespace().collect();
let is_call = |t: &str| {
!t.is_empty()
&& t.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
};
tokens.iter().rev().find(|t| is_call(t)).copied()
}
}