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

nordisk/holger

docs/v2/threat-model.md

Holger: the system threat model

This page models the whole system: the online side, the diode or inbox, the airgap side, the downstream, the console, the estate front, the scan sandbox, and later Heimdall. The older model of the secure update gateway alone is still in v1/secure-update-gateway-threat-model.md; its per-stage attacks (intake, candidate, approvals, finalization, transport, import) still hold and are not repeated here.

It was written by lane HOL-NCSC (2026-10-31) from the NCSC cross-domain gap list (plan rows HOL-CDS-1 to HOL-CDS-5). Each mitigation names the code that does it and the test that holds it. A row marked open has no merged mitigation on main yet.

1. Assets

asset where it lives why it matters
the approved packages production and the AVAILABLE catalog on each side what build systems install; one bad package reaches every build
the anti-rollback floor the import door's floor per vendor and product, and the first-import grants (<data_root>/holger-first-import/) without it an old, vulnerable signed bundle can be replayed
the trust policies and keys promotion.intake.trust_policy, intel.trust_policy, the plugin trust root, the attestation and sync signers who may sign what crosses the boundary
the audit log audit.jsonl (hash-chained, checkpoints signed) and its Arrow index the only record of who did what; NCSC requires it to be complete
identities and sessions the passkey store, the console's machine token, repository tokens, mTLS client certificates they decide who may approve, import and administer
the scan sandbox the laid rootfs, the worker image, the sandboxed clamd and its signatures an unpacked artifact is attacker-controlled data
the release crates, binaries, media, app archives and their statements what every customer installs (chapter 7)

2. Trust boundaries

  1. The online side ↔ the internet. The pull-through and mirrors fetch from public registries; everything fetched is untrusted until the intake chain has scanned it and two people approved it.
  2. The diode or the inbox. A one-way Holger takes packages only through its inbox (a diode folder or a USB stick): boundary: OneWay (the default for the downstream), with no fetch code in the default build. Fetching from one upstream Holger is a separate build (--features fetch) that is written down as boundary: Fetch.
  3. The airgap side. It verifies every bundle offline (signature, floor, each member's digest, the carried evidence) before anything lands, and makes no network call (network: Offline).
  4. The downstream. Serves the build systems on its LAN what its upstream approved; it runs no scanner and takes no upload.
  5. The console (holger-ui). A thin layer: it only calls the server's doors and shows what they answer. Every decision is the server's.
  6. The estate front. The internet-facing TLS front of a sold estate; the boxes behind it are reached over WireGuard only.
  7. The scan sandbox. Every unpack, parse and engine run happens in a rootless container with no network, never in the server process.
  8. Heimdall (later, plan row HEIMDALL-1, on hold). Moves the verifier and the untrusted parsers into wasm guests inside a youki container with a generated seccomp list, so the server never parses a bundle at all.

3. Adversaries

who what they can do
A1 a hostile upstream or package author publish a malicious or vulnerable package, a crafted archive, a fake provenance
A2 a network attacker on a LAN or on the internet read and change clear text, replay, probe the doors, try TLS downgrades
A3 a courier or a hostile medium change, replay or swap a bundle on the way across the diode
A4 a holder of a stolen machine token or client certificate act as the console or a CI runner
A5 one malicious insider with a role approve alone, import alone, hide what they did
A6 a compromised parser or scanner run code with the server's rights if it runs in the server's process
A7 a compromised build host or release requester ship a binary or a medium that is not the attested one

4. The NCSC gaps and what closes them

P1: safe defaults and the boundary (all merged)

gap adversary mitigation code test
a Holger that a network can reach without authentication A2 a listener that is not loopback by name needs an auth method (holger-listen-1) Holger::validate_listeners, server/lib/src/listen.rs server/lib/tests/safe_defaults.rs no_authentication_refuses_a_listener_off_loopback
clear text off loopback A2 TLS, or insecure: true written on the endpoint (holger-listen-2); the CLI defaults to https:// same; holger-cli/src/conn.rs a_cleartext_listener_off_loopback_needs_insecure_true, a_bare_door_is_https_unless_it_is_this_machine
every signed-in user was an admin A4, A5 configured authentication implies RBAC with default_role: Reader; Critical needs a role policy Holger::serving_auth configured_authentication_makes_every_unnamed_identity_a_reader, critical_requires_a_role_policy_in_the_file
one approver was enough A5 steps: asks for two approvers by default; the importer never approves its own import server/lib/src/sources.rs, promote/duties.rs steps_ask_for_two_approvers_by_default
a Holger could run without an audit log A5 no profile starts with the audit log off (holger-audit-13); an audit dir that cannot be opened refuses the start (holger-audit-12) Holger::validate_profile, build_audit_log no_profile_runs_without_its_audit_log
the first import set the floor by a caller's flag A3 the first import is an admin's grant on the secure side, used once and audited (holger-import-9..11); the caller flags and the inbox marker are refused server/lib/src/import_door.rs, holger bundle allow-first-import, holger-cli bundle allow-first-import, the console's Admin → Sync form server/cli/tests/one_way_downstream.rs, holger-cli/tests/against_a_holger.rs an_intel_bundle_is_imported_through_the_admin_door
the console's machine token could invite approvers A4 an approver invite needs a person's stepped-up passkey session (holger-auth-6); an admin is never invited PasskeyDoor::mint_invite_as passkey::tests::an_approver_invite_needs_a_stepped_up_person_never_the_machine_token, an_estate_admin_invites_its_importer_and_approver.rs
the sandbox rootfs was hashed only at the first lay; a medium dir from the environment; the worker image by tag A6, A7 re-hashed at every start (holger-sandbox-14); HOLGER_SANDBOX_MEDIA_DIR ignored on a korp-booted box; every run names the image by digest server/lib/src/sandbox/ every_start_re_hashes_the_laid_sandbox_and_lays_a_changed_one_again, the_media_dir_variable_is_ignored_on_a_box_korp_booted, every_run_names_the_image_by_its_digest_never_its_tag
a host clamd, clamscan or a TCP clamd A2, A6 a real build talks only to the sandboxed clamd's unix socket (holger-sandbox-15); clamd says AlertExceedsMax yes sandbox::check_clamav, server/lib/src/sandbox/clamd.conf a_real_build_talks_only_to_the_sandboxed_clamd_socket, the_sandboxed_clamd_alerts_what_exceeds_its_limits
sockets that did not ask the egress guard A2 every socket asks egress::check first (SIEM, off-site copy, WireGuard tunnels, a test build's TCP clamd); Offline implies the SIEM's local_only server/lib/src/egress.rs, audit/siem.rs, offsite.rs, sync/tunnel.rs server/lib/tests/every_refusal_leaves_an_audit_row.rs every_socket_asks_egress_first
refusals left no record A5 every refusal leaves an audit row: a blocked dial, a refused import (signature, floor, core.json, intel, inbox), an unauthorised importer, a refused TLS handshake (60 rows a minute at most) server/lib/src/refusals.rs, the import door an_offline_holgers_refusals_are_audited_and_its_siem_stays_on_site, bundle_import_audit.rs a_refused_import_leaves_its_audit_row, integration_oci_tls.rs
a one-way side could fetch A1, A3 the downstream's default build carries no fetch code; boundary: OneWay refuses every way out by its line (holger-boundary-1) server/lib/src/boundary.rs one_way_downstream.rs, one_way_ships_no_client.rs
which side is this? A2 the RON's domain: label on every answer (X-Holger-Domain) and in the console's topbar boundary::validate_domain one_way_downstream.rs, holger-ui tests/the_topbar_names_the_domain.rs

P2 and P3: architecture and the supply chain

gap adversary mitigation state
the admin doors shared the data listener A2, A4 ron_admin_url / ron_admin_http_url with a CIDR allow-list; a peer outside it is dropped and audited (holger-listen-3, -4) merged (the_admin_listener_is_its_own_door.rs)
the export carried more than the decision A3 one export profile: every field that crosses is classified, the intake record crosses as its decision lines merged (the_export_carries_only_what_decides.rs)
no revocation for mTLS A4 ron_tls.crl: a revoked client certificate fails the handshake; Critical needs a CRL wherever client_ca is set (holger-tls-1) merged (a_revoked_client_certificate_fails_the_handshake_at_every_door)
one advisory list per tool A1 .cargo/audit.toml is the ids of deny.toml; cargo deny --locked check runs in CI merged (xtask/tests/audit_follows_deny.rs)
the release statement named only the crates A7 cargo xtask release-attest: every medium, each app archive and every binary by sha256, checked before signing merged (xtask/src/release_attest.rs tests)
the SLSA claim was L3 A7 the honest claim is Build L2, with what keeps it from L3 (chapter 7) merged
container formats not unpacked at INGEST (xz, zstd, ar, rpm, conda, 7z): NotUnpacked only logged A1, A6 the INGEST walk opens bzip2, xz, zstd, ar, rpm and conda; 7z and anything it cannot read blocks as not-unpacked (chapter 8) merged
no signed scanner signature bundle A1, A3 the signed bundle holger.scanner-sigs/v1 with a hotfix path (chapter 8); the installed YARA rules are not read yet merged
the clock can be moved back A3 a monotone time floor and a signed offline time token (chapter 10); the provenance trust-root bundles and holger bundle scrub still use the bare clock merged
key files readable by group or others; no hardware signer A6 such key files are refused (holger-key-1) and the attestation signer can be a PKCS#11 token (chapter 10); the operator tools' keys are not checked yet merged
the parsers run in the server process A6 Heimdall (HOL-CDS-4, HEIMDALL-1): verify inside a sandbox, the server never parses a bundle on hold (the owner's go)
a one-way appliance needs pairing for its network A3 a pairing-free PID-1 bring-up: the network from the medium, bundles from a HOLGERINBOX stick (chapter 7) merged

5. Residual risks