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

nordisk/heimdall

crates/heimdall-container/src/genprof.rs

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

//! GENERATED seccomp profiles for [`crate::HeimdallRunner`] (spec §3, §5).
//!
//! Heimdall is a defensive sandbox. A job (its [`heimdall_core::Policy::seccomp`])
//! or the runner's config ([`crate::HeimdallConfig::seccomp`]) names a
//! profile that `heimdall profile gen` wrote, pinned by the SHA-256 of its OCI
//! seccomp JSON:
//!
//! - [`ProfileSource::Workload`]: a checked-in profile compiled into Heimdall
//!   (crate heimdall-profiles), the one for this arch whose digest matches;
//! - [`ProfileSource::OciFile`]: an OCI seccomp JSON on the runner's host
//!   (read by the runner, at most [`MAX_PROFILE_BYTES`], never by the job).
//!
//! [`resolve`] refuses a missing profile, a digest that differs, a profile
//! for another arch, one whose default is not `KILL_PROCESS` and one that
//! does not build, unless the reference says `fallback_to_default`: then the
//! job runs under the fixed profile ([`crate::profile`]) and its result says
//! [`heimdall_core::Protection::Default`].
//!
//! How it is applied: youki keeps installing the fixed profile (plus one
//! narrow `execveat` rule, [`crate::spec`]); the executor, inside the job
//! right before the job's program starts, installs the generated profile ON
//! TOP (heimdall-seccomp's BPF builder, `TSYNC`) and starts the program with
//! `execveat(EXEC_FD, "", .., AT_EMPTY_PATH)`, the same prelude the probe
//! used, so a generated profile fits unchanged. The kernel applies the
//! stricter of the two filters to every call. After the install the executor
//! reports the digest on its report fd ([`REPORT_FD`]; the only extra call the
//! generated filter allows), and the runner signs `Generated { profile_digest }`
//! only when that report came back.

use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;

use heimdall_core::{ProfileSource, RunError, SeccompProfileRef, Sha256Digest};
use heimdall_seccomp::bpf::build_rules;
use heimdall_seccomp::launch::prelude;
use heimdall_seccomp::{Action, Arch, ArgCmp, Libc, Profile, Rule};
use serde::{Deserialize, Serialize};

/// The largest OCI seccomp JSON file a runner reads.
pub const MAX_PROFILE_BYTES: u64 = 1 << 20;

/// The fd the executor reports on after the generated filter is installed
/// (close-on-exec; the job never holds it).
pub const REPORT_FD: i32 = 1020;

/// A generated profile ready to apply: its digest and its rules.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedPlan {
    /// `sha256:<hex>` of the profile's OCI seccomp JSON.
    pub digest: String,
    pub profile: Profile,
}

impl GeneratedPlan {
    /// The rule the executor adds in front of the profile: `write` on
    /// [`REPORT_FD`] only.
    pub fn report_rule() -> Rule {
        Rule {
            syscall: "write".into(),
            action: Action::Allow,
            when: vec![vec![ArgCmp::eq32(0, REPORT_FD as u32)]],
        }
    }
}

fn refuse(r: &SeccompProfileRef, why: impl std::fmt::Display) -> RunError {
    let what = match &r.source {
        ProfileSource::Workload(w) => format!("workload {w:?}"),
        ProfileSource::OciFile(p) => format!("file {}", p.display()),
    };
    RunError::InvalidJob {
        reason: format!("seccomp profile ({what}, {}): {why}", r.sha256),
    }
}

/// The native arch, or a refusal.
fn native() -> Result<Arch, String> {
    Arch::native().ok_or_else(|| "this arch has no seccomp profiles".to_string())
}

/// The checked-in profile `workload` for this arch whose OCI JSON hashes to
/// `digest`.
pub fn builtin(workload: &str, digest: &Sha256Digest) -> Result<Profile, String> {
    let arch = native()?;
    let mine: Vec<_> = heimdall_profiles::ALL
        .iter()
        .filter(|e| e.profile.workload == workload && e.profile.arch == arch)
        .collect();
    if mine.is_empty() {
        return Err(format!(
            "no checked-in profile for this workload on {arch} (missing)"
        ));
    }
    match mine
        .iter()
        .find(|e| Sha256Digest::of(e.oci_json.as_bytes()) == *digest)
    {
        Some(e) => Ok(Profile::from(e.profile)),
        None => Err(format!(
            "digest mismatch: the checked-in profiles for {arch} are {}",
            mine.iter()
                .map(|e| format!(
                    "{} {}",
                    e.profile.libc,
                    Sha256Digest::of(e.oci_json.as_bytes()).to_prefixed()
                ))
                .collect::<Vec<_>>()
                .join(", ")
        )),
    }
}

/// Read an OCI seccomp JSON file pinned by `digest`.
pub fn oci_file(path: &Path, digest: &Sha256Digest) -> Result<Profile, String> {
    if !path.is_absolute() {
        return Err("the path is not absolute".into());
    }
    let f = std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)
        .map_err(|e| format!("missing or unreadable: {e}"))?;
    let mut bytes = Vec::new();
    f.take(MAX_PROFILE_BYTES + 1)
        .read_to_end(&mut bytes)
        .map_err(|e| format!("read: {e}"))?;
    if bytes.len() as u64 > MAX_PROFILE_BYTES {
        return Err(format!("larger than {MAX_PROFILE_BYTES} bytes"));
    }
    let got = Sha256Digest::of(&bytes);
    if got != *digest {
        return Err(format!(
            "digest mismatch: the file hashes to {}",
            got.to_prefixed()
        ));
    }
    let text = std::str::from_utf8(&bytes).map_err(|_| "not UTF-8".to_string())?;
    // The libc is not in the OCI form; the file name of `heimdall profile
    // gen` (`<arch>-<libc>.oci.json`) says it, glibc otherwise (informational).
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    let libc = if name.contains("musl") {
        Libc::Musl
    } else {
        Libc::Glibc
    };
    let workload = path
        .parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .unwrap_or("oci-file");
    heimdall_seccomp::oci::from_oci(text, workload, libc).map_err(|e| e.to_string())
}

/// Resolve a profile reference: `Ok(Some(plan))` = apply it; `Ok(None)` =
/// it is missing or differs and the reference allows the fixed profile
/// (Default); `Err` = refuse the job.
pub fn resolve(r: &SeccompProfileRef) -> Result<Option<GeneratedPlan>, RunError> {
    let Some(digest) = r.digest() else {
        return Err(refuse(r, "the pin is not sha256:<64 lower-case hex>"));
    };
    let found = match &r.source {
        ProfileSource::Workload(w) => builtin(w, &digest),
        ProfileSource::OciFile(p) => oci_file(p, &digest),
    };
    let profile = match found {
        Ok(p) => p,
        Err(_) if r.fallback_to_default => return Ok(None),
        Err(e) => return Err(refuse(r, e)),
    };
    let arch = native().map_err(|e| refuse(r, e))?;
    if profile.arch != arch {
        return Err(refuse(
            r,
            format!("the profile is for {}, this runner is {arch}", profile.arch),
        ));
    }
    let mut all = prelude(false);
    all.push(GeneratedPlan::report_rule());
    all.extend(profile.rules.iter().cloned());
    build_rules(arch, &all, Action::KillProcess)
        .map_err(|e| refuse(r, format!("the profile does not build: {e}")))?;
    Ok(Some(GeneratedPlan {
        digest: digest.to_prefixed(),
        profile,
    }))
}