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

nordisk/heimdall

crates/heimdall-trace/src/doc.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The trace document (`heimdall.trace.v1`, the job's one output
//! `trace.json`). Heimdall is a defensive sandbox: this is what a package
//! did while it was installed or imported with no network, as the in-job
//! tracer saw it.
//!
//! The document is **canonical JSON**: compact, the fields in the order of
//! these structs, every list a sorted set without duplicates. One trace has
//! exactly one encoding; [`Trace::from_canonical`] re-encodes what it parsed
//! and refuses anything that is not byte-identical, so a reader never acts on
//! a document two parsers could read differently. Its integrity comes from
//! the signed result: the file is the job's only output, listed with its
//! SHA-256 in the [`heimdall_core::SignedResult`] (see [`crate::verify_trace`]).
//!
//! Nothing in it carries a pid, a time or a count per entry, so two runs of
//! the same package give the same sets (what [`crate::diff`] compares).

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

/// The format name every trace starts with.
pub const FORMAT: &str = "heimdall.trace.v1";
/// The output name of the trace in the job's `/out`.
pub const TRACE_FILE: &str = "trace.json";
/// The default size bound of a trace document (4 MiB).
pub const DEFAULT_MAX_BYTES: u64 = 4 << 20;
/// The smallest size bound the tracer accepts (room for the header fields).
pub const MIN_MAX_BYTES: u64 = 4096;
/// The longest string (a path, an argument) a trace keeps; longer ones are
/// cut and end in [`CUT`].
pub const MAX_STR: usize = 1024;
/// The most arguments one exec entry keeps.
pub const MAX_ARGS: usize = 64;
/// What a cut string ends with.
pub const CUT: &str = "...[cut]";
/// Who held the job's seccomp USER_NOTIF listener: the tracer. The kernel
/// allows one listener per filter chain, so a trace job runs without the
/// broker's (its result says `broker: false`); file access stays with the
/// mounts and Landlock.
pub const LISTENER: &str = "heimdall-trace";

/// How complete a trace is.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    /// Every call of every traced process was seen and recorded.
    Complete,
    /// Every call was seen and answered, but the size bound dropped entries
    /// (`dropped` says how many): new behaviour may be among them.
    Truncated,
    /// The tracer could not trace (the reason is in `incomplete`): the
    /// document says nothing about the package. A reader treats it as
    /// Unknown, never as clean.
    Incomplete,
}

/// One `execve`/`execveat` a traced process asked for (whether it then
/// succeeded is not recorded: a missing program is an attempt too).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecEvent {
    /// The program that asked (`/proc/<tid>/exe` at the call).
    pub program: String,
    /// The path it asked to execute, made absolute.
    pub path: String,
    /// The argument vector (at most [`MAX_ARGS`], each at most [`MAX_STR`]).
    pub argv: Vec<String>,
    /// More arguments than [`MAX_ARGS`], or one could not be read.
    pub argv_cut: bool,
}

/// One `connect`, `bind`, `sendto` or `sendmsg` with an address.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetEvent {
    pub program: String,
    /// `connect`, `bind`, `sendto` or `sendmsg`.
    pub op: String,
    /// `inet`, `inet6`, `unix`, `netlink`, `packet`, or `family <n>`;
    /// `unreadable` when the address could not be read.
    pub family: String,
    /// `10.0.0.1:443`, `[::1]:53`, a unix path (`@name` when abstract),
    /// `pid <p> groups <g>` for netlink, empty when unreadable.
    pub address: String,
    /// The tracer refused the call (`EACCES`). Every call outside AF_UNIX is
    /// refused (a trace job has no network), and a unix `bind` whose socket
    /// file would land outside the writable paths.
    pub refused: bool,
}

/// One file call: an open, a create, a remove, a rename, a link, a mode or
/// owner change, a truncate.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileEvent {
    pub program: String,
    /// The call's name (`openat`, `mkdir`, `unlinkat`, `renameat2`, ...).
    pub op: String,
    /// The path, absolute and lexically normalised; for a write the parent
    /// directory's symlinks are resolved. `unreadable` when it could not be
    /// read.
    pub path: String,
    /// The open flags as hex (`0x80241`), empty for calls without them.
    pub flags: String,
    /// The call writes, creates, removes or changes something.
    pub write: bool,
    /// A write outside `/tmp` (the job's scratch) and `/dev/null`.
    pub outside_tmp: bool,
    /// The tracer refused the call (`EACCES`): every write outside the
    /// writable paths (`/tmp` unless the job said otherwise, and
    /// `/dev/null`), every write into `/out` (the trace's own place), and
    /// every write whose path could not be read.
    pub refused: bool,
}

/// One distinct call with its argument facts, as heimdall-probe records them
/// (`heimdall_probe::facts::SPECS`: socket domain/type/protocol, open flags,
/// ioctl requests, mmap/mprotect protections, clone flags, prctl options,
/// fcntl commands).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyscallFact {
    pub syscall: String,
    /// Argument index -> value as `0x` hex.
    pub args: BTreeMap<String, String>,
}

impl SyscallFact {
    /// The fact of one probe call.
    pub fn of(c: &heimdall_probe::Call) -> SyscallFact {
        SyscallFact {
            syscall: c.syscall.clone(),
            args: c
                .args
                .iter()
                .map(|(i, v)| (i.to_string(), format!("{v:#x}")))
                .collect(),
        }
    }
}

/// The whole trace.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
    /// Always [`FORMAT`].
    pub format: String,
    /// The traced command.
    pub argv: Vec<String>,
    pub status: Status,
    /// Why the trace is [`Status::Incomplete`] (empty otherwise).
    pub incomplete: String,
    /// How the traced command ended: `exit 0`, `killed by signal 9`,
    /// `killed by seccomp (SIGSYS)`, `timed out`, `not started`.
    pub exit: String,
    /// The tracer's own deadline passed and it killed every process.
    pub timed_out: bool,
    /// Processes still there when the command ended (or at the deadline),
    /// killed by the tracer before the trace was written.
    pub leftover_killed: u64,
    /// Notifications the tracer answered (every call of every traced process).
    pub notifications: u64,
    /// Entries left out by the size bound.
    pub dropped: u64,
    /// The size bound this document was written under.
    pub max_bytes: u64,
    /// Who held the seccomp listener: always [`LISTENER`].
    pub listener: String,
    pub exec: BTreeSet<ExecEvent>,
    pub net: BTreeSet<NetEvent>,
    pub files: BTreeSet<FileEvent>,
    /// Program -> every distinct call it made.
    pub syscalls: BTreeMap<String, BTreeSet<SyscallFact>>,
    /// Calls of a foreign arch or with a number the arch table does not name
    /// (`arch 0x40000003 nr 5`); the tracer refused them (`ENOSYS`).
    pub unknown: BTreeSet<String>,
}

/// Why a document is not a trace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocError {
    /// Not JSON of this shape.
    Parse(String),
    /// Valid, but not the one canonical encoding.
    NotCanonical,
    /// Another format name.
    Format(String),
    /// Larger than its own (or the reader's) bound.
    TooLarge { bytes: u64, max: u64 },
    /// Fields that contradict each other.
    Invalid(String),
}

impl std::fmt::Display for DocError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DocError::Parse(e) => write!(f, "the trace does not parse: {e}"),
            DocError::NotCanonical => f.write_str("the trace is not in its canonical encoding"),
            DocError::Format(v) => write!(f, "the trace format is {v:?}, not {FORMAT:?}"),
            DocError::TooLarge { bytes, max } => {
                write!(f, "the trace is {bytes} bytes, over its bound of {max}")
            }
            DocError::Invalid(e) => write!(f, "the trace is not valid: {e}"),
        }
    }
}

impl std::error::Error for DocError {}

impl Trace {
    /// An empty, complete trace of `argv` under `max_bytes`.
    pub fn new(argv: Vec<String>, max_bytes: u64) -> Trace {
        Trace {
            format: FORMAT.to_string(),
            argv,
            status: Status::Complete,
            incomplete: String::new(),
            exit: "not started".to_string(),
            timed_out: false,
            leftover_killed: 0,
            notifications: 0,
            dropped: 0,
            max_bytes,
            listener: LISTENER.to_string(),
            exec: BTreeSet::new(),
            net: BTreeSet::new(),
            files: BTreeSet::new(),
            syscalls: BTreeMap::new(),
            unknown: BTreeSet::new(),
        }
    }

    /// The canonical encoding.
    pub fn to_canonical(&self) -> Vec<u8> {
        serde_json::to_vec(self).expect("a trace serializes")
    }

    /// Read a trace, refusing anything but its canonical encoding, a foreign
    /// format, a document over its own `max_bytes` or over `reader_max`.
    pub fn from_canonical(bytes: &[u8], reader_max: u64) -> Result<Trace, DocError> {
        let n = bytes.len() as u64;
        if n > reader_max {
            return Err(DocError::TooLarge {
                bytes: n,
                max: reader_max,
            });
        }
        let t: Trace = serde_json::from_slice(bytes).map_err(|e| DocError::Parse(e.to_string()))?;
        if t.format != FORMAT {
            return Err(DocError::Format(t.format));
        }
        if t.to_canonical() != bytes {
            return Err(DocError::NotCanonical);
        }
        if n > t.max_bytes {
            return Err(DocError::TooLarge {
                bytes: n,
                max: t.max_bytes,
            });
        }
        t.validate()?;
        Ok(t)
    }

    /// The fields agree with each other.
    pub fn validate(&self) -> Result<(), DocError> {
        let bad = |s: &str| Err(DocError::Invalid(s.to_string()));
        match self.status {
            Status::Incomplete if self.incomplete.is_empty() => {
                return bad("incomplete without a reason")
            }
            Status::Complete | Status::Truncated if !self.incomplete.is_empty() => {
                return bad("a reason without status incomplete")
            }
            Status::Complete if self.dropped > 0 => return bad("complete but entries dropped"),
            Status::Truncated if self.dropped == 0 => return bad("truncated but nothing dropped"),
            _ => {}
        }
        if self.argv.is_empty() {
            return bad("no argv");
        }
        if self.listener != LISTENER {
            return bad("the listener is not the tracer");
        }
        if self.max_bytes < MIN_MAX_BYTES {
            return bad("max_bytes under the minimum");
        }
        if self.exec.iter().any(|e| e.argv.len() > MAX_ARGS) {
            return bad("an exec entry with too many arguments");
        }
        if self.files.iter().any(|f| f.outside_tmp && !f.write) {
            return bad("outside_tmp on a read");
        }
        Ok(())
    }

    /// The trace says something about the package: complete or truncated.
    pub fn is_usable(&self) -> bool {
        self.status != Status::Incomplete
    }
}

/// `s`, cut to [`MAX_STR`] bytes (on a char boundary) ending in [`CUT`].
pub fn cut(s: String) -> String {
    if s.len() <= MAX_STR {
        return s;
    }
    let mut end = MAX_STR - CUT.len();
    while !s.is_char_boundary(end) {
        end -= 1;
    }
    let mut t = s[..end].to_string();
    t.push_str(CUT);
    t
}