Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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
    }
}