server/lib/src/import_door.rs
//! **The box's import doors** (lane W4): signed intel bundles and signed
//! update / airgap bundles reach a box that has no shell.
//!
//! A Holger box runs as PID 1 and has no shell, so `holger-server intel import`
//! and `holger-server bundle import` could not be run on it. Every door below
//! runs ONE body of code, the same one those two CLI verbs now call:
//!
//! | door | intel bundle | update / airgap bundle |
//! |---|---|---|
//! | gRPC | `AdminService.ImportIntelBundle` (client stream) | `AdminService.ImportBundle` (client stream) |
//! | HTTP | `POST /-/intel/import` | `POST /-/bundle/import` |
//! | drop directory | `<data_root>/intel.inbox`, swept by a jera job | `<data_root>/bundle.inbox`, swept by a jera job |
//! | CLI | `holger-server intel import` | `holger-server bundle import` |
//!
//! **Who may import.** A Repository Admin or a Security Approver
//! ([`Verb::Import`]); anyone else is refused by name (`holger-duty-2`). With
//! RBAC off every caller holds every duty, as everywhere else in the intake
//! chain. The drop directories act as the box itself (the operator who can put a
//! file on the data disk already holds the box).
//!
//! **What an import does** — in this order, fail-closed at every step, the
//! order `bundle import` has kept since B3:
//!
//! 1. verify the bundle in a `verify-bundle` JOB (lane HOL-SANDBOX-TRAIT,
//! [`crate::job_runner`]): the server links the file into the job's input and
//! never opens it; the job checks the release signature from the control members
//! before any payload is decompressed (`load_bundle_verified`), every member, and
//! an export's evidence attestations, and hands back the verified files. The
//! server believes only the job's signed result (the pinned per-boot key, the job
//! id it issued, the listed outputs) and keeps the bytes that verified;
//! 2. a refused job is audited with its reason and answered by name;
//! 3. verify the whole bundle against the trust policy and the estate's clock;
//! 4. hold it to the anti-rollback floor. An absent floor is established only by a
//! first import an ADMIN authorized on this side for exactly this vendor, product and
//! sequence ([`ImportDoor::allow_first_import`]: `holger bundle allow-first-import`,
//! `POST /-/bundle/first-import`; lane HOL-CDS, NCSC gap 2); otherwise it refuses
//! (`holger-import-9`). There is no caller flag and no inbox marker any more
//! (`holger-import-10`);
//! 5. write the audit record BEFORE anything lands; no record, no import;
//! 6. intel: make the advisory set the live local database, which the next scan
//! reads. Bundle: land every artifact AVAILABLE **through the intake chain**
//! ([`IntakeChain::land_imported`]), never into a raw repository's data
//! directory. An airgap export lands each of its artifacts with the evidence
//! and history it carries; any other update bundle lands as one artifact, the
//! signed bundle file itself, at `<supplier>/<product>/<release>.holger.znippy`
//! in the production repository.
//!
//! **The removed bypass.** `bundle import --repo <raw>` wrote the bundle straight
//! into a raw repository's `ron_data_dir`, past every intake state. That path is
//! gone; naming it is refused by name ([`IMPORT_RAW_BYPASS_REMOVED`]), and so is
//! a bundle import on a server with no intake chain.
//!
//! **The drop directories.** An operator copies a bundle onto the data disk
//! through the estate's file path. The sweep job imports every file in the
//! inbox, oldest name first, and moves it to `archive/` (imported) or `refused/`
//! (with a `.why.txt` beside it that names the refusal). A file still being
//! copied is written under a name starting with `.` or ending in `.part` and
//! renamed when complete; the sweep skips both. A `FIRST-IMPORT` marker file is
//! no authorization: the sweep moves it to `refused/` with its why (`holger-import-10`).
//!
//! [`Verb::Import`]: crate::promote::duties::Verb::Import
//! [`IntakeChain::land_imported`]: crate::promote::intake::IntakeChain::land_imported
//! [`IMPORT_RAW_BYPASS_REMOVED`]: holger_errcode::IMPORT_RAW_BYPASS_REMOVED
use crate::lock::LockOrRecover as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use holger_errcode::ErrCode;
use serde::{Deserialize, Serialize};
#[cfg(feature = "intake")]
use update_bundle::container::{atomic_write, load_bundle_verified};
use update_bundle::core::BundleCore;
#[cfg(feature = "intake")]
use update_bundle::digest::sha256_hex;
#[cfg(feature = "intake")]
use update_bundle::receipt::ImportReceipt;
#[cfg(feature = "intake")]
use update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
#[cfg(feature = "intake")]
use update_bundle::schema::CORE_JSON;
#[cfg(feature = "intake")]
use update_bundle::verify::verify_member_map_with_time;
use update_bundle::verify::TargetSpec;
use update_bundle::TrustPolicy;
#[cfg(feature = "intake")]
use crate::audit::TrailKind;
use crate::audit::{AuditAction, AuditEvent, AuditLog, AuditMode};
#[cfg(feature = "intake")]
use crate::auth::{AuthConfig, AuthIdentity};
use crate::intel::IntelConfig;
#[cfg(feature = "intake")]
use crate::promote::airgap;
#[cfg(feature = "intake")]
use crate::promote::duties::{self, DutyError, Verb};
#[cfg(feature = "intake")]
use crate::promote::intake::{ImportedArtifact, IntakeChain, IntakeEntry, LandOutcome};
/// The intel drop directory under the data root.
pub const INTEL_INBOX: &str = "intel.inbox";
/// The update / airgap bundle drop directory under the data root (the one-way inbox,
/// [`crate::boundary::INBOX_DIR`]).
pub const BUNDLE_INBOX: &str = crate::boundary::INBOX_DIR;
/// The source name an airgap import's provenance check reads its gate by (lane G3a).
pub const AIRGAP_SOURCE: &str = "airgap";
/// The REMOVED inbox marker (lane HOL-CDS): it once let the next import establish an
/// absent floor. A file of this name is refused now (`holger-import-10`).
pub const FIRST_IMPORT_MARKER: &str = "FIRST-IMPORT";
/// Where the admins' first-import authorizations live, under the data root.
pub const FIRST_IMPORT_DIR: &str = "holger-first-import";
/// The authorizations file in [`FIRST_IMPORT_DIR`].
pub const FIRST_IMPORT_GRANTS: &str = "grants.json";
/// **An admin's first-import authorization** (lane HOL-CDS, NCSC gap 2): this vendor's
/// product may establish its absent anti-rollback floor with exactly this sequence. Used
/// once; audited when given and when used.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FirstImportGrant {
pub vendor: String,
pub product: String,
pub sequence: u64,
/// Who authorized it.
pub by: String,
/// Unix seconds.
pub at: i64,
}
/// The refusal of a caller's `first_import` flag (or an inbox marker), by name.
pub fn first_import_flag_refusal(what: &str) -> ImportError {
ImportError::new(
&holger_errcode::IMPORT_FIRST_FLAG_REMOVED,
400,
format!(
"{what} is removed: the first import is an admin's action on this side, for one vendor, product and \
sequence (`holger bundle allow-first-import --vendor V --product P --sequence N`, the console, or \
`POST /-/bundle/first-import`); then import the bundle as usual"
),
)
}
/// Where an imported inbox file is moved.
pub const ARCHIVE_DIR: &str = "archive";
/// Where a refused inbox file is moved, with a `.why.txt` beside it.
pub const REFUSED_DIR: &str = "refused";
/// The product of a scanner signature bundle (`holger_intel::sigs::SIGS_PRODUCT`, lane
/// HOL-NCSC): `bundle import` installs it into the sandboxed clamd's database.
pub const SCANNER_SIGS_PRODUCT: &str = "holger.scanner-sigs/v1";
/// Where the network doors stage a streamed bundle before it is imported.
pub const STAGING_DIR: &str = ".holger-import-staging";
/// The anti-rollback floor of imported update bundles, under the data root.
pub const UPDATE_STATE_DIR: &str = "holger-update-state";
/// The import receipts of update bundles, under the data root.
pub const RECEIPTS_DIR: &str = "holger-import-receipts";
/// How often the server looks into the drop directories. A look is a
/// directory listing; a sweep job is queued only when a file waits.
pub const INBOX_POLL: std::time::Duration = std::time::Duration::from_secs(2);
/// How often an online box refreshes its intel (the first tick is at boot).
pub const INTEL_REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
// ───────────────────────── the estate's clock ─────────────────────────
/// **The bundle verifier's time source: the ESTATE's clock** (`crate::clock`,
/// edda `estate-clock`). Every expiry the verifier checks is checked against
/// [`crate::clock::now_unix_ms`], and the verdict names the source.
///
/// * a verified answer stands behind the clock → trusted, the peer's time;
/// * no clock source is configured at all → the system clock, recorded
/// `estate-clock (UNTRUSTED …)` in the verdict;
/// * a source IS configured and nothing verified answered → REFUSED
/// ([`TimeError::Unverified`](update_bundle::time::TimeError::Unverified)): a
/// box told to follow a clock that cannot is not a box without one.
pub struct EstateTime;
impl update_bundle::time::TrustedTime for EstateTime {
fn source_name(&self) -> &'static str {
crate::clock::SOURCE_NAME
}
fn is_trusted(&self) -> bool {
crate::clock::is_trusted()
}
fn now(
&self,
) -> std::result::Result<chrono::DateTime<chrono::Utc>, update_bundle::time::TimeError> {
use update_bundle::time::TimeError;
if !crate::clock::is_trusted() {
let last = || {
crate::clock::last_summary()
.unwrap_or_else(|| "no sync has run in this process".into())
};
match crate::clock::config_now() {
Ok(crate::clock::Config::Sources { .. }) => {
return Err(TimeError::Unverified(last()))
}
Err(why) => return Err(TimeError::Unverified(why)),
Ok(_) => {}
}
}
let ms = crate::clock::now_unix_ms();
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms).ok_or(TimeError::OutOfRange(ms))
}
}
#[cfg(feature = "intake")]
fn estate_now() -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(crate::clock::now_unix_ms())
.unwrap_or_else(chrono::Utc::now)
}
// ───────────────────────── the two kinds ─────────────────────────
/// Which import.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportKind {
/// A `holger.intel/v1` bundle into the local advisory database.
Intel,
/// An update or airgap bundle into the intake chain.
Bundle,
}
impl ImportKind {
/// The drop directory's name under the data root.
pub fn inbox_name(self) -> &'static str {
match self {
ImportKind::Intel => INTEL_INBOX,
ImportKind::Bundle => BUNDLE_INBOX,
}
}
/// The word the duty refusal names (`import an intel bundle`).
pub fn what(self) -> &'static str {
match self {
ImportKind::Intel => "intel",
ImportKind::Bundle => "update",
}
}
pub fn as_str(self) -> &'static str {
match self {
ImportKind::Intel => "intel",
ImportKind::Bundle => "bundle",
}
}
}
// ───────────────────────── refusals ─────────────────────────
/// An import refused by name. Nothing landed.
#[derive(Debug, Clone)]
pub struct ImportError {
pub code: &'static ErrCode,
pub message: String,
/// The HTTP status a door answers with.
pub status: u16,
}
impl ImportError {
pub(crate) fn new(code: &'static ErrCode, status: u16, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
status,
}
}
fn refused(message: impl std::fmt::Display) -> Self {
Self::new(&holger_errcode::IMPORT_REFUSED, 422, format!("{message}"))
}
/// `[holger-<sub>-<n>] message`.
pub fn tagged(&self) -> String {
self.code.tag(&self.message)
}
}
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.tagged())
}
}
impl std::error::Error for ImportError {}
#[cfg(feature = "intake")]
impl From<DutyError> for ImportError {
fn from(e: DutyError) -> Self {
ImportError {
code: e.code(),
status: e.status(),
message: e.to_string(),
}
}
}
/// The refusal of the removed raw-directory path, by name.
pub fn raw_bypass_refusal(repo: &str) -> ImportError {
ImportError::new(
&holger_errcode::IMPORT_RAW_BYPASS_REMOVED,
410,
format!(
"`bundle import --repo {repo}` wrote the bundle straight into the raw repository's data \
directory and bypassed the intake chain; that path is removed. Declare `promotion.intake` \
and import without --repo: every artifact then lands AVAILABLE through intake."
),
)
}
// ───────────────────────── answers ─────────────────────────
/// What an intel import did.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntelImported {
/// `new` or `idempotent-reimport`.
pub outcome: String,
pub bundle_id: String,
pub sequence: u64,
/// `YYYY-MM-DD` of the advisory snapshot.
pub snapshot_at: String,
pub advisories: u64,
pub database: String,
pub checks: Vec<String>,
}
/// One artifact an update bundle landed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LandedArtifact {
pub artifact: String,
pub sha256: String,
/// `AVAILABLE`.
pub state: String,
pub repository: String,
/// `landed` or `already-available`.
pub outcome: String,
}
/// What a bundle import did.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleImported {
/// `new` or `idempotent-reimport`.
pub outcome: String,
pub bundle_id: String,
pub sequence: u64,
/// `airgap-export` or `update-bundle`.
pub kind: String,
pub artifacts: Vec<LandedArtifact>,
pub receipt: String,
pub checks: Vec<String>,
/// H4: the bundle carried a sync manifest and it was applied; the signed ack's file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_ack: Option<String>,
/// H4: the packages the carried manifest's invalidations held here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sync_held: Vec<String>,
}
/// What one inbox sweep did.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct InboxSweep {
pub imported: Vec<String>,
pub refused: Vec<(String, String)>,
}
impl InboxSweep {
pub fn summary(&self) -> String {
format!(
"imported {} [{}], refused {} [{}]",
self.imported.len(),
self.imported.join(", "),
self.refused.len(),
self.refused
.iter()
.map(|(f, why)| format!("{f}: {why}"))
.collect::<Vec<_>>()
.join("; ")
)
}
}
// ───────────────────────── the door ─────────────────────────
/// The intel audit sink: the server's audit log, fail-closed.
#[cfg(feature = "import-door")]
struct ServerAudit<'a> {
log: &'a Arc<dyn AuditLog>,
actor: &'a str,
source: &'a str,
}
#[cfg(feature = "import-door")]
impl holger_intel::ImportAudit for ServerAudit<'_> {
fn ready(&self) -> anyhow::Result<()> {
if self.log.mode() == AuditMode::Off {
anyhow::bail!("audit is off or its directory could not be opened");
}
Ok(())
}
fn record(&self, r: &holger_intel::ImportRecord) -> anyhow::Result<()> {
let ev = AuditEvent::new(
self.actor,
AuditAction::Other,
"intel",
format!("{}/{}", r.supplier, r.bundle_id),
self.source,
200,
r.advisories,
)
.with_detail(format!("intel-import: {}", r.detail()));
self.log
.record(ev)
.and_then(|()| self.log.flush())
.map_err(|e| anyhow::anyhow!("{e:?}"))
}
}
/// **The import door** — one per served process, shared by both network doors
/// and the inbox jobs; the CLI builds its own over the same config.
pub struct ImportDoor {
intel: Option<IntelConfig>,
#[cfg(feature = "intake")]
intake: Option<Arc<IntakeChain>>,
audit: Arc<dyn AuditLog>,
#[cfg(feature = "intake")]
attest: Option<Arc<crate::attest::AttestStore>>,
/// The server's ONE job runner (lane HOL-SANDBOX-TRAIT): every bundle is verified
/// in a `verify-bundle` job, never in this process.
#[cfg(feature = "intake")]
jobs: Option<Arc<crate::job_runner::Jobs>>,
#[cfg_attr(not(feature = "intake"), allow(dead_code))]
bundle_policy: Option<PathBuf>,
data_root: PathBuf,
/// One import at a time per process: the floor is read, judged and moved
/// as one step.
serial: Mutex<()>,
/// The monotone time floor every judgement here is taken at (lane HOL-NCSC).
time_floor: crate::time_floor::TimeFloor,
}
impl ImportDoor {
/// The door over `holger`'s config: its `intel:` block, its intake chain,
/// its audit sink and attestation store, under its data root.
pub fn of(holger: &crate::Holger) -> anyhow::Result<Self> {
let audit = holger.audit_log()?;
#[cfg(feature = "intake")]
let intake = holger.intake_chain(audit.clone())?;
#[cfg(feature = "intake")]
let bundle_policy = intake
.as_ref()
.and_then(|c| c.settings().trust_policy.clone())
.map(PathBuf::from);
#[cfg(not(feature = "intake"))]
let bundle_policy = None;
Ok(Self {
intel: holger.intel.clone(),
#[cfg(feature = "intake")]
intake,
#[cfg(feature = "intake")]
attest: holger.attestation_store()?,
#[cfg(feature = "intake")]
jobs: holger.jobs()?,
audit,
bundle_policy,
time_floor: crate::time_floor::TimeFloor::open(&holger.data_root())
.map_err(|e| anyhow::anyhow!("{e}"))?,
data_root: holger.data_root(),
serial: Mutex::new(()),
})
}
/// The same door over `jobs` (a test's runner).
#[cfg(feature = "intake")]
pub fn with_jobs(mut self, jobs: Arc<crate::job_runner::Jobs>) -> Self {
self.jobs = Some(jobs);
self
}
/// The estate's clock judged at this box's time floor (lane HOL-NCSC).
#[cfg_attr(not(feature = "intake"), allow(dead_code))]
fn time(&self) -> crate::time_floor::FlooredTime<'_> {
crate::time_floor::FlooredTime::new(
&self.time_floor,
&EstateTime,
Some(Arc::clone(&self.audit)),
)
}
/// The time floor (its file, its value), for a status line.
pub fn time_floor(&self) -> &crate::time_floor::TimeFloor {
&self.time_floor
}
/// Raise the floor to a signed time this import accepted; a floor that cannot be kept is
/// logged (the import already stands).
#[cfg_attr(not(feature = "intake"), allow(dead_code))]
fn raise_floor(&self, unix_ms: i64, source: &str) -> bool {
match self.time_floor.raise(unix_ms, source) {
Ok(raised) => raised,
Err(e) => {
log::error!("{e}");
false
}
}
}
/// **A signed time token, verified** (`holger.time-token/v1`): it raises the time floor
/// and lands nothing. Audited first; a refusal is audited by `import_bundle` (HOL-NCSC).
#[cfg(feature = "intake")]
fn accept_time_token(
&self,
members: &update_bundle::MemberMap,
core: &BundleCore,
policy: &TrustPolicy,
actor: &str,
source: &str,
) -> Result<BundleImported, ImportError> {
if self.audit.mode() == AuditMode::Off {
return Err(ImportError::new(&holger_errcode::AIRGAP_AUDIT_FAILED, 409, "time token refused: the audit log is off, and a time that leaves no audit record is not taken"));
}
let report = verify_member_map_with_time(
members,
policy,
&TargetSpec::default(),
None,
&self.time(),
)
.map_err(|e| ImportError::refused(format!("time token refused: {}", e.code().tag(&e))))?;
let ms = crate::time_floor::token_time(members, core)
.map_err(|why| ImportError::new(&holger_errcode::TIME_TOKEN_REFUSED, 422, why))?;
let ev = AuditEvent::new(
actor,
AuditAction::Other,
"",
core.bundle_id.clone(),
source,
200,
0,
)
.with_detail(format!(
"time-token: bundle_id={} time={} digest={}",
core.bundle_id,
core.created_at.to_rfc3339(),
report.candidate_digest
));
self.audit
.record(ev)
.and_then(|()| self.audit.flush())
.map_err(|e| {
ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
500,
format!("time token refused: its audit record could not be written ({e:?})"),
)
})?;
let raised = self
.time_floor
.raise(ms, &format!("the time token {}", core.bundle_id))
.map_err(|why| ImportError::new(&holger_errcode::TIME_FLOOR_UNREADABLE, 500, why))?;
let said = format!(
"TIME FLOOR {} {} (the signed time token {}, signer verified)",
if raised {
"raised to"
} else {
"already at or past"
},
core.created_at.to_rfc3339(),
core.bundle_id
);
Ok(BundleImported {
outcome: if raised {
"time-floor-raised".into()
} else {
"time-floor-unchanged".into()
},
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
kind: "time-token".into(),
artifacts: vec![],
receipt: String::new(),
checks: vec![said],
sync_ack: None,
sync_held: vec![],
})
}
/// Does this server have an intake chain a bundle import can land in?
pub fn has_intake(&self) -> bool {
#[cfg(feature = "intake")]
{
self.intake.is_some()
}
#[cfg(not(feature = "intake"))]
{
false
}
}
pub fn data_root(&self) -> &Path {
&self.data_root
}
/// The drop directory for `kind`.
pub fn inbox(&self, kind: ImportKind) -> PathBuf {
self.data_root.join(kind.inbox_name())
}
/// Create the drop directories this config can use (best effort), under an
/// ABSOLUTE data root only: a relative one is the process's working
/// directory, and the drop directories are not made in whatever directory a
/// test or an embedded caller happened to start in.
pub fn prepare_inboxes(&self) {
if !self.data_root.is_absolute() {
return;
}
if self.intel.is_some() {
let _ = std::fs::create_dir_all(self.inbox(ImportKind::Intel));
}
if self.has_intake() {
let _ = std::fs::create_dir_all(self.inbox(ImportKind::Bundle));
}
}
/// **May `identity` import a `kind` bundle?** Repository Admin or Security
/// Approver; the refusal names the principal, its duties and the verb.
#[cfg(feature = "intake")]
pub fn authorize(
auth: &AuthConfig,
identity: Option<&AuthIdentity>,
kind: ImportKind,
) -> Result<String, ImportError> {
let principal = duties::principal_of(identity);
duties::authorize(
&duties::duties_of(auth, identity),
&principal,
kind.what(),
Verb::Import(kind.what()),
)?;
Ok(principal)
}
/// **May `identity` authorize a first import?** A Repository Admin only (an admin's
/// action on the secure side); the refusal names the principal and its duties.
#[cfg(feature = "intake")]
pub fn authorize_admin(
auth: &AuthConfig,
identity: Option<&AuthIdentity>,
) -> Result<String, ImportError> {
let principal = duties::principal_of(identity);
duties::authorize(
&duties::duties_of(auth, identity),
&principal,
"a first import",
Verb::AllowFirstImport,
)?;
Ok(principal)
}
/// A fresh file under the staging directory, removed when the guard drops.
pub fn stage(&self) -> Result<StagedBundle, ImportError> {
let dir = self.data_root.join(STAGING_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| staging(format!("create {}: {e}", dir.display())))?;
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!(
"{}-{}-{n}.holger.znippy",
std::process::id(),
crate::audit::now_nanos()
));
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| staging(format!("create {}: {e}", path.display())))?;
Ok(StagedBundle {
path,
file: Some(file),
})
}
fn grants_path(&self) -> PathBuf {
self.data_root
.join(FIRST_IMPORT_DIR)
.join(FIRST_IMPORT_GRANTS)
}
/// The standing first-import authorizations.
pub fn first_import_grants(&self) -> Vec<FirstImportGrant> {
std::fs::read(self.grants_path())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save_grants(&self, grants: &[FirstImportGrant]) -> Result<(), ImportError> {
let body = serde_json::to_vec_pretty(grants)
.map_err(|e| grant_refused(500, format!("the authorizations: {e}")))?;
update_bundle::container::atomic_write(&self.grants_path(), &body)
.map_err(|e| grant_refused(500, format!("write {}: {e}", self.grants_path().display())))
}
/// Does an absent floor of `(vendor, product)` stand? `false` when one is recorded.
#[cfg(feature = "intake")]
fn floor_absent(&self, vendor: &str, product: &str) -> bool {
#[cfg(feature = "import-door")]
if product == holger_intel::INTEL_PRODUCT {
if let Some(intel) = &self.intel {
return holger_intel::LocalIntelDb::open(&intel.db_dir)
.installed()
.ok()
.flatten()
.is_none();
}
}
RollbackStore::open(self.data_root.join(UPDATE_STATE_DIR))
.floor(vendor, product)
.map(|f| f.accepted().is_none())
.unwrap_or(false)
}
/// ★ **An admin authorizes the first import** of `vendor`'s `product` at `sequence`
/// (lane HOL-CDS, NCSC gap 2). Refused (`holger-import-11`) when a field is blank, the
/// sequence is 0, a floor already stands, or the audit record cannot be written. It
/// replaces an earlier authorization of the same vendor and product. The caller has
/// already been held to the admin duty ([`Self::authorize_admin`]; the CLI is the box's
/// operator).
#[cfg(feature = "intake")]
pub fn allow_first_import(
&self,
vendor: &str,
product: &str,
sequence: u64,
actor: &str,
source: &str,
) -> Result<FirstImportGrant, ImportError> {
let (vendor, product) = (vendor.trim(), product.trim());
if vendor.is_empty() || product.is_empty() || sequence == 0 {
return Err(grant_refused(422, format!("a first-import authorization names a vendor, a product and a sequence from 1 (got {vendor:?}, {product:?}, {sequence})")));
}
if self.audit.mode() == AuditMode::Off {
return Err(grant_refused(409, "the audit log is off, and an authorization that leaves no audit record is not given".into()));
}
let _one = self.serial.lock_or_recover();
if !self.floor_absent(vendor, product) {
return Err(grant_refused(409, format!("the anti-rollback floor of {vendor}/{product} already stands; a first import is for an absent floor only")));
}
let ev = AuditEvent::new(actor, AuditAction::Other, "imports", format!("{vendor}/{product}"), source, 200, sequence)
.with_detail(format!("first-import authorized: vendor {vendor} product {product} sequence {sequence} by {actor} (trust.first_import: Admin)"));
self.audit
.record(ev)
.and_then(|()| self.audit.flush())
.map_err(|e| {
grant_refused(
500,
format!("the authorization could not be audited ({e:?}), so it was not given"),
)
})?;
let grant = FirstImportGrant {
vendor: vendor.into(),
product: product.into(),
sequence,
by: actor.into(),
at: crate::audit::now_nanos() / 1_000_000_000,
};
let mut grants: Vec<FirstImportGrant> = self
.first_import_grants()
.into_iter()
.filter(|g| !(g.vendor == vendor && g.product == product))
.collect();
grants.push(grant.clone());
self.save_grants(&grants)?;
Ok(grant)
}
/// May this bundle establish its absent floor? The grant for exactly this vendor,
/// product and sequence, or the refusal that names the admin's action.
#[cfg(feature = "intake")]
fn first_import_grant(
&self,
vendor: &str,
product: &str,
sequence: u64,
) -> Result<FirstImportGrant, ImportError> {
let grants = self.first_import_grants();
if let Some(g) = grants
.iter()
.find(|g| g.vendor == vendor && g.product == product && g.sequence == sequence)
{
return Ok(g.clone());
}
let other = grants
.iter()
.find(|g| g.vendor == vendor && g.product == product)
.map(|g| {
format!(
" (an admin authorized sequence {}, not {sequence})",
g.sequence
)
})
.unwrap_or_default();
Err(ImportError::new(
&holger_errcode::IMPORT_FIRST_NOT_AUTHORIZED,
409,
format!(
"no anti-rollback floor stands for {vendor}/{product}, and no admin authorized its first import at sequence {sequence}{other}. \
An admin on this side runs `holger bundle allow-first-import --vendor {vendor} --product {product} --sequence {sequence}` \
(or the console, `POST /-/bundle/first-import`), then the bundle is imported again"
),
))
}
/// The grant is used: remove it and audit that the floor was established by it.
#[cfg(feature = "intake")]
fn spend_grant(&self, g: &FirstImportGrant, bundle_id: &str, actor: &str, source: &str) {
let left: Vec<FirstImportGrant> = self
.first_import_grants()
.into_iter()
.filter(|x| x != g)
.collect();
if let Err(e) = self.save_grants(&left) {
log::warn!(
"first import: the used authorization could not be removed: {}",
e.tagged()
);
}
let ev = AuditEvent::new(actor, AuditAction::Other, "imports", format!("{}/{}", g.vendor, g.product), source, 200, g.sequence).with_detail(format!(
"first-import: bundle {bundle_id} established the floor of {}/{} at sequence {}, authorized by {} at {}",
g.vendor, g.product, g.sequence, g.by, g.at
));
let _ = self.audit.record(ev).and_then(|()| self.audit.flush());
}
/// Import `kind` from `bundle` as `actor` from `source`.
pub fn import(
&self,
kind: ImportKind,
bundle: &Path,
actor: &str,
source: &str,
) -> Result<String, ImportError> {
match kind {
ImportKind::Intel => self.import_intel(bundle, None, actor, source).map(|r| {
format!(
"IMPORTED ({}) {} seq {} dated {}: {} advisories",
r.outcome, r.bundle_id, r.sequence, r.snapshot_at, r.advisories
)
}),
ImportKind::Bundle => self
.import_bundle(bundle, None, &TargetSpec::default(), actor, source)
.map(|r| {
format!(
"IMPORTED ({}) {} seq {}: {} artifact(s)",
r.outcome,
r.bundle_id,
r.sequence,
r.artifacts.len()
)
}),
}
}
// ── intel ─────────────────────────────────────────────────────────────
/// **Import an intel bundle**: verify, floor, audit first, then make its
/// advisory set the live local database. `policy` overrides
/// `intel.trust_policy`.
/// A build without the intel database (the downstream) refuses an intel bundle by name.
#[cfg(not(feature = "import-door"))]
pub fn import_intel(
&self,
_bundle: &Path,
_policy: Option<&TrustPolicy>,
_actor: &str,
_source: &str,
) -> Result<IntelImported, ImportError> {
Err(ImportError::refused("this build carries no intel database (a downstream trusts the upstream's signed scan evidence)"))
}
#[cfg(feature = "import-door")]
pub fn import_intel(
&self,
bundle: &Path,
policy: Option<&TrustPolicy>,
actor: &str,
source: &str,
) -> Result<IntelImported, ImportError> {
let r = self.import_intel_as(bundle, policy, None, actor, source);
if let Err(e) = &r {
self.audit_refusal("intel import", bundle, e, actor, source);
}
r
}
/// HOL-NCSC (NCSC P1 item 5): **a refused import leaves its audit row**, with its code and
/// the file: a signature, a floor, a core.json, refused intel, an inbox refusal. Best
/// effort (an import refused because its audit sink is off has nowhere to write).
fn audit_refusal(&self, what: &str, bundle: &Path, e: &ImportError, actor: &str, source: &str) {
let name = bundle
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let ev = AuditEvent::new(
actor,
AuditAction::Other,
"",
name.clone(),
source,
e.status,
0,
)
.with_detail(format!("{what} refused: {name}: {}", e.tagged()));
if let Err(w) = self.audit.record(ev).and_then(|()| self.audit.flush()) {
log::error!(
"{what} of {name} refused ({}); its audit row could not be written: {w:?}",
e.tagged()
);
}
}
/// [`Self::import_intel`]; `own_first` is the box's OWN refresh establishing its own
/// floor (`intel.refresh`), never a caller's word.
#[cfg(feature = "import-door")]
fn import_intel_as(
&self,
bundle: &Path,
policy: Option<&TrustPolicy>,
own_first: Option<bool>,
actor: &str,
source: &str,
) -> Result<IntelImported, ImportError> {
let Some(intel) = &self.intel else {
return Err(ImportError::new(
&holger_errcode::IMPORT_NO_INTEL,
409,
"this server declares no `intel:` block; name the local advisory database there (db_dir)",
));
};
intel.validate().map_err(ImportError::refused)?;
let loaded;
let policy = match policy {
Some(p) => p,
None => {
loaded = load_policy(
intel.trust_policy.as_deref().map(Path::new),
"`intel.trust_policy`",
)?;
&loaded
}
};
policy
.expired_posture()
.map_err(|e| ImportError::new(e.code(), 422, e.to_string()))?;
// B3 first: an import that cannot be audited is refused for that, before its floor is
// judged (an absent floor's `holger-import-9` would hide the real reason).
if self.audit.mode() == AuditMode::Off {
return Err(ImportError::refused(
"intel import refused: the audit log is off or could not be opened, and an import that leaves no audit record is not allowed",
));
}
// An absent floor: the admin's grant for this bundle, or the refusal by name.
let grant = match own_first {
Some(_) => None,
None if holger_intel::LocalIntelDb::open(&intel.db_dir)
.installed()
.ok()
.flatten()
.is_none() =>
{
let members = load_bundle_verified(bundle, policy).map_err(|e| {
ImportError::refused(format!("intel import refused: {}", e.code().tag(&e)))
})?;
// Every member's digest against the release-signed manifest first: a tampered
// bundle is refused for what it is (`holger-import-5`), before its floor is judged.
verify_member_map_with_time(
&members,
policy,
&TargetSpec::default(),
None,
&self.time(),
)
.map_err(|e| {
ImportError::refused(format!(
"intel import refused: bundle verification failed: {}",
e.code().tag(&e)
))
})?;
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| ImportError::refused("intel bundle has no core.json"))?,
)
.map_err(|e| ImportError::refused(format!("core.json: {e}")))?;
Some((
self.first_import_grant(&core.supplier, &core.product, core.sequence)?,
core.bundle_id,
))
}
None => None,
};
let first_import = own_first.unwrap_or(grant.is_some());
let _one = self.serial.lock_or_recover();
let db = holger_intel::LocalIntelDb::open(&intel.db_dir);
let audit = ServerAudit {
log: &self.audit,
actor,
source,
};
let out = holger_intel::import(
&holger_intel::ImportRequest {
bundle,
policy,
db: &db,
first_import,
},
&self.time(),
&audit,
)
.map_err(|e| ImportError::refused(format!("intel import refused: {e:#}")))?;
self.raise_floor(
out.record.snapshot_at.timestamp_millis(),
&format!("the intel bundle {}", out.record.bundle_id),
);
drop(_one);
if let Some((g, id)) = &grant {
self.spend_grant(g, id, actor, source);
}
Ok(IntelImported {
outcome: out.record.outcome.clone(),
bundle_id: out.record.bundle_id.clone(),
sequence: out.record.sequence,
snapshot_at: out.record.snapshot_at.format("%Y-%m-%d").to_string(),
advisories: out.record.advisories,
database: db.root().display().to_string(),
checks: out.checks,
})
}
/// **`intel.refresh`**: fetch OSV, NVD and KEV (or `intel.feed_mirror`),
/// normalize them, sign the set into a `holger.intel/v1` bundle with the box's
/// OWN refresh signer, and land it through [`Self::import_intel`] — the same
/// verify, anti-rollback floor and audit-first path a carried bundle takes.
/// Fail-closed: a feed that cannot be fetched or read lands nothing, and the
/// database stays as it was. Refused by name, with nothing dialled, on a box
/// that may not fetch (`intel.offline: true`, `network: Offline`, a build
/// without `intel-refresh`).
///
/// The refresh signer (two approver roles and a release signer, ed25519, under
/// `<intel.db_dir>/refresh-signer/`) is minted on the first refresh and trusted
/// by an in-memory policy for `holger.intel/v1` only; `intel.trust_policy`,
/// which carried bundles verify against, is not widened by it. The floor is the
/// one carried bundles share (sequence `YYYYMMDDHHMMSS` of the fetch).
#[cfg(feature = "import-door")]
pub fn refresh_intel(
&self,
net: &holger_intel::IntelNet,
) -> Result<IntelImported, ImportError> {
let Some(intel) = &self.intel else {
return Err(ImportError::new(
&holger_errcode::IMPORT_NO_INTEL,
409,
"this server declares no `intel:` block to refresh",
));
};
let off = |why: &str| {
ImportError::new(
&holger_errcode::IMPORT_REFRESH_OFF,
409,
format!("intel.refresh does not run: {why}; nothing was dialled"),
)
};
if intel.offline {
return Err(off(
"`intel.offline: true` (this box takes intel only as a signed bundle)",
));
}
if crate::egress::is_offline() {
return Err(off("this holger is `network: Offline`"));
}
if net.is_offline() {
return Err(off(
"this build links no intel fetcher (the `intel-refresh` feature)",
));
}
#[cfg(not(feature = "intel-refresh"))]
{
Err(off(
"this build links no intel fetcher (the `intel-refresh` feature)",
))
}
#[cfg(feature = "intel-refresh")]
{
use holger_intel::chrono::{Datelike, Duration, Utc};
let failed =
|why: String| ImportError::new(&holger_errcode::IMPORT_REFRESH_FAILED, 502, why);
let now = Utc::now();
let specs = refresh::feed_specs(intel, net, now.year())
.map_err(|e| failed(format!("{e:#}")))?;
let mut raw = Vec::with_capacity(specs.len());
for spec in specs {
let bytes = net
.get(&spec.uri)
.map_err(|e| failed(format!("fetch {}: {e}", spec.uri)))?;
raw.push(holger_intel::RawFeed {
spec,
bytes,
fetched_at: Utc::now(),
});
}
let set = holger_intel::feeds::normalize(&raw, now)
.map_err(|e| failed(format!("the feeds do not read: {e:#}")))?;
let signer =
refresh::Signer::open_or_mint(&Path::new(&intel.db_dir).join(refresh::SIGNER_DIR))
.map_err(|e| failed(format!("the refresh signer: {e:#}")))?;
let policy = signer.policy();
let out = self.stage()?;
let path = out.path.clone();
drop(out);
let staged = StagedBundle {
path: path.clone(),
file: None,
};
holger_intel::build_bundle(
&set,
holger_intel::BuildOptions {
sequence: holger_intel::bundle::sequence_for(now),
created_at: now,
expires_at: Some(
now + Duration::days(i64::from(intel.block_after_days.max(1))),
),
supplier: holger_intel::DEFAULT_SUPPLIER.into(),
producer: format!("holger-server intel.refresh {}", env!("CARGO_PKG_VERSION")),
policy: policy.clone(),
approvals: signer.approvals(),
release: signer.release(),
out: path.clone(),
},
)
.map_err(|e| failed(format!("sign the fetched set: {e:#}")))?;
let first = holger_intel::LocalIntelDb::open(&intel.db_dir)
.installed()
.ok()
.flatten()
.is_none();
let r = self.import_intel_as(
&staged.path,
Some(&policy),
Some(first),
"intel-refresh",
"local:intel.refresh",
);
drop(staged);
r
}
}
// ── update / airgap bundles ───────────────────────────────────────────
/// **Import an update or airgap bundle** — in a build without the supply
/// chain there is no intake chain to land it in, so it is refused by name.
#[cfg(not(feature = "intake"))]
pub fn import_bundle(
&self,
_bundle: &Path,
_policy: Option<&TrustPolicy>,
_target: &TargetSpec,
_actor: &str,
_source: &str,
) -> Result<BundleImported, ImportError> {
Err(ImportError::new(
&holger_errcode::IMPORT_NO_INTAKE,
409,
"this build carries no intake chain (build with the `supply-chain` feature); a bundle import \
lands its artifacts only through intake, never into a raw repository's data directory",
))
}
/// **The provenance an airgap bundle carried, verified again on this side**
/// (lane G3a), when this side's intake runs the provenance scanner. The
/// subject's source is [`AIRGAP_SOURCE`], so a `sources` gate of that name (or
/// the default gate) decides. Pure: nothing is written; the caller audits.
#[cfg(all(feature = "intake", not(feature = "scanners")))]
fn judge_carried_provenance(
_chain: &IntakeChain,
_v: &airgap::VerifiedArtifact,
) -> Option<(String, holger_provenance::ProvenanceReport)> {
// A downstream runs no provenance scanner of its own: the carried
// provenance was judged upstream, and that verdict is in the signed evidence.
None
}
#[cfg(feature = "scanners")]
fn judge_carried_provenance(
chain: &IntakeChain,
v: &airgap::VerifiedArtifact,
) -> Option<(String, holger_provenance::ProvenanceReport)> {
let p = chain.scanner().and_then(|s| s.provenance())?;
let production = chain.settings().production.clone();
let file =
v.id.name
.rsplit('/')
.next()
.unwrap_or(&v.id.name)
.to_string();
let subject = crate::scanner::ScanSubject::new(&file, "raw", &v.bytes)
.with_source(AIRGAP_SOURCE)
.with_repo(&production);
Some(p.report_with(&subject, &v.provenance))
}
/// The audit line of one carried-provenance verdict: `scanned` when it
/// passed or warned, `policy_denied` (the refusal) when it refused.
#[cfg(feature = "intake")]
fn provenance_event(
chain: &IntakeChain,
v: &airgap::VerifiedArtifact,
by: &str,
report: &holger_provenance::ProvenanceReport,
actor: &str,
source: &str,
) -> AuditEvent {
let refused = report.verdict == holger_provenance::Verdict::Block;
AuditEvent::new(
actor,
AuditAction::Scan,
chain.settings().production.clone(),
IntakeEntry::label_of(&v.id),
source,
if refused { 403 } else { 200 },
v.bytes.len() as u64,
)
.with_detail(format!(
"provenance verified again on the airgap side ({}): source {by}: {}",
holger_provenance::SCANNER_ID,
report.detail
))
.on_trail(
if refused {
TrailKind::PolicyDenied
} else {
TrailKind::Scanned
},
v.sha256.clone(),
)
}
/// The SCANNED line's evidence for a carried-provenance verdict that did not refuse.
#[cfg(feature = "intake")]
fn provenance_evidence(
v: &airgap::VerifiedArtifact,
report: &holger_provenance::ProvenanceReport,
) -> crate::promote::intake::Evidence {
use crate::promote::intake::{Evidence, EvidenceKind, EvidenceVerdict};
let verdict = if report.verdict == holger_provenance::Verdict::Warn {
EvidenceVerdict::Warn
} else {
EvidenceVerdict::Pass
};
Evidence::new(
EvidenceKind::Provenance,
format!("provenance:{}", holger_provenance::SCANNER_ID),
verdict,
)
.with_reference(format!(
"airgap side, sha256:{}: {}",
v.sha256, report.detail
))
}
/// ★ **Install a verified scanner signature bundle** (lane HOL-NCSC): its members are
/// signature files only (`holger-sigs-1` otherwise), the audit record is written first,
/// then the floor moves, then the files land over the set in `<data_root>/sandbox/
/// signatures` (`holger_intel::sigs::SigsDb`) and clamd is asked to reload.
#[cfg(feature = "intake")]
#[allow(clippy::too_many_arguments)]
fn land_scanner_sigs(
&self,
members: &std::collections::BTreeMap<String, Vec<u8>>,
core: &BundleCore,
report: &update_bundle::VerifyReport,
admissibility: Admissibility,
grant: Option<&FirstImportGrant>,
bytes: u64,
rollback: &RollbackStore,
actor: &str,
source: &str,
) -> Result<BundleImported, ImportError> {
#[cfg(not(feature = "scanners"))]
{
let _ = (
members,
core,
report,
admissibility,
grant,
bytes,
rollback,
actor,
source,
);
Err(ImportError::new(&holger_errcode::SIGS_NOT_BUILT, 409, "scanner signature import refused: this build carries no scanners (build with `scanners`)"))
}
#[cfg(feature = "scanners")]
{
use holger_intel::sigs::SigsDb;
let refused = |w: String| {
ImportError::new(
&holger_errcode::SIGS_MEMBER_REFUSED,
422,
format!("scanner signature import refused: {w}"),
)
};
let files = holger_intel::sigs::files_of(members).map_err(refused)?;
let names: Vec<String> = files.iter().map(|f| f.rel()).collect();
let outcome = match admissibility {
Admissibility::New => "new",
Admissibility::IdempotentReimport => "idempotent-reimport",
};
// Audit first: no record, no install, and the floor stays.
let ev = AuditEvent::new(
actor,
AuditAction::Other,
"scanner-sigs",
format!(
"{}/{}/{}",
core.supplier, core.product, core.release_version
),
source,
200,
bytes,
)
.with_detail(format!(
"scanner-sigs-import: bundle_id={} seq={} files={} digest={} outcome={outcome}",
core.bundle_id,
core.sequence,
names.join(","),
report.candidate_digest
));
self.audit.record(ev).and_then(|()| self.audit.flush()).map_err(|e| {
ImportError::new(&holger_errcode::AIRGAP_AUDIT_FAILED, 500, format!("scanner signature import refused: its audit record could not be written ({e:?}); nothing changed"))
})?;
let now = chrono::Utc::now();
rollback
.record(
&core.supplier,
&core.product,
&AcceptedEntry {
sequence: core.sequence,
bundle_id: core.bundle_id.clone(),
core_digest: report.candidate_digest.clone(),
accepted_at: now,
},
)
.map_err(|e| {
ImportError::refused(format!(
"the anti-rollback floor could not be written: {e}"
))
})?;
let layout = holger_sandbox::media::Layout::under(&self.data_root);
let db = SigsDb::open(layout.signatures());
let installed = db.install(&core.bundle_id, core.sequence, &core.supplier, &files, now).map_err(|e| {
ImportError::new(&holger_errcode::SIGS_INSTALL_FAILED, 500, format!("scanner signature import: {e:#}; the floor moved, so import the same bundle again to finish it"))
})?;
if let Some(g) = grant {
self.spend_grant(g, &core.bundle_id, actor, source);
}
let socket = layout.clamd_socket();
let reload = if socket.exists() {
match crate::sandbox::clamd::reload(&socket) {
Ok(()) => "clamd reloads its database".to_string(),
Err(e) => {
format!("clamd did not reload ({e}); it reads the set at its next start")
}
}
} else {
"no sandboxed clamd runs here; it reads the set at its start".to_string()
};
log::warn!(
"scanner signatures: {} seq {} installed {} file(s) ({}); {reload}",
core.bundle_id,
core.sequence,
files.len(),
names.join(", ")
);
let mut checks = report.checks.clone();
checks.push(format!(
"scanner-sigs: INSTALLED {} file(s) into {} ({}); the set now holds {} file(s)",
files.len(),
db.root().display(),
names.join(", "),
installed.files.len()
));
checks.push(format!("scanner-sigs: {reload}"));
Ok(BundleImported {
outcome: outcome.to_string(),
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
kind: "scanner-sigs".into(),
artifacts: Vec::new(),
receipt: db.installed_path().display().to_string(),
checks,
sync_ack: None,
sync_held: Vec::new(),
})
}
}
/// ★ **Install a verified Sigstore trust root** (lane HOL-OCI-EVIDENCE): the bundle (signed by
/// the identities `promotion.intake.trust_policy` pins) must carry a trust root that reads;
/// the audit record is written first, then the floor moves, then it is packed where the
/// floor's `rules.trust_root` names (else `<data_root>/holger-trust/`), and the provenance
/// scanner and the pull-through re-read it at once.
#[cfg(feature = "intake")]
#[allow(clippy::too_many_arguments)]
fn land_trust_root(
&self,
chain: &Arc<IntakeChain>,
members: &std::collections::BTreeMap<String, Vec<u8>>,
core: &BundleCore,
report: &update_bundle::VerifyReport,
admissibility: Admissibility,
grant: Option<&FirstImportGrant>,
bytes: u64,
rollback: &RollbackStore,
actor: &str,
source: &str,
) -> Result<BundleImported, ImportError> {
let root_rel = format!(
"{}{}",
update_bundle::schema::PAYLOAD_PREFIX,
holger_provenance::trust::TRUSTED_ROOT_REL
);
let root = members.get(&root_rel).ok_or_else(|| {
ImportError::refused(format!("the trust bundle carries no {root_rel}"))
})?;
holger_provenance::TrustRoot::from_trusted_root_json(root).map_err(|e| {
ImportError::new(
&holger_errcode::TRUST_ROOT_SEED_REFUSED,
422,
format!("trust root import refused: {}", e.tagged()),
)
})?;
let outcome = match admissibility {
Admissibility::New => "new",
Admissibility::IdempotentReimport => "idempotent-reimport",
};
let root_sha = crate::attest::ArtifactDigest::of_sha256(root)
.hex()
.to_string();
// Audit first: no record, no install, and the floor stays.
let ev = AuditEvent::new(actor, AuditAction::Other, "sigstore-trust", format!("{}/{}/{}", core.supplier, core.product, core.release_version), source, 200, bytes).with_detail(format!(
"sigstore-trust-import: bundle_id={} seq={} trusted_root=sha256:{root_sha} digest={} outcome={outcome}",
core.bundle_id, core.sequence, report.candidate_digest
));
self.audit.record(ev).and_then(|()| self.audit.flush()).map_err(|e| {
ImportError::new(&holger_errcode::AIRGAP_AUDIT_FAILED, 500, format!("trust root import refused: its audit record could not be written ({e:?}); nothing changed"))
})?;
let now = chrono::Utc::now();
rollback
.record(
&core.supplier,
&core.product,
&AcceptedEntry {
sequence: core.sequence,
bundle_id: core.bundle_id.clone(),
core_digest: report.candidate_digest.clone(),
accepted_at: now,
},
)
.map_err(|e| {
ImportError::refused(format!("the anti-rollback floor could not be written: {e}"))
})?;
// The configured trust root, else the data root's bundle; without scanners there is
// no configuration to read (and clippy refuses an `unwrap_or_else` on a known None).
#[cfg(feature = "scanners")]
let target: PathBuf = chain
.scanner()
.and_then(|p| p.provenance())
.and_then(|p| p.trust_root_path())
.map(PathBuf::from)
.unwrap_or_else(|| crate::sigstore_trust::bundle_path(&self.data_root));
#[cfg(not(feature = "scanners"))]
let target: PathBuf = crate::sigstore_trust::bundle_path(&self.data_root);
let installed = crate::sigstore_trust::install(&self.data_root, &target, members, core)
.map_err(|e| ImportError::new(&holger_errcode::TRUST_ROOT_SEED_REFUSED, 500, format!("trust root import: {e}; the floor moved, so import the same bundle again to finish it")))?;
if let Some(g) = grant {
self.spend_grant(g, &core.bundle_id, actor, source);
}
let read = chain
.reload_trust()
.map_err(|e| ImportError::new(&holger_errcode::TRUST_ROOT_RELOAD_FAILED, 500, format!("trust root import: installed at {}, and the scanners could not read it: {e}", target.display())))?
.unwrap_or_else(|| "no scanner here reads a trust root; it is read at the next start".into());
log::warn!("sigstore trust root: {} seq {} installed at {} (trusted_root sha256:{root_sha}); {read}", core.bundle_id, core.sequence, target.display());
let mut checks = report.checks.clone();
checks.push(format!(
"sigstore-trust: INSTALLED seq {} at {} (trusted_root sha256:{root_sha})",
installed.sequence,
target.display()
));
checks.push(format!("sigstore-trust: {read}"));
Ok(BundleImported {
outcome: outcome.to_string(),
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
kind: "sigstore-trust".into(),
artifacts: Vec::new(),
receipt: crate::sigstore_trust::installed_path(&self.data_root)
.display()
.to_string(),
checks,
sync_ack: None,
sync_held: Vec::new(),
})
}
/// **Import an update or airgap bundle** and land every artifact AVAILABLE
/// through the intake chain. `policy` overrides `promotion.intake.trust_policy`. A
/// refusal leaves its audit row (HOL-NCSC).
#[cfg(feature = "intake")]
pub fn import_bundle(
&self,
bundle: &Path,
policy: Option<&TrustPolicy>,
target: &TargetSpec,
actor: &str,
source: &str,
) -> Result<BundleImported, ImportError> {
let r = self.import_bundle_unaudited(bundle, policy, target, actor, source);
if let Err(e) = &r {
self.audit_refusal("bundle import", bundle, e, actor, source);
}
r
}
/// ★ **A bundle published into the bundles door** (lane HOL-BUNDLES-DOOR): verified in a
/// `verify-bundle` job against the door's trust policy (`promotion.intake.bundles_trust_policy`,
/// else `trust_policy`): the release signature, the approvals, every member, its expiry at
/// the time floor. Nothing lands and no floor moves: the bundle is stored as it is for the
/// downstreams that mirror the door, which import it under their own policy. Every refusal
/// is `holger-bundles-1` with the reason, and audited; an accepted one is audited too.
#[cfg(feature = "intake")]
pub fn verify_published(
&self,
bundle: &Path,
actor: &str,
source: &str,
) -> Result<String, ImportError> {
let refused = |why: String| ImportError::new(&holger_errcode::BUNDLES_REFUSED, 422, why);
let r = (|| {
let path = self
.intake
.as_ref()
.and_then(|c| c.bundles_trust_policy())
.map(PathBuf::from);
let policy = load_policy(path.as_deref(), "`promotion.intake.bundles_trust_policy`")
.map_err(|e| refused(e.tagged()))?;
policy
.expired_posture()
.map_err(|e| refused(e.to_string()))?;
let target = TargetSpec::default();
let (members, _manifest, _bytes) = self
.verify_in_job(bundle, &policy, &target, actor, source)
.map_err(|e| refused(e.tagged()))?;
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| refused("the bundle has no core.json".into()))?,
)
.map_err(|e| refused(format!("core.json: {e}")))?;
verify_member_map_with_time(&members, &policy, &target, None, &self.time()).map_err(
|e| refused(format!("bundle verification failed: {}", e.code().tag(&e))),
)?;
Ok(format!(
"{} {} {} sequence {} ({})",
core.supplier, core.product, core.release_version, core.sequence, core.bundle_id
))
})();
match &r {
Err(e) => self.audit_refusal("bundle publish", bundle, e, actor, source),
Ok(what) => {
let name = bundle
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let ev = AuditEvent::new(actor, AuditAction::Other, "", name, source, 201, 0)
.with_detail(format!("bundle published: verified {what}"));
if let Err(e) = self.audit.record(ev).and_then(|()| self.audit.flush()) {
return Err(ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
409,
format!(
"bundle publish refused: its audit row could not be written: {e:?}"
),
));
}
}
}
r
}
#[cfg(feature = "intake")]
fn import_bundle_unaudited(
&self,
bundle: &Path,
policy: Option<&TrustPolicy>,
target: &TargetSpec,
actor: &str,
source: &str,
) -> Result<BundleImported, ImportError> {
let Some(chain) = self.intake.clone() else {
return Err(ImportError::new(
&holger_errcode::IMPORT_NO_INTAKE,
409,
"this server declares no intake chain (`promotion.intake`); a bundle import lands its \
artifacts only through intake, never into a raw repository's data directory",
));
};
let loaded;
let policy = match policy {
Some(p) => p,
None => {
loaded = load_policy(
self.bundle_policy.as_deref(),
"`promotion.intake.trust_policy`",
)?;
&loaded
}
};
policy
.expired_posture()
.map_err(|e| ImportError::new(e.code(), 422, e.to_string()))?;
// Lane HOL-SANDBOX-TRAIT: the bundle is verified in a `verify-bundle` job (the
// release signature before any payload is decompressed, every member, an
// export's evidence attestations), never opened here. What comes back is the
// job's verified files, checked against its signed result; the bytes that
// verified are the bytes kept.
let (members, manifest, bundle_bytes) =
self.verify_in_job(bundle, policy, target, actor, source)?;
let _one = self.serial.lock_or_recover();
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| ImportError::refused("bundle has no core.json"))?,
)
.map_err(|e| ImportError::refused(format!("core.json: {e}")))?;
// HOL-NCSC: a signed time token is carried in like any bundle; it raises the time
// floor and lands nothing (no anti-rollback floor, so no first-import grant).
if core.product == crate::time_floor::TIME_TOKEN_PRODUCT {
return self.accept_time_token(&members, &core, policy, actor, source);
}
let rollback = RollbackStore::open(self.data_root.join(UPDATE_STATE_DIR));
let floor = rollback.floor(&core.supplier, &core.product).map_err(|e| {
ImportError::refused(format!("the anti-rollback floor could not be read: {e}"))
})?;
let state = floor
.accepted()
.cloned()
.unwrap_or_else(AcceptedState::empty);
let report =
verify_member_map_with_time(&members, policy, target, Some(&state), &self.time())
.map_err(|e| {
ImportError::refused(format!(
"bundle verification failed: {}",
e.code().tag(&e)
))
})?;
// B3 first: an import that cannot be audited is refused before anything else is judged.
if self.audit.mode() == AuditMode::Off {
return Err(ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
409,
"bundle import refused: the audit log is off or could not be opened, and an import that \
leaves no audit record is not allowed",
));
}
// NCSC gap 2: an absent floor is established only with an admin's grant for exactly
// this vendor, product and sequence.
let grant = match floor.accepted() {
Some(_) => None,
None => Some(self.first_import_grant(&core.supplier, &core.product, core.sequence)?),
};
let first_import = grant.is_some();
let admissibility = floor
.check_admissible(&core, &report.candidate_digest, first_import)
.map_err(|e| ImportError::refused(e.code().tag(&e).to_string()))?;
// HOL-NCSC: a scanner signature bundle installs its files, it lands no artifact.
if core.product == SCANNER_SIGS_PRODUCT {
return self.land_scanner_sigs(
&members,
&core,
&report,
admissibility,
grant.as_ref(),
manifest.bundle_bytes,
&rollback,
actor,
source,
);
}
// HOL-OCI-EVIDENCE: a Sigstore trust root installs over the box's, and is read at once.
if core.product == crate::sigstore_trust::TRUST_PRODUCT {
return self.land_trust_root(
&chain,
&members,
&core,
&report,
admissibility,
grant.as_ref(),
manifest.bundle_bytes,
&rollback,
actor,
source,
);
}
// Everything the landing needs is verified now, before anything is written.
let export = core.source_provenance.source_kind == airgap::SOURCE_KIND;
let verified = if export {
let payloads: Vec<airgap::CorePayload> = core
.payloads
.iter()
.map(|p| airgap::CorePayload {
path: p.relative_path.clone(),
sha256: p.sha256.clone(),
blake3: p.blake3.clone(),
size: p.size_bytes,
})
.collect();
let (_, verified) = airgap::verify_import(&members, &payloads, self.attest.as_deref())
.map_err(|e| ImportError {
code: e.code(),
status: 422,
message: format!("bundle import refused: {e}"),
})?;
verified
} else {
Vec::new()
};
// H4: a sync manifest the bundle carries is verified NOW, before anything
// lands (an untrusted signer or a rollback refuses the whole import), and
// applied after the landing. A Holger that trusts no upstream ignores it.
#[cfg(feature = "sync")]
let sync_doc: Option<(Arc<crate::sync::SyncHub>, Vec<u8>)> = match (
export,
members.get(&format!("evidence/{}", holger_sync::BUNDLE_MANIFEST)),
chain.sync().filter(|h| h.is_downstream()),
) {
(true, Some(bytes), Some(hub)) => {
hub.check_manifest(bytes).map_err(|e| ImportError {
code: e.code,
status: e.status,
message: format!("bundle import refused: its sync manifest: {}", e.message),
})?;
Some((hub, bytes.clone()))
}
(true, Some(_), None) => {
log::info!("bundle import: the bundle carries a sync manifest; this Holger trusts no upstream (`sync.upstreams`), so it is not applied");
None
}
_ => None,
};
// B3: no audit record, no import.
if self.audit.mode() == AuditMode::Off {
return Err(ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
409,
"bundle import refused: the audit log is off or could not be opened, and an import that \
leaves no audit record is not allowed",
));
}
// Lane G3a: the provenance that came with the bytes is verified AGAIN
// here, offline, under this side's own policy and trust root, for every
// artifact BEFORE the import writes anything. A refusal writes only
// itself: no hash_verified, no airgap_imported, nothing lands.
let mut judged = Vec::new();
for v in &verified {
let Some((by, report)) = Self::judge_carried_provenance(&chain, v) else {
judged.push(None);
continue;
};
if report.verdict == holger_provenance::Verdict::Block {
let ev = Self::provenance_event(&chain, v, &by, &report, actor, source);
self.audit
.record(ev)
.and_then(|()| self.audit.flush())
.map_err(|e| {
ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
500,
format!(
"bundle import refused: its refusal could not be audited ({e:?})"
),
)
})?;
let code = report
.code
.as_deref()
.and_then(holger_errcode::by_code)
.unwrap_or(&holger_errcode::PROVENANCE_MISSING);
return Err(ImportError::new(
code,
422,
format!(
"bundle import refused: '{}': {}",
IntakeEntry::label_of(&v.id),
report.detail
),
));
}
judged.push(Some((by, report)));
}
let outcome = match admissibility {
Admissibility::IdempotentReimport => "idempotent-reimport",
Admissibility::New => "new",
};
let bundle_sha = manifest.bundle_sha256.clone();
let production = chain.settings().production.clone();
let mut events = vec![AuditEvent::new(
actor,
AuditAction::Other,
production.clone(),
format!(
"{}/{}/{}",
core.supplier, core.product, core.release_version
),
source,
200,
manifest.bundle_bytes,
)
.with_detail(format!(
"bundle-import: bundle_id={} seq={} digest={} outcome={outcome} into the intake chain",
core.bundle_id, core.sequence, report.candidate_digest
))
.on_trail(TrailKind::AirgapImported, bundle_sha.clone())];
if !export {
events.push(
AuditEvent::new(
actor,
AuditAction::Other,
production.clone(),
bundle_label(&core),
source,
200,
manifest.bundle_bytes,
)
.with_detail(format!(
"bundle-import: hash verified: bundle {} matches its release-signed manifest",
core.bundle_id
))
.on_trail(TrailKind::HashVerified, bundle_sha.clone()),
);
}
for p in &core.payloads {
let sha = p.sha256.trim().to_ascii_lowercase();
let label = format!("{}/{}", core.bundle_id, p.relative_path);
events.push(
AuditEvent::new(actor, AuditAction::Other, production.clone(), label.clone(), source, 200, p.size_bytes)
.with_detail(format!("bundle-import: hash verified: payload {} matches the signed manifest of bundle {}", p.relative_path, core.bundle_id))
.on_trail(TrailKind::HashVerified, sha.clone()),
);
events.push(
AuditEvent::new(
actor,
AuditAction::Other,
production.clone(),
label,
source,
200,
p.size_bytes,
)
.with_detail(format!(
"bundle-import: imported on the airgap side from bundle {} seq {} ({outcome})",
core.bundle_id, core.sequence
))
.on_trail(TrailKind::AirgapImported, sha),
);
}
events
.into_iter()
.try_for_each(|ev| self.audit.record(ev))
.and_then(|()| self.audit.flush())
.map_err(|e| {
ImportError::new(
&holger_errcode::AIRGAP_AUDIT_FAILED,
500,
format!("bundle import refused: its audit record could not be written ({e:?}); nothing landed and the floor did not move"),
)
})?;
// Land through the intake chain, each artifact audited first.
let mut landed = Vec::new();
let mut land = |imp: &ImportedArtifact<'_>| -> Result<(), ImportError> {
let label = crate::promote::intake::IntakeEntry::label_of(&imp.id);
let sha = sha256_hex(imp.bytes);
let row = match chain.land_imported(imp).map_err(|e| ImportError {
code: e.code(),
status: e.status(),
message: format!("bundle import refused: {e}"),
})? {
LandOutcome::Landed(e) => LandedArtifact {
artifact: label,
sha256: e.sha256,
state: e.to.name().into(),
repository: e.repo,
outcome: "landed".into(),
},
LandOutcome::AlreadyAvailable => LandedArtifact {
artifact: label,
sha256: sha,
state: "AVAILABLE".into(),
repository: production.clone(),
outcome: "already-available".into(),
},
};
landed.push(row);
Ok(())
};
if export {
for (v, judged) in verified.iter().zip(judged) {
// The carried provenance's (passing) verdict: on the trail, then
// on the SCANNED line.
let mut local_evidence = Vec::new();
if let Some((by, report)) = judged {
self.audit
.record(Self::provenance_event(&chain, v, &by, &report, actor, source))
.map_err(|e| ImportError::new(&holger_errcode::AIRGAP_AUDIT_FAILED, 500, format!("bundle import refused: the provenance check's audit record could not be written ({e:?})")))?;
local_evidence.push(Self::provenance_evidence(v, &report));
}
// Keep the verified attestations on this side too, so an Auditor
// reads them here. They are NOT re-recorded as a scan.
if let Some(store) = &self.attest {
let digest = crate::attest::ArtifactDigest::of_sha256(&v.bytes);
for env in &v.envelopes {
store
.accept(env, std::slice::from_ref(&v.signer_cert), Some(&digest))
.map_err(|e| {
ImportError::new(
&holger_errcode::AIRGAP_ATTESTATION_INVALID,
422,
format!("bundle import refused: {}", e.tagged()),
)
})?;
}
}
land(&ImportedArtifact {
local_evidence,
id: v.id.clone(),
bytes: &v.bytes,
actor: actor.to_string(),
bundle_id: core.bundle_id.clone(),
candidate_digest: report.candidate_digest.clone(),
release_signer: report.release_signer_fingerprint.clone(),
online_history: v.online_history.clone(),
verified: "bundle release signature, per-artifact sha256/blake3/size, evidence attestations".into(),
index_row: v.index_row.clone(),
})?;
}
} else {
land(&ImportedArtifact {
id: traits::ArtifactId {
namespace: None,
name: bundle_label(&core),
version: String::new(),
},
bytes: bundle_bytes.as_deref().ok_or_else(|| {
ImportError::refused(
"the verify-bundle job handed back no bundle bytes for an update bundle",
)
})?,
actor: actor.to_string(),
bundle_id: core.bundle_id.clone(),
candidate_digest: report.candidate_digest.clone(),
release_signer: report.release_signer_fingerprint.clone(),
online_history: Vec::<IntakeEntry>::new(),
verified:
"bundle release signature, every member digest against the signed manifest"
.into(),
local_evidence: Vec::new(),
index_row: None,
})?;
}
// The floor moves only after everything landed.
rollback
.record(
&core.supplier,
&core.product,
&AcceptedEntry {
sequence: core.sequence,
bundle_id: core.bundle_id.clone(),
core_digest: report.candidate_digest.clone(),
accepted_at: estate_now(),
},
)
.map_err(|e| {
ImportError::refused(format!(
"the anti-rollback floor could not be recorded: {e}"
))
})?;
if let Some(g) = &grant {
self.spend_grant(g, &core.bundle_id, actor, source);
}
let receipt = ImportReceipt {
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
supplier: core.supplier.clone(),
product: core.product.clone(),
release_version: core.release_version.clone(),
candidate_digest: report.candidate_digest.clone(),
release_signer_fingerprint: report.release_signer_fingerprint.clone(),
imported_at: estate_now(),
outcome: outcome.to_string(),
bundle_sha256: bundle_sha,
};
let receipts = self.data_root.join(RECEIPTS_DIR);
let _ = std::fs::create_dir_all(&receipts);
let receipt_path = receipts.join(format!("{}.json", sanitize(&core.bundle_id)));
let body = serde_json::to_vec_pretty(&receipt)
.map_err(|e| ImportError::refused(format!("receipt: {e}")))?;
atomic_write(&receipt_path, &body)
.map_err(|e| ImportError::refused(format!("receipt: {e}")))?;
#[cfg(feature = "sync")]
let (sync_ack, sync_held) = match sync_doc {
Some((hub, bytes)) => {
let a = hub
.apply(&bytes, &format!("bundle {} seq {}", core.bundle_id, core.sequence), actor, source)
.map_err(|e| ImportError { code: e.code, status: e.status, message: format!("bundle import: the artifacts landed, and its sync manifest was not applied: {}", e.message) })?;
#[cfg(feature = "fetch")]
let ack = Some(a.ack_path.display().to_string());
// A one-way Holger signs no receipt: nothing goes back.
#[cfg(not(feature = "fetch"))]
let ack = None;
(ack, a.quarantined)
}
None => (None, Vec::new()),
};
#[cfg(not(feature = "sync"))]
let (sync_ack, sync_held): (Option<String>, Vec<String>) = (None, Vec::new());
// HOL-NCSC: the accepted bundle's signed time raises the time floor.
self.raise_floor(
core.created_at.timestamp_millis(),
&format!("the bundle {} seq {}", core.bundle_id, core.sequence),
);
Ok(BundleImported {
outcome: outcome.into(),
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
kind: if export {
"airgap-export"
} else {
"update-bundle"
}
.into(),
artifacts: landed,
receipt: receipt_path.display().to_string(),
checks: report.checks.clone(),
sync_ack,
sync_held,
})
}
/// ★ **Verify `bundle` in a `verify-bundle` job** (lane HOL-SANDBOX-TRAIT): link it
/// into the job's input (no open), with this side's trust policy and attestation
/// trust beside it; run the job through the server's ONE runner; believe only the
/// signed result ([`crate::job_runner::Jobs::run`]); read back its verified
/// files. A refusal (the job's, or of its result) is audited with its reason and
/// answered by name.
#[cfg(feature = "intake")]
#[allow(clippy::type_complexity)]
fn verify_in_job(
&self,
bundle: &Path,
policy: &TrustPolicy,
target: &TargetSpec,
actor: &str,
source: &str,
) -> Result<
(
update_bundle::MemberMap,
crate::job_runner::verify_bundle::Manifest,
Option<Vec<u8>>,
),
ImportError,
> {
use crate::job_runner::heimdall_core::{Job, JobId, Limits, Nonce, Policy, Verdict};
use crate::job_runner::{
holger_job_runner as runner, verify_bundle as vb, JobDirs, JOBS_DIR,
};
let Some(jobs) = self.jobs.clone() else {
return Err(ImportError::new(
&holger_errcode::SANDBOX_JOB_RUN_FAILED,
503,
"this server has no job runner, and a bundle is verified only in a job",
));
};
let file = bundle
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let failed = |code: &'static ErrCode, status: u16, message: String, job: &str| {
let ev = AuditEvent::new(
actor,
AuditAction::Other,
"imports",
file.clone(),
source,
status,
0,
)
.with_detail(format!(
"bundle-import refused by the verify-bundle job {job} (runner {}): {message}",
jobs.features().runner
));
if let Err(e) = self.audit.record(ev).and_then(|()| self.audit.flush()) {
log::warn!("bundle import: the refusal of {file} could not be audited: {e:?}");
}
ImportError::new(code, status, message)
};
let id = JobId::random();
let dirs = JobDirs::new(&self.data_root.join(JOBS_DIR), &id)
.map_err(|e| staging(format!("the job's directories: {e}")))?;
crate::job_runner::link_input(bundle, &dirs.input, vb::BUNDLE_FILE)
.map_err(|e| staging(format!("put {} into the job: {e}", bundle.display())))?;
let policy_json =
serde_json::to_vec(policy).map_err(|e| staging(format!("the trust policy: {e}")))?;
std::fs::write(dirs.input.join(vb::POLICY_FILE), policy_json)
.map_err(|e| staging(format!("the job's trust policy: {e}")))?;
if let Some(a) = &self.attest {
let doc = serde_json::to_vec(&a.trust_doc())
.map_err(|e| staging(format!("the attestation trust: {e}")))?;
std::fs::write(dirs.input.join(vb::ATTEST_FILE), doc)
.map_err(|e| staging(format!("the job's attestation trust: {e}")))?;
}
// Lane HOL-HEIMDALL-04: with Heimdall's released guests laid and pinned, the job verifies
// the bundle in the split wasm guest (the guests a read path, their root in the input).
let mut params = vb::Params::at(target, &self.time());
let mut read = Vec::new();
match jobs.guests() {
Some(Ok(g)) => {
std::fs::write(dirs.input.join(vb::GUEST_ROOT_FILE), &g.root_der)
.map_err(|e| staging(format!("the guests' trust root: {e}")))?;
params.guest = Some(vb::GuestParam { read: read.len() });
read.push(g.dir.clone());
}
Some(Err(why)) => {
return Err(failed(
&holger_errcode::SANDBOX_JOB_RUN_FAILED,
503,
format!("the verify-bundle guests are not the pinned ones, so no bundle is verified: {why}"),
&id.to_hex(),
))
}
None => {}
}
let params = serde_json::to_value(params).map_err(|e| staging(e.to_string()))?;
let job = Job {
id,
nonce: Nonce::random(),
kind: runner::worker(&[vb::PROGRAM]),
input_dir: dirs.input.clone(),
output_dir: dirs.output.clone(),
params,
policy: Policy {
read,
limits: Limits {
wall: std::time::Duration::from_secs(600),
memory_bytes: 4 << 30,
cpus: 1.0,
pids: 64,
..Default::default()
},
..Default::default()
},
};
let result = jobs.run(job).map_err(|f| {
failed(
f.code,
if f.code == &holger_errcode::SANDBOX_JOB_RUN_FAILED {
503
} else {
422
},
f.message,
&id.to_hex(),
)
})?;
if let Verdict::Refused { code, reason } = &result.verdict {
let c = holger_errcode::by_code(code).unwrap_or(&holger_errcode::IMPORT_REFUSED);
return Err(failed(c, 422, reason.clone(), &id.to_hex()));
}
vb::read_outputs(&dirs.output).map_err(|e| {
failed(
&holger_errcode::SANDBOX_JOB_ANSWER_INVALID,
422,
format!("the verify-bundle job's outputs: {e}"),
&id.to_hex(),
)
})
}
/// **The verified `core.json` of `bundle` under `policy`**, from a verify-bundle job
/// (`holger bundle allow-first-import --from-bundle`: a field of a bundle is believed
/// only once it verified, and the bundle is opened only in the job).
#[cfg(feature = "intake")]
/// A server with no intake chain has no job runner: there (an intel-only box, whose
/// intel import is not yet a job) the signature is checked here, as before.
pub fn verified_core(
&self,
bundle: &Path,
policy: &TrustPolicy,
actor: &str,
source: &str,
) -> Result<BundleCore, ImportError> {
let members = match &self.jobs {
Some(_) => {
self.verify_in_job(bundle, policy, &TargetSpec::default(), actor, source)?
.0
}
None => load_bundle_verified(bundle, policy).map_err(|e| {
ImportError::refused(format!("bundle verification failed: {}", e.code().tag(&e)))
})?,
};
BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| ImportError::refused("bundle has no core.json"))?,
)
.map_err(|e| ImportError::refused(format!("core.json: {e}")))
}
// ── the drop directories ──────────────────────────────────────────────
/// The files waiting in `kind`'s inbox, oldest name first.
pub fn pending(&self, kind: ImportKind) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(self.inbox(kind))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
let name = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
!name.starts_with('.') && !name.ends_with(".part")
})
.collect();
out.sort();
out
}
/// **Sweep `kind`'s inbox**: import every waiting file, then move it to
/// `archive/` or `refused/` (with its `.why.txt`). The jera job runs this.
pub fn sweep(&self, kind: ImportKind) -> anyhow::Result<InboxSweep> {
let inbox = self.inbox(kind);
let mut sweep = InboxSweep::default();
for file in self.pending(kind) {
let name = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let stamp = crate::audit::now_nanos() / 1_000_000_000;
let result = if name == FIRST_IMPORT_MARKER {
let e = first_import_flag_refusal("the inbox's `FIRST-IMPORT` marker");
self.audit_refusal(
&format!("{} inbox", kind.as_str()),
&file,
&e,
"inbox",
"local:inbox",
);
Err(e)
} else {
self.import(
kind,
&file,
&format!("{}-inbox", kind.as_str()),
"local:inbox",
)
};
match result {
Ok(line) => {
log::info!("{}: {name}: {line}", kind.inbox_name());
move_into(&file, &inbox.join(ARCHIVE_DIR), &format!("{stamp}-{name}"))?;
sweep.imported.push(name);
}
Err(e) => {
let why = e.tagged();
log::warn!("{}: {name} REFUSED: {why}", kind.inbox_name());
let dest =
move_into(&file, &inbox.join(REFUSED_DIR), &format!("{stamp}-{name}"))?;
let _ = std::fs::write(dest.with_extension("why.txt"), format!("{why}\n"));
sweep.refused.push((name, why));
}
}
}
Ok(sweep)
}
}
/// Move `file` into `dir` as `name`.
fn move_into(file: &Path, dir: &Path, name: &str) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let dest = dir.join(name);
std::fs::rename(file, &dest)
.map_err(|e| anyhow::anyhow!("move {} to {}: {e}", file.display(), dest.display()))?;
Ok(dest)
}
fn grant_refused(status: u16, why: String) -> ImportError {
ImportError::new(&holger_errcode::IMPORT_FIRST_GRANT_REFUSED, status, why)
}
fn staging(why: String) -> ImportError {
ImportError::new(&holger_errcode::IMPORT_STAGING_FAILED, 500, why)
}
fn load_policy(path: Option<&Path>, setting: &str) -> Result<TrustPolicy, ImportError> {
let path = path.ok_or_else(|| {
ImportError::new(
&holger_errcode::IMPORT_NO_POLICY,
409,
format!("no trust policy: set {setting} (or pass --policy to the CLI)"),
)
})?;
let text = std::fs::read_to_string(path).map_err(|e| {
ImportError::new(
&holger_errcode::IMPORT_NO_POLICY,
409,
format!("trust policy {}: {e}", path.display()),
)
})?;
TrustPolicy::from_ron(&text).map_err(|e| {
ImportError::new(
e.code(),
409,
format!("trust policy {}: {e}", path.display()),
)
})
}
/// Where an update bundle that is not an airgap export lands in production:
/// `<supplier>/<product>/<release>.holger.znippy`.
pub fn bundle_label(core: &BundleCore) -> String {
format!(
"{}/{}/{}.holger.znippy",
sanitize(&core.supplier),
sanitize(&core.product),
sanitize(&core.release_version)
)
}
fn sanitize(seg: &str) -> String {
let s: String = seg
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
if s.is_empty() || s == "." || s == ".." {
"_".into()
} else {
s
}
}
/// A staged bundle file, removed when dropped.
pub struct StagedBundle {
pub path: PathBuf,
file: Option<std::fs::File>,
}
impl StagedBundle {
/// Append a piece of the streamed bundle.
pub fn write(&mut self, bytes: &[u8]) -> Result<(), ImportError> {
use std::io::Write;
match self.file.as_mut() {
Some(f) => f
.write_all(bytes)
.map_err(|e| staging(format!("write the staged bundle: {e}"))),
None => Err(staging("the staged bundle is already closed".into())),
}
}
/// Flush and close the file; the path stays until the guard drops.
pub fn finish(&mut self) -> Result<(), ImportError> {
if let Some(f) = self.file.take() {
f.sync_all()
.map_err(|e| staging(format!("flush the staged bundle: {e}")))?;
}
Ok(())
}
}
impl Drop for StagedBundle {
fn drop(&mut self) {
self.file.take();
let _ = std::fs::remove_file(&self.path);
}
}
/// The refresh's feed list and its signer (`intel-refresh` only).
#[cfg(feature = "intel-refresh")]
mod refresh {
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use holger_intel::feeds::{FeedKind, FeedSpec, DEFAULT_ECOSYSTEMS};
use holger_intel::SignerFiles;
use update_bundle::digest::sha256_hex;
use update_bundle::policy::{
ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy,
};
use update_bundle::schema::POLICY_SCHEMA;
use crate::intel::IntelConfig;
/// Where the box keeps its refresh signer, under `intel.db_dir`.
pub const SIGNER_DIR: &str = "refresh-signer";
const ROLES: [&str; 3] = ["security-reviewer", "system-owner", "release"];
/// The feeds to fetch: the mirror's `index.txt`, or the public set.
pub fn feed_specs(
intel: &IntelConfig,
net: &holger_intel::IntelNet,
year: i32,
) -> Result<Vec<FeedSpec>> {
let Some(mirror) = intel
.feed_mirror
.as_deref()
.map(|m| m.trim_end_matches('/'))
else {
let ecosystems: Vec<String> = if intel.ecosystems.is_empty() {
DEFAULT_ECOSYSTEMS.iter().map(|s| s.to_string()).collect()
} else {
intel.ecosystems.clone()
};
return Ok(FeedSpec::default_set(&ecosystems, year));
};
let index = net
.get(&format!("{mirror}/index.txt"))
.map_err(|e| anyhow::anyhow!("fetch {mirror}/index.txt: {e}"))?;
let index = String::from_utf8(index).context("the mirror's index.txt is not UTF-8")?;
let mut out = Vec::new();
for name in index
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
if name.contains('/') || name.starts_with('.') {
bail!("the mirror's index names {name:?}, which is not a plain feed file name");
}
let stem = name.strip_suffix(".gz").unwrap_or(name);
let stem = stem
.strip_suffix(".zip")
.or_else(|| stem.strip_suffix(".json"))
.unwrap_or(stem);
let kind = if let Some(eco) = stem.strip_prefix("osv-") {
FeedKind::Osv {
ecosystem: eco.to_string(),
}
} else if let Some(n) = stem.strip_prefix("nvd-") {
FeedKind::Nvd {
name: n.to_string(),
}
} else if stem == "kev" {
FeedKind::Kev
} else {
bail!("the mirror's index names {name:?}, which is no feed (osv-<Ecosystem>, nvd-<name>, kev)");
};
out.push(FeedSpec {
kind,
uri: format!("{mirror}/{name}"),
});
}
if !out.iter().any(|s| matches!(s.kind, FeedKind::Osv { .. })) {
bail!("the mirror's index names no OSV feed, so the set would scan everything clean");
}
Ok(out)
}
/// The box's own refresh signer: three ed25519 identities on the data disk.
pub struct Signer {
files: Vec<(String, SignerFiles, String)>,
}
impl Signer {
pub fn open_or_mint(dir: &Path) -> Result<Self> {
std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
let mut files = Vec::new();
for role in ROLES {
let (cert, key): (PathBuf, PathBuf) = (
dir.join(format!("{role}.der")),
dir.join(format!("{role}.pk8")),
);
if !cert.exists() || !key.exists() {
let kp = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519)
.context("mint an ed25519 key")?;
let mut params =
rcgen::CertificateParams::new(vec![]).context("cert params")?;
params.distinguished_name.push(
rcgen::DnType::CommonName,
format!("holger intel.refresh {role}"),
);
let c = params.self_signed(&kp).context("self-sign")?;
write_private(&key, &kp.serialize_der())?;
std::fs::write(&cert, c.der())
.with_context(|| format!("write {}", cert.display()))?;
}
let sf = SignerFiles::read(&cert, &key)?;
let fp = sha256_hex(&sf.cert_der);
files.push((role.to_string(), sf, fp));
}
Ok(Self { files })
}
fn fp(&self, role: &str) -> String {
self.files
.iter()
.find(|(r, ..)| r == role)
.map(|(_, _, f)| f.clone())
.unwrap_or_default()
}
fn files(&self, role: &str) -> SignerFiles {
let (_, f, _) = self.files.iter().find(|(r, ..)| r == role).expect("minted");
SignerFiles {
cert_der: f.cert_der.clone(),
key_pkcs8: f.key_pkcs8.clone(),
}
}
pub fn approvals(&self) -> Vec<(String, SignerFiles)> {
vec![
("security-reviewer".into(), self.files("security-reviewer")),
("system-owner".into(), self.files("system-owner")),
]
}
pub fn release(&self) -> SignerFiles {
self.files("release")
}
/// The policy that trusts exactly this signer, for intel only.
pub fn policy(&self) -> TrustPolicy {
TrustPolicy {
schema_version: POLICY_SCHEMA.into(),
trust_mode: Some(TrustMode::PinnedSigners),
trusted_roots: vec![],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![
ApprovedSigner {
fingerprint: self.fp("security-reviewer"),
subject: None,
roles: vec!["security-reviewer".into()],
},
ApprovedSigner {
fingerprint: self.fp("system-owner"),
subject: None,
roles: vec!["system-owner".into()],
},
],
required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
minimum_distinct_approvers: 2,
trusted_release_signers: vec![self.fp("release")],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: true,
on_expired_metadata: Some(ExpiredPosture::Refuse),
require_sbom: false,
require_vex: false,
require_scan: false,
allowed_products: vec![holger_intel::INTEL_PRODUCT.into()],
allowed_models: vec![],
}
}
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o
.open(path)
.with_context(|| format!("create {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("write {}", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_raw_bypass_is_refused_by_name() {
let e = raw_bypass_refusal("bundles");
assert!(e.tagged().starts_with("[holger-import-1]"), "{e}");
assert!(e.tagged().contains("--repo bundles"), "{e}");
}
#[test]
fn a_landing_path_segment_cannot_climb() {
assert_eq!(sanitize("acme corp"), "acme_corp");
assert_eq!(sanitize(".."), "_");
assert_eq!(sanitize("../1.2"), ".._1.2");
assert_eq!(sanitize(""), "_");
}
}