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
- 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.
- 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 asboundary: Fetch. - 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). - The downstream. Serves the build systems on its LAN what its upstream approved; it runs no scanner and takes no upload.
- 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.
- The estate front. The internet-facing TLS front of a sold estate; the boxes behind it are reached over WireGuard only.
- The scan sandbox. Every unpack, parse and engine run happens in a rootless container with no network, never in the server process.
- 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
- The server still parses bundles. Until Heimdall, a parser bug in the bundle verifier is a bug in the server's process. The hostile-index guard (znippy-common 0.10.3) and the fuzzers narrow it; they do not remove it.
insecure: trueon the estate and appliance media. The estate's doors bind on wg0 and the front terminates TLS, so the wire is encrypted; the appliance serves its LAN in clear text until it carries a certificate. A LAN attacker (A2) can read what the downstream serves, though not change what it trusts (the bundles are signed).- Reads are open by default (the airgap mirror's posture). Gating them is
require_auth_for_reads: true. - The release key is the release manager's. A release manager who is also the requester can sign a statement the build platform did not write (why the claim is SLSA Build L2).
- No transparency log. Release bundles are key-signed and verified offline.
- Refused TLS handshakes are rate-limited to 60 audit rows a minute; past that a summary row counts them. A flood is seen, not itemised.
- A test build may talk to a host or TCP clamd; only a real build is held to the sandboxed socket.
- The open rows of section 4 are open until they are merged; this page is updated with each.