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

nordisk/holger

docs/v1/demo-airgap.md

The Holger 1.0 airgap demo

Holger is a secure artifact gateway for air-gapped and high-assurance environments. scripts/demo-airgap.sh shows the whole walk with two Holger servers and a directory that stands in for the USB stick. Nothing is mocked, and there are no special hacks: the secure side runs in a podman pod with --network none, and an unmodified pip install installs the wheel there.

Run it

One command builds the binaries, runs the demo tests, runs the demo and reads its receipt:

cargo xtask demo airgap                       # build, demo tests, the demo, the receipt
cargo xtask demo airgap --bin-dir <dir>       # binaries built elsewhere (the toolchain container)
cargo xtask demo tests                        # only the declared demo test row

The verb is green only when every check in the receipt passed. A check whose lane has not landed (NOT YET) makes it INCOMPLETE (exit 2), never green. By hand:

cargo build --release -p holger -p holger-update-bundle \
  --features holger/secure_update,holger/sign,holger/holger-server-alias --bins
cargo build --release -p holger-provenance --example sign_slsa
HOLGER_BIN_DIR=target/release scripts/demo-airgap.sh

Every run writes receipt.json (schema holger.demo-airgap-receipt/v1: one check per step, with its result and detail, and the facts: the package, its sha256, the ClamAV image and database hashes, the YARA rules, the bundle's sha256), a plain-text demo.log a person can read, and secure-side.out into DEMO_OUT (default target/demo-airgap/<UTC time>).

cargo xtask demo tests is the declared row for intake_serves_pip (with its ignored real-pip arm) and airgap_export_import, under their own features.

The script needs podman, openssl, curl, sha256sum and python3 on the host, and sign_slsa (cargo build --release -p holger-provenance --example sign_slsa). It pulls the python image once (PYTHON_IMAGE, default python:3.13-slim-trixie, the same Debian release as the toolchain image). The online side downloads DEMO_PACKAGE (default idna==3.20) with pip; set DEMO_WHEEL to use a local wheel instead. The intel bundle is built from the fixture OSV/NVD/KEV feeds in intel/tests/fixtures; DEMO_INTEL_LIVE=1 fetches the live feeds instead. DEMO_KEEP=1 keeps the work directory.

Each step prints its result. The first failure stops the script and prints the command's output. A step whose lane has not landed prints NOT YET: <lane>; it is never counted as a pass.

What happens

Step What runs
Malware engines ClamAV runs as clamd from CLAMAV_IMAGE, pinned by digest (default clamav/clamav:1.5.4@sha256:e19130cd…). freshclam never runs, so the signature database is exactly the image's. The CVD files are copied onto the USB stick with their sha256. The YARA rules are scripts/demo/yara/*.yar (EICAR), run by Holger's built-in matcher with no yara binary; they ride the USB stick too. Both are required: true in promotion.intake.inspect on both sides.
Scan sandbox Every scan on both sides runs in the scan worker's container (localhost/holger-scan-worker, minted FROM scratch by cargo xtask scan-worker image): rootless podman over an API socket of the demo's own, --network none, the artifact read-only, a capped tmpfs, a read-only root, no capabilities, uid 65532, limits and a deadline. The image rides the USB stick as an OCI archive with its sha256, and the secure side checks it; it never pulls.
Offline intel holger-server intel build normalizes OSV, NVD and KEV into a signed, versioned holger.intel/v1 bundle (two approvals, a release signer); intel import verifies it into the online Holger's local advisory database. The same bundle rides the USB stick.
Online Holger Four raw repositories form the intake chain (incoming → quarantine → approved → production). Static-token principals hold duties (importer: Importer; sec-anna, sec-bert: SecurityApprover). approvals: 2. The evidence signer is a P-256 leaf under its own root.
Import pip downloads the wheel; PUT /incoming/<wheel> with the importer's token. The wheel is INGESTED and not downloadable.
Hash The sha256 recorded at INGESTED equals the file's, and every later state re-checks it.
EICAR A wheel with the EICAR test file planted in it (eicar_demo/eicar.com) is refused at the door before a byte is stored, by name: ClamAV's signature and the YARA rule EICAR_Test_File.
Scan INGESTED → SCANNED runs every required scanner (inspection, malware, vulnerability, license, sbom) and stores signed evidence by sha256: the file inspection, ClamAV and YARA, the CVE scan against the verified intel database, the license scan and the CycloneDX SBOM.
Provenance The demo's build system signs a SLSA Provenance v1 statement about the wheel (DSSE plus an in-toto Statement v1, Ed25519 key demo-release, sign_slsa from holger-provenance) into <data_root>/holger-provenance/<sha256>/. Both sides run promotion.intake.scan.provenance: (rules: (keys: [<demo-release>], builders: [<builder>]), default: require(keys: ["demo-release"], slsa_min: 2)), and provenance is a required scanner. SCANNED records provenance:holger-provenance=pass.
Tampered provenance After the scan, the stored attestation is rewritten (another builder id, the old signature). A second export carries it in a bundle whose own signatures are all valid. The secure side refuses that bundle by holger-provenance-3 before anything lands.
Policy SCANNED → POLICY_CHECKED runs the osv gate: CVEs, and the license policy read from the wheel's METADATA (deny_licenses, unknown = hold).
Quarantine sec-anna holds the artifact, the importer's release is refused by a duty, and sec-bert releases it. Nothing is served meanwhile.
Approval The importer's approval is refused. sec-anna is 1 of 2, and sec-bert makes it APPROVED.
Seal SEALED → PROMOTED → AVAILABLE. Production serves the sealed bytes, with the ingested sha256.
Export holger-server export takes the AVAILABLE set with its evidence, attestations, SBOMs and intake records, carrying only what the secure side decides by (the intake records as their decision lines: no importer name, reason, timing or repository; server/lib/src/export_profile.rs). The verdict is computed from the stored evidence. holger-bundle approve (two roles) and finalize (a distinct release signer) sign the bundle onto the USB directory.
Online trail holger-server audit trail --sha256 <wheel> (as an Auditor) shows the artifact's one tamper-evident trail: imported → hash_verified → scanned → policy_passed → approved → sealed → exported. The export writes the exported step for each artifact (IntakeChain::record_export). audit verify walks the hash chain and must pass.
Carry over The USB directory holds the export bundle, the intel bundle, both trust policies, and the online evidence root and signer certificate.
No internet Inside the pod (--network none, loopback only), pypi.org and 1.1.1.1 are unreachable.
Secure ClamAV A clamd in the same pod loads the database from the USB stick (its sha256 checked first) and must report the same database version as the online side.
Tamper A copy with one flipped byte is refused before anything is written.
Verified import holger-server bundle import checks the release signature, the anti-rollback floor, every payload's sha256/blake3/size against the export index and the signed core.json, and every evidence attestation against the secure side's own root and enrolled signer. Each artifact lands AVAILABLE in the secure production repository; every state is audited first, and a failed audit write lands nothing.
Secure rescan Before anything lands, the secure side verifies the carried provenance again under its own require policy (audited), and IntakeChain::land_imported runs its own malware engines (its clamd and YARA rules) over every artifact the bundle carries. A hit refuses the whole import by name and is audited. A clean pass is recorded as malware:secure-side-rescan=pass on the INGESTED entry. A side that declares no engine lands as before.
Secure intel intel import verifies the same intel bundle into the secure side's own advisory database, offline.
Serve offline The secure server runs network: Offline: a config that needs the network is refused at boot (holger-offline-2), and every outbound dial is refused before a socket opens (holger-offline-1).
Secure trail The same verbs on the secure side: the trail of the same sha256 carries hash_verified and airgap_imported, and audit verify passes. The landing's own records carry no trail step, so the secure trail never claims a scan or an approval that happened on the other side.
pip pip install --index-url http://127.0.0.1:…/production/simple/ idna==3.20 installs from the PEP 503 index; every link carries #sha256=. A package that was never exported is not installable.

The pieces

The tests