crates/heimdall-cli/src/trace.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! `heimdall trace run|verify|diff`: the dynamic-trace job (heimdall-trace).
//! **Heimdall is a defensive sandbox.**
//!
//! - `run`: trace a command (a package's install or import) in a
//! HeimdallRunner job with no network; writes the signed result and the
//! trace, prints the outcome as one JSON line.
//! - `verify`: verify a trace job's signed result and its `trace.json`.
//! - `diff`: a trace against a baseline trace; prints what is new.
//!
//! Exit codes: 0 traced (or a clean diff), 1 Unknown (or new behaviour), 2
//! usage, 3 a file or the runner could not be read or started.
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::{Parser, Subcommand};
use heimdall_core::{ImageRef, SignedResult};
use heimdall_run::verify::{load_pinned_key, read_result_file};
use heimdall_trace::doc::DEFAULT_MAX_BYTES;
use heimdall_trace::{diff, run_trace, verify_trace, Trace, TraceJob, TraceOutcome};
use crate::{EXIT_HOST, EXIT_OK, EXIT_REJECTED, EXIT_USAGE};
#[derive(Parser, Debug)]
#[command(
name = "heimdall trace",
about = "Heimdall is a defensive sandbox: trace a package's install or import with no network, verify a trace, diff it against a baseline."
)]
struct TraceCli {
#[command(subcommand)]
verb: Verb,
}
#[derive(Subcommand, Debug)]
enum Verb {
/// Trace a command in a HeimdallRunner job (no network); the signed result and trace.json.
Run(Box<RunArgs>),
/// Verify a trace job's signed result and the trace.json beside it.
Verify(VerifyArgs),
/// What a trace does that its baseline does not (exit 1 when anything, or not complete).
Diff(DiffArgs),
}
#[derive(clap::Args, Debug)]
struct RunArgs {
/// The local OCI image layout.
#[arg(long)]
layout: PathBuf,
/// The image's manifest digest (sha256:<hex>): a trace image, or a worker
/// image when --tracer adds the tracer layer.
#[arg(long)]
image: String,
/// Add this static heimdall-trace binary to --image as one more layer first.
#[arg(long)]
tracer: Option<PathBuf>,
/// The input dir (the package), mounted read-only at /in.
#[arg(long = "in")]
input: PathBuf,
/// The out dir; it must not exist yet (trace.json lands there).
#[arg(long)]
out: PathBuf,
/// Where to write the signed result (canonical wire bytes).
#[arg(long)]
result: PathBuf,
/// The runner's work dir (default: <out>.work).
#[arg(long)]
work: Option<PathBuf>,
/// Write the runner's pinned key record here.
#[arg(long = "pin-out")]
pin_out: Option<PathBuf>,
#[arg(long = "wall-secs", default_value_t = 120)]
wall_secs: u64,
#[arg(long = "memory-mib", default_value_t = 1024)]
memory_mib: u64,
#[arg(long, default_value_t = 256)]
pids: u32,
#[arg(long = "max-bytes", default_value_t = DEFAULT_MAX_BYTES)]
max_bytes: u64,
/// K=V added to the command's environment; may repeat.
#[arg(long = "env")]
env: Vec<String>,
/// Where the command may write (default /tmp); may repeat.
#[arg(long = "writable")]
writable: Vec<String>,
/// The command and its arguments (after --).
#[arg(last = true, required = true)]
argv: Vec<String>,
}
#[derive(clap::Args, Debug)]
struct VerifyArgs {
/// The signed result file.
result: PathBuf,
/// The pinned public key: a file holding the record, or the record itself.
#[arg(long)]
key: String,
/// The job's out dir (holds trace.json and nothing else).
#[arg(long)]
files: PathBuf,
#[arg(long = "max-bytes", default_value_t = DEFAULT_MAX_BYTES)]
max_bytes: u64,
}
#[derive(clap::Args, Debug)]
struct DiffArgs {
/// The baseline trace.json (a clean package's trace).
baseline: PathBuf,
/// The trace.json to check.
trace: PathBuf,
#[arg(long = "max-bytes", default_value_t = DEFAULT_MAX_BYTES)]
max_bytes: u64,
}
pub fn main(rest: Vec<String>) -> u8 {
let cli =
match TraceCli::try_parse_from(std::iter::once("heimdall trace".to_string()).chain(rest)) {
Ok(c) => c,
Err(e) => {
let _ = e.print();
return if e.use_stderr() { EXIT_USAGE } else { EXIT_OK };
}
};
match cli.verb {
Verb::Run(a) => run(*a),
Verb::Verify(a) => verify(a),
Verb::Diff(a) => diff_cmd(a),
}
}
fn print_outcome(o: &TraceOutcome) -> u8 {
let v = match o {
TraceOutcome::Traced { trace, job_code } => serde_json::json!({
"outcome": "traced",
"status": trace.status,
"exit": trace.exit,
"exec": trace.exec.len(),
"net": trace.net.len(),
"files": trace.files.len(),
"programs": trace.syscalls.len(),
"dropped": trace.dropped,
"job_code": job_code,
}),
TraceOutcome::Unknown { reason } => {
serde_json::json!({"outcome": "unknown", "reason": reason})
}
};
println!("{v}");
if o.is_unknown() {
EXIT_REJECTED
} else {
EXIT_OK
}
}
fn run(a: RunArgs) -> u8 {
let mut env = Vec::new();
for kv in &a.env {
match kv.split_once('=') {
Some((k, v)) if !k.is_empty() => env.push((k.to_string(), v.to_string())),
_ => {
eprintln!("heimdall trace: --env {kv:?} is not K=V");
return EXIT_USAGE;
}
}
}
if a.out.exists() {
eprintln!("heimdall trace: {} exists already", a.out.display());
return EXIT_USAGE;
}
let mut image = ImageRef {
name: "trace".into(),
digest: a.image.clone(),
};
if let Some(t) = &a.tracer {
image = match heimdall_trace::image::with_tracer(&a.layout, &image, t) {
Ok(i) => i,
Err(e) => {
eprintln!("heimdall trace: adding the tracer layer: {e}");
return EXIT_HOST;
}
};
}
let work = a
.work
.clone()
.unwrap_or_else(|| PathBuf::from(format!("{}.work", a.out.display())));
let runner = match heimdall_container::HeimdallRunner::new(
heimdall_container::HeimdallConfig::new(&a.layout, &work),
) {
Ok(r) => r,
Err(e) => {
eprintln!("heimdall trace: the runner did not start: {e}");
return EXIT_HOST;
}
};
if let Some(p) = &a.pin_out {
if let Err(e) = write_new(p, runner.pinned().to_text().as_bytes()) {
eprintln!("heimdall trace: {}: {e}", p.display());
return EXIT_HOST;
}
}
if let Err(e) = std::fs::create_dir_all(&a.out) {
eprintln!("heimdall trace: {}: {e}", a.out.display());
return EXIT_HOST;
}
let mut tj = TraceJob::new(image, a.argv.clone());
tj.env.extend(env);
if !a.writable.is_empty() {
tj.writable = a.writable.clone();
}
tj.max_bytes = a.max_bytes;
tj.limits.wall = Duration::from_secs(a.wall_secs);
tj.limits.memory_bytes = a.memory_mib << 20;
tj.limits.pids = a.pids;
let (signed, o) = run_trace(&runner, &tj, a.input.clone(), a.out.clone());
if let Some(s) = &signed {
if let Err(e) = write_new(&a.result, &s.to_bytes()) {
eprintln!("heimdall trace: {}: {e}", a.result.display());
return EXIT_HOST;
}
}
print_outcome(&o)
}
fn write_new(p: &Path, b: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(p)?;
f.write_all(b)
}
fn verify(a: VerifyArgs) -> u8 {
let key = match load_pinned_key(&a.key).and_then(|k| Ok(k.verifying_key()?)) {
Ok(k) => k,
Err(e) => {
eprintln!("heimdall trace: the key: {e}");
return EXIT_HOST;
}
};
let bytes = match read_result_file(&a.result) {
Ok(b) => b,
Err(e) => {
eprintln!("heimdall trace: {}: {e}", a.result.display());
return EXIT_HOST;
}
};
let o = match SignedResult::from_bytes(&bytes) {
Ok(s) => verify_trace(&s, &key, None, &a.files, a.max_bytes),
Err(e) => TraceOutcome::Unknown {
reason: format!("the signed result does not decode: {e}"),
},
};
print_outcome(&o)
}
fn read_trace(p: &Path, max: u64) -> Result<Trace, String> {
let b = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?;
Trace::from_canonical(&b, max).map_err(|e| format!("{}: {e}", p.display()))
}
fn diff_cmd(a: DiffArgs) -> u8 {
let (b, t) = match (
read_trace(&a.baseline, a.max_bytes),
read_trace(&a.trace, a.max_bytes),
) {
(Ok(b), Ok(t)) => (b, t),
(Err(e), _) | (_, Err(e)) => {
eprintln!("heimdall trace: {e}");
return EXIT_HOST;
}
};
let d = diff(&b, &t);
println!(
"{}",
serde_json::to_string_pretty(&d).expect("a diff serializes")
);
if d.is_clean() {
EXIT_OK
} else {
EXIT_REJECTED
}
}