diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml
index f521a62..c2f972d 100644
--- a/.gitea/workflows/gitea-main-ci.yaml
+++ b/.gitea/workflows/gitea-main-ci.yaml
@@ -8,16 +8,19 @@
# shell-local post-processors extract artifacts, build the UKI (ukify), and
# build+push the OCI release image.
#
-# This is an UNSIGNED (lab) build: sb_key/sb_cert are intentionally NOT passed,
-# so build-oci.sh emits its loud unsigned-UKI warning. A production fleet must
-# sign in CI/HSM -- see the "TODO: wire SB_KEY/SB_CERT" step below.
+# Signed when the SB_KEY/SB_CERT secrets are set; otherwise an UNSIGNED (lab)
+# build where build-oci.sh emits its loud unsigned-UKI warning. Signing runs
+# shell-local on the runner (not in the build VM); the key is staged to
+# RUNNER_TEMP and shredded after. See docs/secure-boot-keys.md.
#
# Required Gitea repo secrets:
# REGISTRY_USERNAME - registry user for `oci_image` (oci_push=true)
# REGISTRY_TOKEN - registry password/token
# SSH_AUTHORIZED_KEY - public key for the sudo admin user; empty => no admin
# user + root left unlocked (lab). Set it to lock root.
-# # SB_KEY, SB_CERT - (future) Secure Boot signing key + cert for signed UKIs
+# SB_KEY, SB_CERT - Secure Boot signing: the `db` PRIVATE key (PEM) + its
+# cert (PEM). Empty => unsigned lab build. PK/KEK are NOT
+# here (offline/HSM). Generate per docs/secure-boot-keys.md.
#
# Runner: needs KVM (qemu accelerator=kvm) and the build-host tooling installed
# below (libguestfs, erofs-utils, cryptsetup, jq, ukify, buildah).
@@ -115,8 +118,11 @@ jobs:
name: Install build-host tooling
# Tools the shell-local post-processors run on the RUNNER (not the VM):
# libguestfs (virt-*), erofs-utils (mkfs.erofs), cryptsetup
- # (veritysetup), jq, systemd-ukify (ukify), buildah (OCI), plus qemu +
- # shellcheck for the lint gate.
+ # (veritysetup), jq, systemd-ukify (ukify), sbsigntool (sbsign — used by
+ # ukify to sign the UKI when SB_KEY/SB_CERT are set), buildah (OCI), plus
+ # qemu + shellcheck for the lint gate.
+ # NB: the Debian/Ubuntu package is sbsigntool (singular); on Fedora/RHEL
+ # it's sbsigntools (plural).
#
# libguestfs builds a supermin appliance on first use, which needs a
# kernel + modules present on the runner (incl. xfs, to read the build
@@ -131,7 +137,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
qemu-system-x86 qemu-utils libguestfs-tools linux-image-generic \
erofs-utils cryptsetup-bin jq systemd-ukify systemd-boot-efi \
- buildah shellcheck attr
+ sbsigntool buildah shellcheck attr
sudo chmod 0644 /boot/vmlinuz-* 2>/dev/null || true
-
@@ -176,11 +182,32 @@ jobs:
--password "${{ secrets.PACKAGE_TOKEN }}" \
git.bitasylum.org
+ -
+ name: Materialize Secure Boot signing material
+ # sb_key/sb_cert are consumed by ukify as FILE PATHS (not values), and the
+ # signing post-processor runs shell-local on THIS runner (not in the build
+ # VM), so write the secrets to runner-temp files and pass their paths. The
+ # `db` PRIVATE key is the only signing key here; PK/KEK stay offline (see
+ # docs/secure-boot-keys.md). If SB_KEY is unset, this no-ops and the build
+ # falls back to an UNSIGNED lab UKI (the loud warning stays).
+ if: ${{ secrets.SB_KEY != '' }}
+ env:
+ SB_KEY: ${{ secrets.SB_KEY }} # db private key (PEM) — secret
+ SB_CERT: ${{ secrets.SB_CERT }} # db cert (PEM) — public, kept as a secret for convenience
+ run: |
+ umask 077
+ d="$RUNNER_TEMP/sb"; mkdir -p "$d"
+ printf '%s\n' "$SB_KEY" > "$d/db.key"
+ printf '%s\n' "$SB_CERT" > "$d/db.crt"
+ echo "SB_KEY_FILE=$d/db.key" >> "$GITHUB_ENV"
+ echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV"
+ echo "Secure Boot signing material staged (signed UKI build)."
+
-
name: packer build (UKI + OCI release image)
- # UNSIGNED lab build: sb_key/sb_cert deliberately omitted.
- # TODO: wire SB_KEY/SB_CERT secrets for a signed UKI:
- # -var sb_key=${{ secrets.SB_KEY }} -var sb_cert=${{ secrets.SB_CERT }}
+ # Signed when SB_KEY/SB_CERT secrets are set (paths exported above);
+ # UNSIGNED lab build (loud warning) when they're empty. sb_key/sb_cert are
+ # PATHS, never values — see the materialize step and docs/secure-boot-keys.md.
run: |
packer build -color=false -on-error=abort \
-var image_version="${{ steps.vars.outputs.image_version }}" \
@@ -190,11 +217,20 @@ jobs:
-var disable_dot1x=${{ env.DISABLE_DOT1X }} \
-var nvmet_traddr="${{ env.NVMET_TRADDR }}" \
-var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \
+ -var sb_key="${SB_KEY_FILE:-}" \
+ -var sb_cert="${SB_CERT_FILE:-}" \
-var kiosk_url="${{ env.KIOSK_URL }}" \
-var boot_splash=${{ env.BOOT_SPLASH }} \
-var enable_stig=${{ env.ENABLE_STIG }} \
.
+ -
+ name: Shred Secure Boot signing key
+ # Remove the private key from the runner FS even if the build failed.
+ # (RUNNER_TEMP is also wiped per-job on ephemeral runners — belt + braces.)
+ if: always()
+ run: shred -u "$RUNNER_TEMP/sb/db.key" 2>/dev/null || rm -f "$RUNNER_TEMP/sb/db.key" 2>/dev/null || true
+
-
name: STIG deviations report (report-only)
# The build downloaded the post-remediation scan (stig-arf.xml +
diff --git a/.gitignore b/.gitignore
index 27a3afb..ae81594 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
reports
+keys
+# Throwaway USBGuard mass-storage test image (see the USBGuard verify steps)
+/usbguard-test.img
diff --git a/CLAUDE.md b/CLAUDE.md
index 72ca1f3..017f1c7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -171,6 +171,33 @@ instead of working around it.
nondeterministic (timestamp/uuid/key); find + normalize it in 2d hygiene;
(d) read the generated `stig-deviations.md` — every REVIEW row is a real finding
to triage or justify in `docs/stig-deviations.md`.
+ **STIG-breaks-login gotchas already hit + fixed (2026-06-19) — don't regress:**
+ (i) **fapolicyd** blocks non-RPM executables → the kiosk's
+ `/usr/local/bin/kiosk-session` must be in fapolicyd's trust (done in
+ 2c-STIG-postfix); keep fapolicyd, don't waive it (it's the writable-overlay
+ exec guard dm-verity can't provide). (ii) **Admin SSH key must be ECDSA/RSA,
+ NOT ed25519** — the STIG crypto policy drops ed25519 from sshd's accepted
+ pubkey algorithms (`server-sig-algs` shows ecdsa+rsa-sha2 only), so an ed25519
+ key gets `Permission denied (publickey)` at the signature stage. (iii)
+ **passwordless/key-only appliance accounts** (kiosk, fleetadmin) are exempted
+ from STIG password-aging + faillock account phase in 2c-STIG-postfix (else
+ locked out). (iv) the kiosk unit is `WantedBy=multi-user.target` (not
+ graphical.target, which the host never reaches → unit dead at boot).
+ Offline diagnosis without booting: mount the OCI image's `rootfs.erofs`
+ read-only and inspect `/etc/pam.d`, fapolicyd, sshd config, etc.
+ **REVIEW-row triage (2026-06-20, scan app-9f7d8d1) added two more bench gates —
+ don't regress:** (v) **USBGuard is ENABLED + enforcing** (block target) with a
+ hardware-independent HID/hub allowlist + `AuditBackend=LinuxAudit` (2c-STIG). The
+ generated policy is built on the virtio build VM (no real USB), so without the
+ HID/hub allow rules a **physical console keyboard/mouse is blocked** on real
+ hardware — BENCH-VERIFY console input. (vi) **admin sudo SELinux elevation**:
+ the admin is mapped to **`staff_u`** (`semanage login`) + sudoers
+ `TYPE=sysadm_t ROLE=sysadm_r NOPASSWD:ALL` so `selinux_context_elevation_for_sudo`
+ passes; `staff_u` (not `sysadm_u`) so SSH login isn't gated by `ssh_sysadm_login`.
+ BENCH-VERIFY admin SSH login + sudo + privileged ops under confinement, and
+ **re-check reproducibility** (semanage rebuilds the policy store → possible
+ verity-hash drift). The triage waivers/decisions live in the tailoring +
+ docs/stig-deviations.md #12; the generated report should show 0–1 REVIEW.
- NTP via DHCP option 42 → chrony (no public-pool egress): confirm on a booted
host `chronyc sources` lists the DHCP-handed lab server (not `*.pool.ntp.org`).
Assumes (a) NM exports the lease's NTP servers to the dispatcher as
diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl
index 859206a..d557996 100644
--- a/diskless.pkr.hcl
+++ b/diskless.pkr.hcl
@@ -170,6 +170,8 @@ source "qemu" "diskless" {
output_directory = "output-qemu"
vm_name = "diskless-build.qcow2"
disk_size = "12G"
+ disk_interface = "virtio"
+ net_device = "virtio-net-pci"
format = "qcow2"
accelerator = "kvm"
memory = 8192
@@ -177,6 +179,7 @@ source "qemu" "diskless" {
headless = true
vnc_bind_address = "0.0.0.0"
qemuargs = [["-cpu", "host"],
+ [ "-smp", "cpus=4,cores=4,sockets=1" ],
[ "-serial", "stdio" ]]
http_directory = "http"
@@ -253,6 +256,20 @@ build {
# dracut warns and silently falls back to gzip (a larger initramfs).
"dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl",
+ # EFI / Secure Boot management tools, for MANUAL key enrollment over SSH on
+ # a workstation in Setup Mode: efitools (efi-updatevar, efi-readvar,
+ # cert-to-efi-sig-list, sign-efi-sig-list, KeyTool) + efivar/mokutil/
+ # efibootmgr companions. See docs/secure-boot-keys.md ("Manual enrollment");
+ # auto-enroll is TODO #10. They write to efivarfs
+ # (/sys/firmware/efi/efivars), NOT the read-only root, so they work at
+ # runtime; RPM-installed => already fapolicyd-trusted (no STIG trust edit).
+ # efitools is EPEL-only on el9: enable EPEL just to pull it, then drop the
+ # repo so a non-kiosk image's repo set is unchanged (kiosk builds re-add it).
+ "dnf -y install efivar mokutil efibootmgr",
+ "rpm -q efitools >/dev/null 2>&1 || { dnf -y install epel-release && dnf -y install efitools && EFITOOLS_EPEL=1; } || true",
+ "rpm -q efitools >/dev/null 2>&1 && echo '==> EFI Secure Boot tools installed (efitools + efivar/mokutil/efibootmgr)' || echo 'WARNING: efitools not installed (EPEL unavailable?) — manual SB enrollment tooling missing' >&2",
+ "[ \"$${EFITOOLS_EPEL:-0}\" = 1 ] && dnf -y remove epel-release >/dev/null 2>&1 || true",
+
# Plymouth boot splash. The custom scripted 'fleet' theme renders the
# security-handshake checklist driven by the dracut hooks
# (`plymouth update --status=...`). plymouth-plugin-label gives Image.Text
@@ -493,7 +510,10 @@ build {
destination = "/tmp/"
}
provisioner "shell" {
- environment_vars = ["ENABLE_STIG=${var.enable_stig}"]
+ environment_vars = [
+ "ENABLE_STIG=${var.enable_stig}",
+ "ADMIN_USER=${var.admin_user}",
+ ]
inline = [
"set -e",
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'enable_stig=false -> skipping STIG hardening.'; mkdir -p /root; echo '
STIG hardening disabled for this build (enable_stig=false).' > /root/stig-report.html; : > /root/stig-arf.xml; exit 0; fi",
@@ -514,6 +534,70 @@ build {
"echo \"==> Remediating against tailored STIG profile using $DS (this changes a lot)\"",
# --remediate returns nonzero when rules remain failed (expected) — tolerate.
"oscap xccdf eval --remediate --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true",
+ # --- Supplemental remediations (report triage 2026-06-19, scan app-9f7d8d1).
+ # Applied BETWEEN --remediate and the clean eval so the report of record
+ # reflects them. See docs/stig-deviations.md #12.
+ # (a) postfix: remove the unused local MTA. The appliance sends no mail and
+ # ships audit off-box via rsyslog (2f), so an MTA is dead surface. This
+ # makes postfix_prevent_unrestricted_relay N/A; package_postfix_installed
+ # is waived in the tailoring to match.
+ "echo '==> supplemental: removing unused MTA (postfix)'",
+ "dnf remove -y postfix >/dev/null 2>&1 || true",
+ # Removing postfix leaves auditd's STIG `space_left_action = email` with no
+ # MTA (it warns at boot and can't send). Repoint the 75%-full alert to
+ # SYSLOG so it rides the remote audit offload (2f) instead. Only
+ # space_left_action changes; admin_space_left_action stays `single` (STIG).
+ # The auditd_data_retention_space_left_action rule (wants email) is waived in
+ # the tailoring to match — see docs/stig-deviations.md #12.
+ "[ -f /etc/audit/auditd.conf ] && sed -i 's/^space_left_action[[:space:]]*=.*/space_left_action = SYSLOG/' /etc/audit/auditd.conf || true",
+ # (b) USBGuard: usbguard is already installed + enabled, but its policy was
+ # generated on the build VM (no real USB input). Add the LinuxAudit audit
+ # backend (configure_usbguard_auditbackend) AND a hardware-independent
+ # HID/hub allowlist so a physical console keyboard/mouse keep working
+ # under the default block target (a plain keyboard = HID only, a keyboard
+ # with built-in hub = HID+hub, so all three allow forms are needed).
+ # BENCH-VERIFY physical console input on real hardware.
+ "echo '==> supplemental: usbguard LinuxAudit backend + HID/hub allowlist'",
+ "if rpm -q usbguard >/dev/null 2>&1; then",
+ " [ -f /etc/usbguard/usbguard-daemon.conf ] || touch /etc/usbguard/usbguard-daemon.conf",
+ " sed -i '/^[ \\t]*AuditBackend=/Id' /etc/usbguard/usbguard-daemon.conf",
+ " printf 'AuditBackend=LinuxAudit\\n' >> /etc/usbguard/usbguard-daemon.conf",
+ " touch /etc/usbguard/rules.conf",
+ " for rule in 'allow with-interface match-all { 03:*:* }' 'allow with-interface match-all { 09:00:* }' 'allow with-interface match-all { 03:*:* 09:00:* }'; do",
+ " grep -qF \"$rule\" /etc/usbguard/rules.conf || printf '%s\\n' \"$rule\" >> /etc/usbguard/rules.conf",
+ " done",
+ " restorecon /etc/usbguard/usbguard-daemon.conf /etc/usbguard/rules.conf 2>/dev/null || true",
+ "else",
+ " echo 'WARNING: usbguard not installed; skipping (configure_usbguard_auditbackend stays a finding).'",
+ "fi",
+ # (c) selinux_context_elevation_for_sudo: STIG wants sudo to ELEVATE the
+ # SELinux context. SSG ships no auto-fix. Decision (2026-06-20):
+ # REMEDIATE WITH CONFINEMENT. Two parts:
+ # 1. Rewrite the admin sudoers rule to add the role/type transition
+ # (KEEP NOPASSWD — the admin is key-only; #8). This is what the
+ # OVAL check looks for, so it makes the rule pass.
+ # 2. Map the admin to the confined **staff_u** SELinux user so the
+ # staff_r->sysadm_r sudo transition is actually authorized at
+ # runtime (unconfined_u cannot reach sysadm_r). staff_u (NOT
+ # sysadm_u) so SSH login isn't gated by the ssh_sysadm_login bool.
+ # ⚠ BENCH-VERIFY: admin SSH login succeeds AND `sudo` works AND every
+ # privileged op the admin needs runs under the sysadm_t transition. If
+ # this locks the admin out, enable_stig=false is the escape hatch and
+ # this rule reverts to a justified deviation. ⚠ REPRODUCIBILITY: confirm
+ # the verity hash is stable across two builds — semanage rebuilds the
+ # SELinux policy store and may write nondeterministic content; if it
+ # drifts, normalize in 2d.
+ "if [ -n \"$ADMIN_USER\" ] && id \"$ADMIN_USER\" >/dev/null 2>&1; then",
+ " echo \"==> supplemental: sudo SELinux elevation — confining $ADMIN_USER to staff_u + sudoers role/type\"",
+ " command -v semanage >/dev/null 2>&1 || dnf install -y policycoreutils-python-utils",
+ " printf '%s ALL=(ALL) TYPE=sysadm_t ROLE=sysadm_r NOPASSWD:ALL\\n' \"$ADMIN_USER\" > \"/etc/sudoers.d/$ADMIN_USER\"",
+ " chmod 0440 \"/etc/sudoers.d/$ADMIN_USER\"",
+ " visudo -cf \"/etc/sudoers.d/$ADMIN_USER\"",
+ " restorecon \"/etc/sudoers.d/$ADMIN_USER\"",
+ " if semanage login -l 2>/dev/null | grep -qE \"^$ADMIN_USER[[:space:]]\"; then semanage login -m -s staff_u \"$ADMIN_USER\"; else semanage login -a -s staff_u \"$ADMIN_USER\"; fi",
+ " restorecon -R \"/home/$ADMIN_USER\" 2>/dev/null || true",
+ " echo \"==> $ADMIN_USER mapped to staff_u; sudo elevates to sysadm_r/sysadm_t\"",
+ "fi",
# Clean post-remediation eval = the report/ARF of record (no --remediate).
"oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true",
# GUARD: a scan with 0 evaluated rules (everything notapplicable/notselected)
diff --git a/docs/TODO.md b/docs/TODO.md
index 198d26d..eb8eff4 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -5,7 +5,7 @@ Persistent backlog, kept in sync with the working session task tracker. Status:
---
-## Current build posture (LAB / test) — as of 2026-06-18
+## Current build posture (LAB / test) — as of 2026-06-19
The image currently builds in **open-network test mode**. These toggles must be
flipped back for a secure/production build (consolidated as item #8):
@@ -15,12 +15,24 @@ flipped back for a secure/production build (consolidated as item #8):
per-env DNS name like `nvme.fleet.lab` once DNS-at-boot is trusted).
- Unsigned UKI (`sb_key`/`sb_cert` not passed) → Secure Boot must be OFF on the
test target. Production needs CI/HSM signing.
-- `enable_kiosk=false` by default (CI may set true); `enable_stig=true`;
- `boot_splash=true`; NTP via DHCP→serving-host chrony.
+- `enable_kiosk=true`; `boot_splash=true`; NTP via DHCP→serving-host chrony.
+- **`enable_stig` / CI `ENABLE_STIG="false"` RIGHT NOW** — temporarily disabled
+ to get a working box while the STIG regressions were diagnosed (now fixed,
+ below). **Flip back to `true`** and bench-verify the hardened build.
+- **`debug_shell=true` (DEBUG HOLE — passwordless root on tty2). REMOVE before
+ prod** (`-var debug_shell=false`). Note: it didn't actually come up on tty2 in
+ testing; diagnosis ended up done via offline `rootfs.erofs` mount + SMBIOS
+ `enforcing=0`. SMBIOS cmdline-extra works on Proxmox only after a full VM
+ **stop/start** (not `reset`).
+- **Admin SSH key MUST be ECDSA or RSA, NOT ed25519**, when STIG is on — the STIG
+ crypto policy drops ed25519 from sshd's accepted pubkey algorithms. Set the
+ `SSH_AUTHORIZED_KEY` CI secret to an ECDSA/RSA key (see #2).
- **Hostname:** image is hostname-neutral (empty `/etc/hostname`,
`hostname-mode=default`); the serving dnsmasq's `fleet-namer` (`--dhcp-script`)
assigns a stable creative starship name per MAC (e.g. `nebula-vanguard-07`) via
DHCP option 12 + DNS A/PTR. Host picks it up on next DHCP renewal/reboot.
+- **Serving `.env`:** must set `AUDIT_HOST=audit.fleet.lab` (+ `docker compose up
+ -d dnsmasq` to reload) for remote log offload to resolve/flow.
What's working/verified vs awaiting bench is per-item below. Cross-cutting
bench-verify checklist lives in `CLAUDE.md` → "Known bench-verify items".
@@ -78,8 +90,13 @@ in **[stig-deviations.md](stig-deviations.md)** (curated register) and reported
per-build by **[../scripts/stig-deviations.sh](../scripts/stig-deviations.sh)**
(non-pass rules tagged intentional vs REVIEW); CI publishes
`stig-report.html` + `stig-arf.xml` + `stig-deviations.md`. Scanner tooling is
-removed from the shipped image. **BENCH-VERIFY:** boot/login still work;
-reproducible verity hash across two builds; triage REVIEW rows.
+removed from the shipped image. **BENCH-VERIFY:** boot/login **VERIFIED ON THE
+BENCH 2026-06-19** — hardened build with `enable_stig=true`, host
+`cygnus-cruiser-99`: kiosk auto-starts + renders, admin SSH (ECDSA) logs in,
+audit offload reaches the collector, DHCP creative hostname applied, NVMe/TCP
+verity root mounts and the host stays up. **STILL OPEN:** reproducible verity
+hash across two builds; triage the REVIEW rows (below); the secure path (#8,
+802.1X/MACsec — this was the `disable_dot1x=true` test path).
**FINDING 2026-06-18 (FIXED) — false-clean scan.** The first hardened build
(branch `hardening`, `ada4aa4`) produced a report reading "0 REVIEW / clean",
@@ -107,34 +124,101 @@ two appliance accounts — `chage -M -1 -m 0 -I -1 -E -1` + `faillock --reset`;
kiosk uses a dedicated `/etc/pam.d/kiosk` (`PAMName=kiosk`, permissive account /
full session for seat); sshd gets an `account sufficient pam_succeed_if.so user
= fleetadmin` first-rule bypass. Scoped to those two accounts; everyone else
-keeps full STIG. Justified as register deviation #10. **Bench-verify on rebuild:
-kiosk renders + admin SSH works with hardening on.**
+keeps full STIG. Justified as register deviation #10. **VERIFIED ON THE BENCH 2026-06-19**
+(`enable_stig=true`, host `cygnus-cruiser-99`): kiosk auto-starts + renders and
+admin SSH (ECDSA key) logs in with hardening on.**
**UPDATE 2026-06-19 — offline erofs inspection of the hardened image (mounted
`rootfs.erofs` read-only) pinned the real causes; the PAM-account theory above
was incomplete:**
-- **Kiosk = fapolicyd (CONFIRMED).** fapolicyd is `enabled` and `kiosk-session`
- was not in its trust → it blocks `/usr/local/bin/kiosk-session`. Fix: KEEP
- fapolicyd (it's the exec-guard for the writable overlay dm-verity can't cover)
- and add the kiosk binary to its trust DB at build (2c-STIG-postfix). Not waived.
-- **SSH = NOT a STIG/PAM/SELinux issue.** `enforcing=0` didn't help; pam_namespace
- is inert (empty namespace.conf); account bypass present; key file + perms +
- sshd config all correct. Remaining cause: **baked key mismatch** — the
- `SSH_AUTHORIZED_KEY` CI secret bakes `audiobuzz@s3`
- (`SHA256:30p4vOQdWk0TdRQIR3VMo95yc6I7cRmCwIBRVjIHaV4`); if the client uses a
- different private key → `Permission denied (publickey)`. AWAITING client
- fingerprint / `ssh -vvv` to confirm; fix is the secret, not code.
-- Side note: SMBIOS cmdline-extra injection works only after a Proxmox VM
- **stop/start** (not `reset`); and the debug-shell-on-tty2 attempt did not come
- up — diagnosis ended up done via offline erofs mount instead.
+- **Kiosk had TWO causes, both FIXED:**
+ 1. **fapolicyd** is `enabled` and `kiosk-session` wasn't in its trust → it
+ blocked `/usr/local/bin/kiosk-session`. Fix: KEEP fapolicyd (it's the
+ exec-guard for the writable overlay dm-verity can't cover) and add the kiosk
+ binary to its trust DB at build (2c-STIG-postfix). Not waived.
+ 2. **Auto-start:** kiosk was `WantedBy=graphical.target` but the host boots to
+ `multi-user.target` (graphical never reached) → unit `inactive (dead)` at
+ boot, fine on manual `restart`. Fix: `WantedBy=multi-user.target` + dropped
+ `set-default graphical.target` (kiosk launches weston directly, needs no DM
+ target).
+- **SSH = STIG crypto policy excludes ed25519 (CONFIRMED via `ssh -vvv`).** The
+ key fingerprint MATCHED (not a mismatch); the server's `server-sig-algs`
+ advertised only ecdsa + rsa-sha2 — **no `ssh-ed25519`** — so the ed25519 admin
+ key is rejected at the signature stage (before PAM/SELinux, which is why
+ `enforcing=0` and the account-bypass didn't help). **Fix (your action): set the
+ `SSH_AUTHORIZED_KEY` CI secret to an ECDSA (`ssh-keygen -t ecdsa -b 384`) or RSA
+ key**, then rebuild. No code change. (Alt: re-allow ed25519 via
+ `PubkeyAcceptedAlgorithms +ssh-ed25519` in the sshd drop-in — but that's a
+ non-approved algo and would re-fail a STIG rule; prefer the ECDSA key.)
+- **rsyslog TLS-offload rules waived (lab):** `rsyslog_encrypt_offload_*` force
+ gtls+x509+CA and broke the plaintext `2f` forward → unselected in the
+ tailoring; production TLS offload tracked under #8.
+- Diagnosis tooling notes: SMBIOS cmdline-extra works only after a Proxmox VM
+ **stop/start** (not `reset`); the debug-shell-on-tty2 never came up — the
+ decisive diagnosis was **offline `rootfs.erofs` mount** + `ssh -vvv` +
+ `enforcing=0`.
+
+**REVIEW-ROW TRIAGE — DONE 2026-06-19 (scan `app-9f7d8d1`, 21 REVIEW → 1 open).**
+The post-fix hardened scan was valid (398 pass / 12 fail / 9 notchecked). All 21
+REVIEW rows dispositioned (full detail: [stig-deviations.md](stig-deviations.md)
+#12). Mechanics: a rule only stops being flagged REVIEW if it's `selected="false"`
+in `diskless-tailoring.xml` (the register prose alone doesn't clear it) or made to
+pass / N/A.
+ - **Bucket A (waived, register-justified):** `encrypt_partitions`,
+ `auditd_audispd_configure_sufficiently_large_partition` (#1); `grub2_password`
+ (#2); `sysctl_crypto_fips_enabled`, `harden_sshd_macs_openssh_conf_crypto_policy`,
+ `harden_sshd_macs_opensshserver_conf_crypto_policy` (#5);
+ `configured_firewalld_default_deny` (#4); `sssd_enable_certmap`,
+ `sssd_has_trust_anchor` (#7).
+ - **Bucket B (waived N/A):** `account_temp_expire_date`,
+ `accounts_user_home_paths_only`, `libreswan_approved_tunnels`,
+ `security_patches_up_to_date`, `ssh_keys_passphrase_protected`.
+ - **Bucket C (decisions applied):** `installed_OS_is_vendor_supported` (Rocky IS
+ supported; RHEL-datastream CPE artifact — waived), `network_configure_name_resolution`
+ (single DHCP DNS by design — waived), `sysctl_user_max_user_namespaces_no_remediation`
+ (KEEP userns for Chromium sandbox — waived), `accounts_authorized_local_users`
+ (appliance accounts authorized, #10 — waived; prod: set the authorized-users
+ regex var instead), `package_postfix_installed` + `postfix_prevent_unrestricted_relay`
+ (**postfix removed** in 2c-STIG — no MTA needed; audit goes off-box via 2f),
+ **USBGuard** `configure_usbguard_auditbackend` + `usbguard_allow_hid`/`_hub`/
+ `_hid_and_hub` (**ENABLED + enforcing**: LinuxAudit backend + hardware-independent
+ HID/hub allowlist written in 2c-STIG; ⚠ BENCH-VERIFY physical console keyboard/
+ mouse on real hardware — the build-VM-generated policy + default block target
+ would otherwise lock out console input).
+ - **`selinux_context_elevation_for_sudo` — REMEDIATED WITH CONFINEMENT
+ (decision 2026-06-20).** 2c-STIG now rewrites the admin sudoers to
+ `TYPE=sysadm_t ROLE=sysadm_r NOPASSWD:ALL` and maps the admin to **`staff_u`**
+ (`semanage login`) so the sudo role transition is authorized. ⚠ **TWO BENCH
+ GATES:** (a) admin SSH login + sudo + privileged ops must work under `staff_u`
+ confinement (escape hatch: `enable_stig=false` → reverts to a justified
+ deviation); (b) **reproducibility** — `semanage` rebuilds the policy store;
+ confirm the verity hash is stable across two builds (normalize in 2d if it
+ drifts). Needs `policycoreutils-python-utils` (installed in 2c-STIG if absent).
+
+ **Expected next-build report: 0–1 REVIEW** (0 if both postfix→N/A and
+ usbguard→pass land and sudo confinement holds; the triage was validated by
+ re-running `scripts/stig-deviations.sh` against the app-9f7d8d1 ARF with the new
+ tailoring — 21 REVIEW → 3, where the 3 are exactly postfix/usbguard/sudo, all
+ addressed for the next build).
Remaining (future hardening, currently waived — see the register): FIPS,
firewall, per-host/rotatable admin access. **Remote audit offload — WIRED both
ends** (2026-06-19): receiver `serving/audit-collector/` (rsyslog TCP/514,
per-source-host files) + sender in the image (provisioner 2f, `audit_host` var:
audisp-syslog→LOCAL6 + rsyslog `omfwd` to `audit.fleet.lab`). Lab is plaintext;
-TLS/RELP is the remaining hardening. **Bench-verify:** booted host's auditd AVCs
-land in `audit-collector/logs//audit.log` and journal in `messages.log`.
+TLS/RELP is the remaining hardening. **VERIFIED END-TO-END ON THE BENCH
+2026-06-19** — booted host `cygnus-cruiser-99` (192.168.50.148) forwards to the
+collector, which writes `audit-collector/logs/192.168.50.148/{audit,messages}.log`.
+**Serving-host firewall gotcha hit + fixed:** the collector is host-networked so
+the serving host's **ufw** governs :514 (Docker doesn't manage iptables for
+host-net containers); 514/tcp was bound to the wrong NIC (`ens18` instead of the
+boot-LAN `ens19`), so SYNs were silently dropped (`SYN-SENT` / "connection timed
+out", not refused). Fix: `ufw allow in on ens19 to any port 514 proto tcp`. The
+sender's `action.resumeRetryCount="-1"` buffered the backlog and drained on
+reconnect — no records lost. Documented in
+[../serving/audit-collector/README.md](../serving/audit-collector/README.md).
+When the **workstation** firewall is re-enabled (#4 / register #4) it needs the
+reciprocal **egress** allow for 514/tcp to the collector.
### Original spec (retained for reference)
@@ -540,6 +624,8 @@ smoke test.
by the serving dnsmasq — drop the hardcoded `192.168.50.10`.
- **Signing:** wire `sb_key`/`sb_cert` (CI/HSM secrets) for a signed UKI; enable
Secure Boot on targets. Until then the loud unsigned warning must stay.
+ **Key generation + enrolment procedure: [secure-boot-keys.md](secure-boot-keys.md)**
+ (the PK/KEK/db trio; `db` = `sb_key`/`sb_cert`, PK/KEK stay offline).
- **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS,
`disable_dot1x=false`) — the items deferred this session:
- NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA
@@ -603,3 +689,58 @@ Tips the balance toward "do it" only if: we start managing the **serving hosts**
roles to amortise — currently they're docker-compose), provisioning grows much
more branchy, or we want molecule-tested hardening roles validated without a
full ~17-min Packer build.
+
+## [ ] 10. (OPTIONAL) Secure Boot cert auto-enrollment for provisioning
+
+Speed up bringing up new workstations: bake the **public** PK/KEK/db certs into an
+image, detect Secure Boot **Setup Mode** at boot, and auto-enroll them with
+efitools (`efi-updatevar`/`KeyTool`) so a fresh/reset box adopts the fleet trust
+root without a manual firmware-setup visit. Pairs with #8 (signing) +
+[secure-boot-keys.md](secure-boot-keys.md).
+
+**What's safe:** the certs are public (no key material — only the **db** private
+key signs, and it stays in CI/HSM, invariant #7); Setup Mode accepts
+*unauthenticated* writes to PK/KEK/db, so public certs (ESL) are sufficient. It's
+fleet-generic (no per-host state, invariant #1) and deterministic (reproducible
+verity hash).
+
+**The real risk — provisioning-time TOFU (trust-on-first-use):** in Setup Mode
+Secure Boot isn't enforcing, so the enroller runs as *unverified* code, and the
+fleet's UEFI **HTTP boot is unauthenticated** in that state. An on-path attacker
+during provisioning can serve a malicious image that enrolls **their** PK/KEK/db —
+the box then "has Secure Boot" but trusts the attacker. Auto-enroll is only as
+trustworthy as the provisioning channel, and it removes the physical-presence
+approval gate. So treat it as a controlled-environment provisioning step, not an
+always-on behaviour.
+
+**Optional separate provisioning UKI (preferred shape):** build a dedicated
+**provisioning UKI** that carries the certs + the enroll logic, instead of arming
+the enroller in the production image. Keeps the standing footgun out of the normal
+image (a factory reset + tampered image can't silently re-root trust), and lets
+the enroller be a one-shot, attended/isolated step. The production UKI never
+enrolls.
+ - Build it as a separate Packer target / flag (e.g. `provisioning=true`) →
+ its own small UKI; signed or unsigned (it runs in Setup Mode anyway, but sign
+ it so it *also* boots once keys are enrolled / for re-runs).
+ - Or a tiny standalone EFI tool (KeyTool-style) served alongside, not a full OS.
+
+**Mitigations (do these regardless of UKI vs separate UKI):**
+ - Run provisioning on a **trusted, isolated VLAN** (or a physically controlled
+ bench) — the integrity Setup Mode can't give must come from the environment.
+ - **Gate** enrollment behind an explicit provisioning mode / one-shot token;
+ never arm it on every boot of the production image.
+ - **Read-back verify before declaring success:** `efi-readvar` the enrolled
+ PK/KEK/db and compare fingerprints to the values baked in; mismatch = fail
+ loudly (turns silent TOFU into tamper-evidence).
+ - **Audit** the enrollment event + read-back fingerprints to the collector.
+ - **Keep the Microsoft UEFI CA in `db`** (or enroll your db alongside it) unless
+ every NIC/GPU option ROM is covered by your keys — clearing MS db can break
+ signed **option ROMs** (NIC PXE/HTTP-boot, GPU init), bricking the boot path.
+ - **PCR 7 check:** enrollment changes the Secure Boot measurement in PCR 7;
+ confirm nothing the fleet seals to the TPM (the EAP-TLS key at `0x81010001`,
+ NV cert) is PCR-7-policy-bound before relying on this.
+
+**Decision before building:** auto-enroll trades assurance for convenience and is
+reasonable for a controlled fleet; the highest-assurance path is attended/firmware
+or manufacturer pre-provisioning. Default recommendation: **separate provisioning
+UKI + isolated VLAN + read-back verify.**
diff --git a/docs/secure-boot-keys.md b/docs/secure-boot-keys.md
new file mode 100644
index 0000000..1a739ea
--- /dev/null
+++ b/docs/secure-boot-keys.md
@@ -0,0 +1,170 @@
+# Secure Boot key generation & enrollment — diskless fleet
+
+The fleet boots a **signed UKI directly via systemd-stub** (no shim, no GRUB), so
+UEFI Secure Boot verifies the UKI against keys **you** enrol in firmware. This
+doc generates the three Secure Boot keys (the "trio": **PK, KEK, db**), enrols
+them, and wires the `db` key into the build.
+
+> **Invariant #7:** signing keys never enter the build VM or this repo. The
+> commands below produce key material that lives **offline / in an HSM**. Only the
+> **db** key signs UKIs routinely, and the build receives it as a *path*
+> (`sb_key`/`sb_cert`) on the CI/HSM host — never committed, never echoed in logs.
+> Treat every `*.key` file here as secret; never check one into git.
+
+## The trio — roles and where each lives
+
+| Key | Role | Used by | Where it lives |
+|---|---|---|---|
+| **PK** (Platform Key) | Root of trust; controls who may update the KEK. Setting it locks Secure Boot into User Mode. | Firmware enrolment only | **Offline / HSM**, used once (and for KEK rotation) |
+| **KEK** (Key Exchange Key) | Signs updates to `db`/`dbx`. | Firmware enrolment only | **Offline / HSM** |
+| **db** (Signature Database) | Signs the bootable UKI. | **The fleet build** — `sb_key`/`sb_cert` ([../scripts/build-oci.sh](../scripts/build-oci.sh) → ukify/sbsign) | HSM/CI signing host |
+
+Trust chain at enrolment: **PK signs KEK → KEK signs db → db signs the UKI.**
+
+Only `db` is online (it signs every release; the UKI cmdline is immutable once
+signed — invariant #6 — so re-signing is part of each build). PK and KEK are only
+needed to rotate keys later, so they stay offline.
+
+## Tooling
+
+```bash
+# Fedora / RHEL: dnf install openssl efitools sbsigntools
+# Debian / Ubuntu: apt install openssl efitools sbsigntool
+```
+
+## 1. Generate the three key/cert pairs
+
+```bash
+GUID=$(uuidgen); echo "$GUID" > GUID.txt # owner GUID for the EFI variables
+
+# Platform Key
+openssl req -newkey rsa:2048 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 \
+ -subj "/CN=Fleet Platform Key/" -out PK.crt
+# Key Exchange Key
+openssl req -newkey rsa:2048 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 \
+ -subj "/CN=Fleet KEK/" -out KEK.crt
+# Signature DB key <-- this pair becomes sb_key / sb_cert
+openssl req -newkey rsa:2048 -nodes -keyout db.key -new -x509 -sha256 -days 3650 \
+ -subj "/CN=Fleet db/" -out db.crt
+```
+
+`rsa:2048` is the maximally firmware-compatible size for the enrolled variables;
+`4096` works on most modern UEFI but some implementations reject it for
+PK/KEK/db. The PEM `db.key`/`db.crt` are exactly what sbsign/ukify consume.
+
+## 2. Convert to signed EFI variable updates (`.auth`)
+
+```bash
+for v in PK KEK db; do cert-to-efi-sig-list -g "$GUID" $v.crt $v.esl; done
+
+sign-efi-sig-list -g "$GUID" -k PK.key -c PK.crt PK PK.esl PK.auth # PK self-signs
+sign-efi-sig-list -g "$GUID" -k PK.key -c PK.crt KEK KEK.esl KEK.auth # PK signs KEK
+sign-efi-sig-list -g "$GUID" -k KEK.key -c KEK.crt db db.esl db.auth # KEK signs db
+```
+
+## 3. Enrol into firmware
+
+Put the firmware in **Setup Mode** first (clear the existing keys in the UEFI
+setup screen). Then enrol **db and KEK before PK** — setting PK last is what flips
+the firmware out of Setup Mode and turns Secure Boot on:
+
+```bash
+efi-updatevar -f db.auth db
+efi-updatevar -f KEK.auth KEK
+efi-updatevar -f PK.auth PK # do this LAST
+```
+
+Alternatively, enrol the `.auth`/`.esl` files via the firmware's own
+"enrol key from file" UI or `KeyTool.efi` — useful for headless fleet hardware
+where this is scripted into provisioning.
+
+> The fleet uses a UKI with **no shim and no Microsoft keys**, so no MOK is
+> needed. If a host must also boot vendor-signed media, *add* your `db` alongside
+> Microsoft's UEFI CA instead of clearing it.
+
+### Manual enrollment on a fleet workstation (over SSH)
+
+The build bakes the EFI management tools into the image (`efitools` +
+`efivar`/`mokutil`/`efibootmgr`, installed in the main package provisioner), so
+you can enrol keys **manually over SSH** on a booted workstation that is in Setup
+Mode — handy for provisioning before the automated flow (TODO #10) exists. The
+tools write to `efivarfs` (`/sys/firmware/efi/efivars`), not the read-only root,
+so they work at runtime; they're RPM-installed so fapolicyd already trusts them.
+
+```bash
+# on the workstation (as the sudo admin):
+mokutil --sb-state # check Secure Boot / Setup Mode
+efi-readvar # dump current PK/KEK/db/dbx
+# copy your PK.auth/KEK.auth/db.auth (or .esl) onto the host, then in Setup Mode:
+sudo efi-updatevar -f db.auth db
+sudo efi-updatevar -f KEK.auth KEK
+sudo efi-updatevar -f PK.auth PK # last — leaves Setup Mode
+efi-readvar # read back + confirm fingerprints match
+```
+
+Only the **public** certs/`.esl`/`.auth` need to reach the workstation — never
+copy `PK.key`/`KEK.key`/`db.key` onto a host.
+
+## 4. Wire `db` into the build
+
+```bash
+packer build -var image_version=X.Y.Z \
+ -var sb_key=/path/to/db.key \
+ -var sb_cert=/path/to/db.crt \
+ -var oci_image=/fleet-boot .
+```
+
+ukify signs `fleet-.efi` with `db`; the enrolled `db` cert lets firmware
+verify it. Without these vars the build stays **unsigned** (lab-only, with the
+loud warning). In CI, `sb_key`/`sb_cert` are secret paths on the HSM/signing host.
+
+## Signing in CI (Gitea Actions)
+
+`sb_key`/`sb_cert` **can** be Gitea repo secrets, with one wrinkle: ukify consumes
+them as **file paths**, not values, and the signing post-processor runs
+**shell-local on the runner** (not in the build VM — consistent with invariant
+#7). So the job materializes the secrets to runner-temp files, passes the paths,
+and shreds the key afterward. This is wired in
+[../.gitea/workflows/gitea-main-ci.yaml](../.gitea/workflows/gitea-main-ci.yaml)
+(steps: *Materialize Secure Boot signing material* → *packer build* → *Shred
+Secure Boot signing key*). Set repo secrets **`SB_KEY`** (the `db` private key,
+PEM) and **`SB_CERT`** (the `db` cert, PEM); leave them unset for an unsigned lab
+build.
+
+Caveats:
+- **Only `db` belongs in CI.** PK and KEK never leave the offline/HSM store.
+- `SB_CERT` is a public cert (not secret); kept as a secret only for convenience.
+- A CI secret puts the private key **in plaintext on the runner FS** for the
+ duration of the build — only as safe as the runner. Use a **trusted,
+ ephemeral/self-hosted** runner, ensure secrets are **not exposed to fork/PR
+ builds**, and rely on `RUNNER_TEMP` wipe + the shred step. Gitea masks secret
+ values in logs, and `build-oci.sh` passes the *path* (not content) to ukify, so
+ the key won't leak into logs.
+- **Stronger (production target):** keep `db.key` in an **HSM/PKCS#11** token or
+ KMS and sign *through* it, so the raw key never touches the runner disk. The
+ CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal
+ (TODO #8).
+
+## Key hygiene & lifecycle
+
+- **PK.key and KEK.key go offline / into an HSM and stay there.** They are only
+ needed to rotate keys; routine signing uses **db.key** only.
+- **Production ideal:** keep `db.key` in an HSM/PKCS#11 token and have
+ sbsign/ukify sign *through* it rather than reading a file from disk.
+- **Validity:** `-days 3650` is 10 years; pick a rotation policy. Rotating `db`
+ = sign new `db.esl` with KEK, push the `db` variable update, re-sign UKIs.
+- **dbx** (the revocation list) is a fourth, optional variable — manage it to
+ blacklist a compromised `db` cert.
+- **Lab shortcut:** [`sbctl`](https://github.com/Foxboron/sbctl)
+ (`sbctl create-keys` → `sbctl enroll-keys`) generates and enrols the whole trio
+ and tracks signed files — convenient for a dev box, but it keeps keys as local
+ files, so for the fleet's HSM/offline-PK model use the manual flow above for
+ PK/KEK.
+
+## Related
+
+- Invariants #6 (UKI cmdline immutable once signed), #7 (keys never in repo/VM),
+ #5 (roothash↔UKI hard gate) — [../CLAUDE.md](../CLAUDE.md).
+- Signing is the production-posture step in [TODO.md](TODO.md) #8.
+- FIPS is currently off and is to be revisited alongside signing/Secure Boot
+ ([stig-deviations.md](stig-deviations.md) #5).
diff --git a/docs/ssp-annex-a-ism-residual-risk.md b/docs/ssp-annex-a-ism-residual-risk.md
new file mode 100644
index 0000000..aa2c150
--- /dev/null
+++ b/docs/ssp-annex-a-ism-residual-risk.md
@@ -0,0 +1,221 @@
+# Annex A — ISM Control Cross-Reference & Residual Risk Register
+
+> **Protective marking:** `OFFICIAL: Sensitive` *(inherits the parent
+> [SSP](ssp.md) marking; set per system classification).*
+>
+> **Companion to:** [System Security Plan](ssp.md). Read the SSP first for system
+> description, boundary and roles.
+>
+> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
+> **May 2026 release**.
+
+---
+
+## How to read this annex
+
+This annex does two things:
+
+1. **§2 — ISM control cross-reference:** maps the ISM control set (by guideline
+ chapter) to *how this system implements it*, with an implementation **status**
+ and a pointer to any **residual risk**.
+2. **§3 — Residual risk register:** the consolidated, rated list of residual
+ risks (`R-NN`), each with ISM linkage, compensating controls, treatment and
+ owner. This is the artifact the Authorising Officer reasons over.
+
+### ⚠ Control-identifier currency caveat
+
+The `ISM-NNNN` identifiers below are **representative** and were drafted against
+an understanding of the ISM to a **January 2026 baseline**. The ISM is reissued
+quarterly and **renumbers/adds/retires controls between releases.** Before this
+annex is relied upon for assessment, the **IRAP assessor MUST reconcile every
+identifier against the actual May 2026 ISM**, confirm no newly-introduced
+controls are unmapped, and confirm none of the cited controls were retired. Where
+an exact number is uncertain it is shown as `ISM-NNNN*` (asterisk = *verify
+number*). The **control intent and the system's implementation** are the durable
+content here; the numbers are a convenience for the assessor.
+
+### Status legend
+
+| Status | Meaning |
+|---|---|
+| **Implemented** | Control is in place and operating in the current build. |
+| **Partial** | Implemented but incomplete, or scoped to lab config. |
+| **Planned** | On the roadmap / POA&M; not yet implemented. |
+| **N/A (justified)** | Structurally not applicable to a diskless/UKI/immutable design; compensating control noted. |
+| **Waived** | Deliberately not met; documented in the [STIG deviation register](stig-deviations.md) and/or a residual risk here. |
+
+### Risk rating scale
+
+Likelihood × Consequence → **Low / Medium / High / Critical** (qualitative,
+ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
+**post-treatment residual**.
+
+---
+
+## 2. ISM control cross-reference
+
+### 2.1 Guidelines for Security Documentation
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| System Security Plan | ISM-0041* | An SSP is developed and maintained. | This document set ([SSP](ssp.md) + this annex). | Partial | Formal sign-off, role appointment pending. |
+| Incident Response Plan | ISM-0043* | An IRP is developed, maintained, exercised. | Not yet authored; inherent recovery via reboot/redeploy. | Planned | R-12 |
+| Continuous monitoring plan | ISM-1163* | Continuous monitoring strategy documented. | auditd + log offload exist; plan not formalised. | Partial | R-07 |
+| Strategies maintained & approved | ISM-0027* | Documentation approved by the authorising authority. | Pending AO appointment & approval. | Planned | — |
+
+### 2.2 Guidelines for Cyber Security Roles
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| CISO / system owner appointed | ISM-0714*, ISM-1071* | Defined accountable security roles. | Roles identified in [SSP §3]; appointments TBA. | Planned | — |
+| System-specific security roles | ISM-1525* | Administrators with defined responsibilities. | Platform team operates build/release & runtime. | Partial | R-05 |
+
+### 2.3 Guidelines for System Hardening — Operating systems & applications
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` with documented tailoring ([stig-deviations.md](stig-deviations.md)). | Partial | R-03 (CI report-only/disabled now) |
+| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + one trusted kiosk binary) + immutable dm-verity root. | Implemented | — |
+| Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — |
+| Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — |
+| Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — |
+| Memory/exploit mitigations | ISM-1341*, ISM-1342* | Enable platform exploit mitigations. | UKI cmdline: `slub_debug=P`, `page_poison=1`, `init_on_alloc/free=1`, `vsyscall=none`. | Implemented | — |
+| Mandatory access control | ISM-1416* | MAC enforced. | **SELinux enforcing**; labels preserved into erofs (asserted at build). | Implemented | — |
+| Host-based intrusion prevention / file integrity (AIDE) | ISM-1288* | File integrity monitoring. | **N/A** — superseded by dm-verity (read-only, signed, continuous). | N/A (justified) | — |
+| Antivirus / endpoint scanning | ISM-1417* | Malware protection on endpoints. | **N/A/compensated** — immutable verified root + allow-listing means no persistent writable executable surface; overlay is RAM-only, discarded each boot. | N/A (justified) | — |
+
+### 2.4 Guidelines for System Hardening — Authentication
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81010001`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) |
+| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled. | Partial | R-05 |
+| Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — |
+| No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — |
+| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane not yet enforced.** | Partial | R-05 |
+| Passwordless appliance accounts | — (STIG #10) | Account lockout/aging. | `kiosk`/`fleetadmin` are key-only; password aging/faillock **exempted for these two only**, full policy system-wide otherwise. | Implemented | R-05 (folds into rotatable-access roadmap) |
+
+### 2.5 Guidelines for System Management
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Patching / vulnerability remediation | ISM-1493*, ISM-1144*, ISM-1472* | Timely patching. | Patch = **rebuild + re-sign + redeploy a new digest**; atomic, uniform fleet update; cadence/SLA to define. | Partial | R-10 (patch SLA undefined) |
+| Secure build & change control | ISM-1483* | Controlled, repeatable builds. | Reproducible Packer build in CI; digest-pinned deployment; one commit/release; rollback = revert commit. | Implemented | — |
+| Protect build/signing key material | ISM-0501*, ISM-1233* | Protect keys. | Signing keys CI/HSM-held; **never** in repo or build VM; never logged. | Implemented | — |
+| Privileged actions logged | ISM-1509* | Admin actions auditable. | auditd rules capture privileged/admin events. | Implemented | R-07 (offload protection) |
+| Disable debug/back-door access | ISM-1491* | No undocumented access paths. | `debug_shell=true` currently bakes a **passwordless root tty** (lab debugging). | Partial | **R-02 (must remove before prod)** |
+
+### 2.6 Guidelines for System Monitoring (event logging)
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Centralised event logging | ISM-0988*, ISM-1405* | Logs centrally collected. | `audisp-syslog`→rsyslog `omfwd` (disk-assisted queue) → dedicated collector (`serving/audit-collector/`). | Partial | R-07 |
+| Required events logged | ISM-0582*, ISM-1537* | Log security-relevant events. | STIG auditd rule set; kernel `audit=1`, `audit_backlog_limit=8192`. | Implemented | — |
+| Protect logs in transit | ISM-1432*, ISM-0407* | Encrypt/authenticate log transport. | **Plaintext** offload on isolated LAN (rsyslog TLS rules waived — see [STIG #6](stig-deviations.md)). | Waived (lab) | **R-07** |
+| Log retention & integrity | ISM-0859*, ISM-1815* | Retain & protect logs. | Per-source-host files on collector; retention policy & tamper-protection not yet defined. | Planned | R-07 |
+| Event log analysis / alerting | ISM-0109*, ISM-1228* | Review logs; alert on incidents. | No SIEM correlation/alerting defined. | Planned | R-07, R-12 |
+
+### 2.7 Guidelines for Networking
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Network access control (802.1X) | ISM-0520*, ISM-1182* | Authenticate devices to the network. | **802.1X** port-based control; unauthenticated port = no access. | Partial | R-01 |
+| Network segmentation | ISM-1181*, ISM-1532* | Segregate & control traffic. | VLAN assignment post-auth; isolated boot LAN; serving stack separated. | Partial | R-09 |
+| Host-based firewall | ISM-1416*, ISM-1416a* | Restrict host network exposure. | **firewalld disabled** (risk to NVMe/TCP & NM during bring-up). | Waived (lab) | **R-09** |
+| Disable unused network functionality | ISM-1296* | Minimise network services. | Minimal services; no extraneous listeners; kiosk has no inbound. | Implemented | — |
+
+### 2.8 Guidelines for Communications Infrastructure / Systems
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Link-layer encryption (cable/fabric) | ISM-1187*, ISM-0181* | Protect data on the link. | **MACsec** on the access link (encryption + integrity), keyed via MKA after EAP. | Partial | R-01 (MKA survival across switch-root not yet bench-verified) |
+
+### 2.9 Guidelines for Cryptography
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| ASD-Approved Cryptographic Algorithms (AACA) | ISM-0471*, ISM-1446* | Use approved algorithms. | STIG crypto policy (modern ciphers/MACs/KEX); EAP-TLS, MACsec-GCM. **Conformance to ISM AACA not formally asserted; FIPS off.** | Partial | **R-06** |
+| ASD-Approved Cryptographic Protocols (AACP) | ISM-1139*, ISM-1453* | Use approved protocols (TLS 1.2+/1.3). | EAP-TLS, NVMe/TCP & log transport currently plaintext on LAN. | Partial | R-04, R-06, R-07 |
+| Key protection / non-exportability | ISM-0501*, ISM-1091* | Protect keys in hardware where feasible. | EAP-TLS private key **non-exportable in TPM**; UKI signing keys in HSM/CI. | Implemented | — |
+| Cryptographic equipment / module evaluation | ISM-0467*, ISM-1277* | Use evaluated crypto modules. | TPM 2.0 hardware; module-evaluation status to confirm; FIPS module not enabled. | Partial | R-06 |
+
+### 2.10 Guidelines for ICT Equipment & Media
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Media sanitisation / no data remanence | ISM-0347*, ISM-1218* | Prevent data remanence. | **Diskless** — no persistent local media; writable layer is **RAM tmpfs**, gone at power-off. | Implemented | — |
+| Tamper-evidence / boot integrity | ISM-1542*, ISM-0312* | Detect firmware/boot tampering. | **UEFI Secure Boot** + signed UKI + immutable signed cmdline + dm-verity; TPM measurements available for attestation. | Partial | R-11 (remote attestation not yet implemented) |
+| Device identification | ISM-1195* | Uniquely identify devices. | Identity derived from TPM cert (hostnqn from cert serial); no spoofable per-host config. | Implemented | — |
+
+### 2.11 Guidelines for Procurement, Outsourcing & Evaluated Products
+
+| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
+|---|---|---|---|---|---|
+| Supply-chain / artifact integrity | ISM-1567*, ISM-1568* | Trustworthy software supply chain. | FROM-scratch OCI artifact; SHA256SUMS; digest-pinned manifest; reproducible build. | Implemented | — |
+| Evaluated/ supported products | ISM-0280*, ISM-1467* | Prefer evaluated/vendor-supported. | RHEL/Rocky 9 (vendor-supported); TPM 2.0; evaluation status of crypto to confirm. | Partial | R-06 |
+
+---
+
+## 3. Residual risk register
+
+Ratings are **pre-treatment** → with the **post-treatment residual** the AO is
+asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M).
+
+| ID | Residual risk | ISM linkage | Likelihood × Consequence → **Rating** | Existing compensating controls | Treatment (POA&M) | Post-treat. residual | Owner / target |
+|---|---|---|---|---|---|---|---|
+| **R-01** | Secure path (802.1X EAP-TLS + MACsec) **implemented but not bench-verified end-to-end**; disabled in test builds (`disable_dot1x=true`). Until verified, devices may attach to an unauthenticated/unencrypted link. | 2.4, 2.7, 2.8 (ISM-1546*, 0520*, 1187*) | Likely × Major → **High** | Test path on an isolated lab LAN; full auth stack present (dormant); design follows ASD intent. | Full boot-chain bench test (swtpm + hostapd + FreeRADIUS): port `Authorized`, macsec0 addressed, MKA survives switch-root, NVMe root mounts. Re-enable in CI. | **Low** (once verified) | Platform team — *before production ATO* |
+| **R-02** | `debug_shell=true` bakes a **passwordless root shell** on a tty — an undocumented privileged access path. | 2.5 (ISM-1491*) | Possible × Severe → **High** | Lab-only; physical access required; isolated environment. | Set `debug_shell=false` for all non-lab builds; assert absent in CI. | **Low** | Platform team — *before production* |
+| **R-03** | STIG hardening currently **report-only / disabled in CI** (`enable_stig=false`); shipped images may be unhardened. | 2.3 (ISM-1407*/1408*) | Possible × Major → **Medium** | Tailoring + register exist; SELinux/fapolicyd still on; re-enable is a single flag. | Flip `ENABLE_STIG="true"`; confirm scan evaluates >0 rules; triage REVIEW rows (buckets A/B/C in TODO #2). | **Low** | Platform team — *before assessment* |
+| **R-04** | NVMe/TCP **root traffic plaintext** on the LAN — exposure of OS content / injection if the segment is compromised. | 2.9 (ISM-1139*/1453*) | Unlikely × Major → **Medium** | Isolated boot LAN; MACsec on the access link (once R-01 done) protects the first hop; dm-verity makes injected root blocks fail verification (integrity preserved even if confidentiality isn't). | Production: TLS/IPsec for the storage transport, or rely on MACsec end-to-end across the fabric; document the decision. | **Low–Medium** | Platform/network — *production* |
+| **R-05** | **NOPASSWD key-only admin**; no per-host or rotatable access model; human admin MFA on the mgmt plane not enforced. | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, fully audited; no interactive password to attack. | Per-host/rotatable access (SSH CA / config-agent) + admin MFA on the management plane. | **Low–Medium** | Platform team — *roadmap* |
+| **R-06** | **FIPS off**; ISM **AACA/AACP conformance not formally asserted**; crypto-module evaluation status unconfirmed. | 2.9 (ISM-0471*/1139*/1277*) | Possible × Major → **Medium** | Modern STIG crypto policy; non-exportable TPM key; no deliberately weak algorithms. Acceptable at OFFICIAL; **mandatory to resolve for PROTECTED+.** | Assert AACA/AACP conformance; decide on FIPS module / evaluated crypto alongside Secure Boot signing. | **Low** (at appropriate classification) | Security + Platform — *before PROTECTED+* |
+| **R-07** | Audit-log **offload plaintext**, **single-site**, **no SIEM alerting**; logs interceptable/repudiable and not actively reviewed. | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Medium** | Isolated LAN; disk-assisted queue (no loss on collector blip); auditd local copy; per-source files. | rsyslog **TLS** (gtls/6514) + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); define retention + integrity; forward to SIEM with alerting. | **Low** | Platform/SecOps — *production* |
+| **R-08** | **Placeholder RADIUS CA** (`files/etc/dot1x/radius-ca.pem`) — real EAP-TLS trust anchor not provisioned; auth cannot succeed and trust is unestablished. | 2.4 (ISM-1546*) | Certain (lab) × Major → **Medium** | Documented as a placeholder; environment-specific provisioning is the intended path; no real CA committed. | Provision the environment's real RADIUS CA out-of-band (not committed unless classification permits). | **Low** | Environment owner — *before production* |
+| **R-09** | **Host firewall disabled**; host exposes more network surface than necessary. | 2.7 (ISM-1416*) | Possible × Moderate → **Low–Medium** | Isolated LAN; minimal services; egress controlled at the network/serving layer; no kiosk inbound. | Re-enable firewalld with explicit allow rules (NVMe/TCP `:4420`, DHCP, log offload, SSH) once the secure path is verified. | **Low** | Platform/network — *after R-01* |
+| **R-10** | **Patch SLA undefined** — no documented cadence for rebuilding/redeploying against new CVEs. | 2.5 (ISM-1144*/1472*) | Likely × Moderate → **Medium** | Atomic rebuild/redeploy model makes patching fast & uniform; reproducible builds. | Define patch cadence & critical-CVE SLA; automate rebuild triggers. | **Low** | Platform team — *roadmap* |
+| **R-11** | **No remote attestation** of TPM boot measurements — a compromised boot can't be detected centrally. | 2.10 (ISM-1542*) | Unlikely × Major → **Low–Medium** | Secure Boot + signed UKI + dm-verity already prevent unsigned/modified boot; measurements exist locally. | Implement remote attestation (TPM quote → verifier) gating network/storage access. | **Low** | Platform team — *roadmap* |
+| **R-12** | **No formal Incident Response Plan**; volatile RAM overlay complicates live forensics. | 2.1, 2.6 (ISM-0043*/0109*) | Possible × Moderate → **Medium** | Inherent recovery (reboot → verified state; revert digest); centralised logs survive host wipe. | Author IRP + log-retention + break-glass/forensics procedure suited to stateless hosts. | **Low** | SecOps — *before production* |
+
+### 3.1 Risk heatmap (post-treatment targets)
+
+```
+Consequence → Minor Moderate Major Severe
+Likelihood ↓
+ Certain
+ Likely R-10 R-01*
+ Possible R-05 R-07 R-03 R-02*
+ R-12 R-06 R-08
+ Unlikely R-04
+ R-11 R-09
+ Rare
+( * = currently High pre-treatment; all targeted to Low after treatment )
+```
+
+*The heatmap shows pre-treatment positions; every item has a treatment to Low /
+Low–Medium. The AO is asked to authorise on the basis that R-01, R-02, R-03, R-08
+and R-12 are **closed before production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11
+are either accepted with compensating controls (isolated-LAN lab) or carried on
+the POA&M with target dates.*
+
+---
+
+## 4. Assessor notes / open items for the IRAP assessment
+
+1. **Reconcile all `ISM-NNNN*` identifiers** against the May 2026 ISM (see
+ currency caveat) and confirm no new controls are unmapped.
+2. **Confirm the system data classification** with the System Owner — it sets the
+ required control baseline and may reclassify several "N/A/accepted" items
+ (notably R-06 cryptography) as mandatory.
+3. **Witness the secure-path bench test** (R-01) — this is the central
+ effectiveness check; the rest of the architecture's value is contingent on it.
+4. **Verify reproducibility** — two builds → identical `verity.json` root hash
+ (build-integrity control evidence).
+5. **Review the STIG generated report** for the build under assessment — every
+ non-pass rule should map to the [deviation register](stig-deviations.md);
+ anything flagged **REVIEW** is an unjustified finding.
+6. **Confirm lab-only flags are off** in the assessed build: `debug_shell=false`,
+ `enable_stig=true`, `disable_dot1x=false`, real RADIUS CA in place.
+
+---
+
+*End of Annex A.*
diff --git a/docs/ssp.md b/docs/ssp.md
new file mode 100644
index 0000000..0067c3d
--- /dev/null
+++ b/docs/ssp.md
@@ -0,0 +1,261 @@
+# System Security Plan (SSP) — Diskless Fleet Boot System
+
+> **Protective marking:** `OFFICIAL: Sensitive` *(sample marking — set per the
+> organisation's classification of the highest-sensitivity data the system
+> handles; an SSP that describes the security architecture is normally marked at
+> or one level above the system's data classification.)*
+>
+> **A&A framework:** Australian Government — Protective Security Policy Framework
+> (PSPF) + ASD *Information Security Manual* (ISM). This document is the **System
+> Security Plan** required by the ISM *Guidelines for cyber security
+> documentation* and is the primary input to the IRAP security assessment and the
+> Authorising Officer's authorisation (ATO) decision.
+
+| Field | Value |
+|---|---|
+| System name | Diskless Fleet Boot System ("the fleet") |
+| Document type | System Security Plan (SSP) |
+| Version | 0.1 (DRAFT — pre-assessment) |
+| Date | 2026-06-19 |
+| Author / maintainer | System Owner (delegate) |
+| Status | Lab / pre-production. Secure path (802.1X/MACsec) not yet bench-verified. |
+| Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) |
+
+> **Currency caveat (read before relying on control numbers).** This SSP and its
+> annex are drafted against the ISM structure as understood to a January 2026
+> baseline. The engagement specifies the **May 2026 ISM**. The ISM is reissued on
+> a quarterly cadence and **control identifiers are added, retired and
+> renumbered between releases.** Every `ISM-NNNN` identifier in Annex A is
+> therefore **representative and MUST be validated by the IRAP assessor against
+> the actual May 2026 release** before the assessment is relied upon. The
+> *intent* of each control mapping is stable; the *number* may not be.
+
+---
+
+## 1. Purpose and scope
+
+This SSP describes the security architecture, the security controls implemented,
+and the residual risk of the **Diskless Fleet Boot System** — a fleet of
+diskless workstations that network-boot a signed, immutable operating system over
+the wire and hold **no persistent local state**. It supports an Australian
+**Assessment and Authorisation (A&A)** process: it is the document an IRAP
+assessor evaluates to produce the Security Assessment Report (SAR), and that the
+Authorising Officer (AO) uses to make a risk-based **Authority to Operate (ATO)**
+decision.
+
+**In scope:** the boot chain (UEFI HTTP boot → signed UKI → dracut initramfs →
+network authentication → encrypted root mount → verified read-only OS), the
+runtime workstation OS image, the host's cryptographic identity (TPM), and the
+hardening baseline applied to the image.
+
+**Out of scope (separately authorised):** the lab serving stack (DHCP/HTTP/
+NVMe-oF target, audit collector — `serving/`), the 802.1X authenticator/RADIUS
+infrastructure, the certificate enrolment authority, the network fabric, and
+physical security of the facility. These are dependencies referenced here but
+carry their own security documentation and authorisation.
+
+## 2. System description
+
+### 2.1 Overview
+
+Each host is **diskless**: it has no installed operating system and no writable
+persistent storage that participates in identity. At power-on it performs UEFI
+**HTTP boot**, fetches a **signed Unified Kernel Image (UKI)**, and assembles a
+running system whose root filesystem is an **immutable, cryptographically
+verified image mounted over the network**. All host identity is derived **at
+runtime from a hardware Trusted Platform Module (TPM)**; nothing host-specific
+exists in any build artifact.
+
+A release is a **signed UKI** plus a root image, packaged together as a
+**FROM-scratch OCI artifact image** and pinned by digest in a deployment
+manifest. One commit = one release = one digest = one manifest entry.
+
+### 2.2 Boot and trust chain (control flow)
+
+1. **UEFI Secure Boot** measures and verifies the UKI signature (vendor/Secure
+ Boot key) before execution.
+2. **UKI (systemd-stub)** carries the kernel, the `--no-hostonly` initramfs, and
+ an **immutable, signed kernel command line** (includes the dm-verity root
+ hash and STIG kernel parameters). No GRUB.
+3. **Initramfs (dracut)** brings up the NIC and performs **802.1X EAP-TLS**
+ network authentication using a **private key held in the TPM** (persistent
+ handle `0x81010001`); the client certificate is read from TPM NV index
+ `0x01800001`.
+4. On authorisation, **MACsec** is established on the access link (link-layer
+ encryption/integrity); the host then obtains an IP **on the MACsec interface**
+ via DHCP.
+5. The host connects its **root over NVMe/TCP**, maps it through **dm-verity**
+ (block-level integrity against the signed root hash in the UKI cmdline), and
+ mounts the **erofs** read-only root.
+6. A **tmpfs (RAM) volatile overlay** (`systemd.volatile=overlay`) provides the
+ writable layer. It is discarded at power-off — the host returns to a known,
+ verified state every boot.
+7. `switch-root` into the real system; NetworkManager renews the DHCP lease so
+ the network-mounted root stays alive; the host derives its NVMe `hostnqn` from
+ the TPM certificate serial and (kiosk builds) launches the locked-down kiosk.
+
+### 2.3 Authorisation boundary
+
+The authorisation boundary is the **running workstation host**: the UKI, the
+initramfs and its custom 802.1X/MACsec dracut module, the verified erofs root,
+the tmpfs overlay, the TPM-backed identity, and the hardened OS configuration.
+The boundary's external interfaces are: (a) the **access link** (802.1X-controlled
+port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c)
+**outbound audit log** forwarding; (d) the **kiosk application** endpoint the
+browser renders. Each crossing is a control point addressed in Annex A.
+
+### 2.4 Data and classification
+
+- **Data at rest in the artifact:** none that is host-specific or sensitive. The
+ image is generic to the whole fleet by design (Architecture invariant #1 — *no
+ per-host state in any artifact, ever*).
+- **Host identity / key material:** the EAP-TLS private key **never leaves the
+ TPM**; the certificate is read from TPM NV. Signing keys for the UKI are
+ CI/HSM-held and **never enter the build VM or the repository**.
+- **Data in transit:** access link protected by **MACsec**; NVMe/TCP root and
+ audit-log offload currently **plaintext on an isolated lab LAN** (see residual
+ risk R-04, R-07 in Annex A).
+- **Data at rest in the overlay:** ephemeral, RAM-only, discarded at power-off.
+
+The **system's data classification governs this SSP's marking and the required
+ISM control set.** The classification of the data rendered by the kiosk
+application and accessible from the workstation must be confirmed with the System
+Owner; this draft assumes an **OFFICIAL / OFFICIAL: Sensitive** working
+classification and should be re-baselined if the system processes PROTECTED or
+above (which raises additional ISM control obligations, e.g. ASD-approved
+cryptography enforcement, gateway controls, and stricter media handling).
+
+## 3. Security governance and A&A roles
+
+| Role | Responsibility (PSPF/ISM) | Assignment |
+|---|---|---|
+| **Authorising Officer (AO)** | Accepts residual risk; grants/denies/revokes the ATO. | *TBA — appoint before assessment* |
+| **System Owner** | Accountable for the system's security posture and this SSP. | *TBA* |
+| **Chief Information Security Officer (CISO)** | Organisational cyber security oversight; advises the AO. | *TBA* |
+| **IRAP Assessor** | Independent assessment of control implementation effectiveness; produces the SAR. | *Engaged for assessment* |
+| **System Manager / Administrators** | Day-to-day operation, patching, incident response, continuous monitoring. | Fleet platform team |
+| **Build / release pipeline owner** | Integrity of the CI build, signing, and digest-pinned deployment. | Fleet platform team |
+
+## 4. A&A process and status
+
+This system follows the ISM A&A lifecycle:
+
+1. **Define** the system, its boundary and data classification (this SSP §2).
+2. **Select** the applicable ISM controls (Annex A, driven by classification).
+3. **Implement** the controls (this SSP §5–§6; the codebase is the implementation
+ of record).
+4. **Assess** — IRAP assessor evaluates effectiveness → **Security Assessment
+ Report (SAR)**.
+5. **Authorise** — AO weighs residual risk (Annex A risk register) → **ATO**.
+6. **Monitor** — continuous monitoring and a maintained **Plan of Action &
+ Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M
+ source).
+
+**Current status:** Pre-assessment **DRAFT**. The system is functional in a lab
+configuration. Several controls are **partial or deferred** and are tracked as
+residual risks with treatment plans (Annex A §3). The security-critical path
+(802.1X/MACsec) is **implemented but not yet bench-verified end-to-end** and is
+currently disabled in test builds (`disable_dot1x=true`) — this is the single
+largest gate on a production authorisation.
+
+## 5. Security control implementation — by ISM cyber security principle
+
+The ISM organises controls under four principles: **Govern, Protect, Detect,
+Respond.** Annex A gives the line-by-line control cross-reference; this section
+summarises the implementation posture.
+
+### 5.1 Govern
+- Documented architecture (design v2 + errata), enforced **architecture
+ invariants** ([CLAUDE.md](../CLAUDE.md)), and a maintained backlog/POA&M.
+- Reproducible, **digest-pinned** releases; one release = one digest = one
+ manifest commit; rollback = revert the commit.
+- Signing keys held outside the repo and build VM (CI/HSM).
+- *Gaps:* formal SRMP, IRP, and continuous-monitoring plan to be authored;
+ A&A roles to be appointed (§3).
+
+### 5.2 Protect
+- **Identity & authentication:** mutual **802.1X EAP-TLS** with a
+ **non-exportable TPM key**; network access controlled at the switch port.
+- **Link protection:** **MACsec** on the access link.
+- **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable
+ cmdline**; **dm-verity** read-only root (continuous cryptographic integrity);
+ **erofs** immutable filesystem; **reproducible** build (verity hash is a pure
+ function of inputs).
+- **System hardening:** **DISA STIG** baseline via OpenSCAP/SSG with a documented
+ tailoring; **SELinux enforcing**; **fapolicyd** application allow-listing (the
+ exec guard for the writable RAM overlay that dm-verity cannot cover);
+ **key-only SSH**, root login disabled, hardened ciphers/MACs/KEX; **kiosk
+ lockdown** (single locked browser, no shell/navigation).
+- **No persistent attack surface:** diskless; overlay discarded each boot; no
+ per-host secrets in the image.
+- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled**;
+ NVMe/TCP and audit offload **plaintext on the isolated LAN**; NOPASSWD key-only
+ admin; **placeholder RADIUS CA**. See Annex A residual risks.
+
+### 5.3 Detect
+- **auditd** enabled with STIG audit rules; kernel `audit=1` +
+ `audit_backlog_limit` in the signed cmdline.
+- **Centralised log offload** via rsyslog (`audisp-syslog` → rsyslog `omfwd`,
+ disk-assisted queue) to a dedicated **audit collector** (`serving/audit-collector/`).
+- *Gaps:* offload currently **plaintext** (TLS planned, R-07); offload is
+ **local site only**; no SIEM correlation/alerting defined yet.
+
+### 5.4 Respond
+- Known-good recovery is **inherent**: a host returns to a verified state on
+ reboot; a bad release is rolled back by reverting one manifest commit (digest
+ pin).
+- *Gaps:* formal **Incident Response Plan**, log-retention policy, and
+ break-glass/forensics procedure (the diskless model complicates live forensics
+ — RAM overlay is volatile) to be authored.
+
+## 6. Essential Eight alignment (indicative)
+
+The Essential Eight is mitigation guidance, not a substitute for the ISM, but the
+AO will expect a maturity view:
+
+| Mitigation | Posture | Notes |
+|---|---|---|
+| Application control | **Strong** | fapolicyd allow-listing + immutable verified root; only RPM-trusted (plus one trusted kiosk binary) executables run. |
+| Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. |
+| Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. |
+| User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. |
+| Restrict admin privileges | **Partial** | Single key-only `wheel` admin (NOPASSWD, see R-05); no per-host/rotatable model yet. |
+| Patch operating systems | **Process** | Same rebuild/redeploy model; immutable image means uniform, atomic OS updates. |
+| Multi-factor authentication | **Strong (machine)** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). Human admin MFA to be addressed for the management plane. |
+| Regular backups | **N/A (by design)** | Diskless, stateless hosts hold nothing to back up; recoverability is re-provisioning from a pinned digest. Backup applies to serving-side state, out of scope here. |
+
+## 7. Residual risk summary
+
+The full residual-risk register is **[Annex A §3](ssp-annex-a-ism-residual-risk.md#3-residual-risk-register)**.
+The headline residual risks the AO must explicitly accept or treat before a
+production ATO:
+
+| ID | Residual risk | Rating (pre-treatment) | Disposition |
+|---|---|---|---|
+| R-01 | Secure path (802.1X/MACsec) not yet bench-verified; currently disabled in test builds. | **High** | Treat — verify before production authorisation. |
+| R-02 | `debug_shell=true` bakes a **passwordless root shell** on a tty. | **High** | Treat — must be removed before production (lab-only). |
+| R-03 | STIG hardening temporarily **report-only / disabled in CI** (`enable_stig=false`). | **Medium** | Treat — re-enable before assessment. |
+| R-04 | NVMe/TCP root traffic **plaintext** on the lab LAN. | **Medium** | Accept (isolated LAN) / treat for production. |
+| R-05 | NOPASSWD key-only admin; no per-host or rotatable access model. | **Medium** | Accept with compensating controls / roadmap. |
+| R-06 | **FIPS off**; ASD-approved-cryptography conformance not yet asserted. | **Medium** | Treat — required for PROTECTED+; revisit with signing. |
+| R-07 | Audit-log offload **plaintext**, single-site, no SIEM alerting. | **Medium** | Treat — TLS offload + monitoring on roadmap. |
+| R-08 | **Placeholder RADIUS CA** in the repo; real trust anchor not yet provisioned. | **Medium** | Treat — environment-specific, before production. |
+| R-09 | Host **firewall disabled**. | **Low–Medium** | Accept (isolated LAN) / treat with explicit allow-rules. |
+
+Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A.
+
+## 8. Continuous monitoring and maintenance
+
+- **Build-time assurance:** every release is reproducible (verity hash verified
+ stable across two builds), the UKI/rootfs roothash coupling is a hard CI gate,
+ and the STIG scan publishes a report + a deviations register per build.
+- **Runtime monitoring:** auditd + centralised log offload; host returns to a
+ verified state each boot.
+- **POA&M:** [docs/TODO.md](TODO.md) is the live working POA&M; items feed Annex A
+ treatments. The SSP and Annex A are re-baselined at each material architecture
+ change and at each scheduled A&A review.
+
+---
+
+*End of SSP. See [Annex A](ssp-annex-a-ism-residual-risk.md) for the ISM control
+cross-reference and the full residual-risk register.*
diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md
index 4f73b0a..c187796 100644
--- a/docs/stig-deviations.md
+++ b/docs/stig-deviations.md
@@ -34,6 +34,86 @@ Posture decisions (2026-06-18, lab phase):
| 11 | **Login banner content** (`banner_etc_issue`, `banner_etc_issue_net`, `banner_etc_motd`) | STIG requires the verbatim DoD/USG "you are accessing a U.S. Government Information System" text. This is a fictional starship fleet, not a USG system, so `/etc/issue{,.net}` + `/etc/motd` carry the fleet (ISV AURELIAN) notice instead (provisioner step 2e, `files/banner/fleet-issue`). The content rules therefore fail by design. | The banner **keeps its security function**: it still states authorized-crew-only, that the terminal is monitored/logged, and that unauthorized access is prohibited — only the wording is themed. `sshd_enable_warning_banner` (Banner is shown) still passes. |
+## 12. Report triage 2026-06-19 (scan `app-9f7d8d1`)
+
+Triage of the 21 REVIEW rows from the post-fix hardened scan (398 pass / 12 fail
+/ 9 notchecked — a valid scan). All but one are dispositioned below; the rule IDs
+are unselected in `diskless-tailoring.xml` (or made to pass / N/A) so they no
+longer surface as REVIEW. **Remaining open: `selinux_context_elevation_for_sudo`**
+(see end).
+
+**Waived — covered by an existing register entry (Bucket A):**
+- `encrypt_partitions`, `auditd_audispd_configure_sufficiently_large_partition` —
+ single erofs+tmpfs root, no partitions (#1).
+- `grub2_password` — UKI, no GRUB (#2).
+- `sysctl_crypto_fips_enabled`, `harden_sshd_macs_openssh_conf_crypto_policy`,
+ `harden_sshd_macs_opensshserver_conf_crypto_policy` — FIPS off (#5).
+- `configured_firewalld_default_deny` — firewall off for the lab boot LAN (#4).
+- `sssd_enable_certmap`, `sssd_has_trust_anchor` — no SSSD/PKI/smartcard; local
+ key-only host, no domain join (#7).
+
+**Waived — architecturally absent, N/A (Bucket B):** `account_temp_expire_date`
+(no temporary accounts), `accounts_user_home_paths_only` (appliance accounts,
+fixed home layout), `libreswan_approved_tunnels` (no IPsec/libreswan),
+`security_patches_up_to_date` (rebuild-from-current model — a scanner can't assess
+patch currency against a future rebuild), `ssh_keys_passphrase_protected` (no
+private keys stored on the host; host keys regenerate per boot).
+
+**Waived — decisions (Bucket C):**
+- `installed_OS_is_vendor_supported` — Rocky **is** vendor-supported; the rule
+ fails only because the RHEL datastream's CPE checks for an RHEL subscription.
+- `network_configure_name_resolution` — single DHCP-provided resolver by design
+ on the boot LAN; the multi-DNS requirement is N/A to the netboot model.
+- `sysctl_user_max_user_namespaces_no_remediation` — **KEEP user namespaces
+ enabled**: the Chromium kiosk sandbox requires them. Disabling userns would
+ break the kiosk.
+- `accounts_authorized_local_users` — the authorized-users check doesn't know the
+ key-only appliance accounts (`fleetadmin`, `kiosk`); they are authorized (see
+ #10). *Production note: prefer setting the SSG authorized-users regex variable
+ to assert these two explicitly rather than waiving the whole check.*
+- `package_postfix_installed` — **postfix removed** (provisioner 2c-STIG): the
+ appliance sends no mail and ships audit off-box via rsyslog (2f), so a local
+ MTA is dead attack surface. This also makes `postfix_prevent_unrestricted_relay`
+ N/A. Audit-failure notification relies on the remote offload, not email
+ (`auditd ... action_mail_acct` stays configured but is not the alerting path).
+- `auditd_data_retention_space_left_action` — **consequence of removing postfix.**
+ STIG sets `space_left_action = email`, but with no MTA auditd logs *"Email
+ option is specified but /usr/lib/sendmail doesn't seem executable"* at every
+ boot and can't send. 2c-STIG repoints **`space_left_action = SYSLOG`** so the
+ 75%-full alert rides the remote audit offload (2f) instead; this rule wants
+ `email`, so it's waived. `admin_space_left_action` stays `single` (the critical
+ action, unaffected) and `action_mail_acct = root` stays set.
+
+**Remediated to pass (not waived):**
+- `configure_usbguard_auditbackend` + `usbguard_allow_hid` / `usbguard_allow_hub`
+ / `usbguard_allow_hid_and_hub` — **decision: ENABLE USBGuard with enforcement.**
+ usbguard was already installed + enabled, but its policy is generated on the
+ build VM (no real USB), so under the default **block** target a physical console
+ keyboard/mouse would be blocked. The provisioner now sets
+ `AuditBackend=LinuxAudit` and writes a **hardware-independent HID + hub
+ allowlist** (`allow with-interface match-all { 03:*:* }`, `{ 09:00:* }`, and the
+ composite `{ 03:*:* 09:00:* }`) so console input survives while mass storage and
+ other device classes are blocked. **⚠ BENCH-VERIFY physical console input on
+ real hardware** — if a platform's input device doesn't match the HID/hub
+ classes, fall back to a per-platform allow rule or re-waive
+ `configure_usbguard_auditbackend`.
+
+**Remediated with confinement (decision 2026-06-20) — `selinux_context_elevation_for_sudo`:**
+SSG ships no automated fix; the STIG manual remediation
+(`... TYPE=sysadm_t ROLE=sysadm_r ...`) requires the admin to reach the
+`sysadm_r` role, which `unconfined_u` cannot. So the provisioner (2c-STIG) now
+(1) rewrites the admin sudoers rule to ` ALL=(ALL) TYPE=sysadm_t
+ROLE=sysadm_r NOPASSWD:ALL` (keeps NOPASSWD — the admin is key-only, #8 — and is
+what the OVAL check matches), and (2) maps the admin to the confined **`staff_u`**
+SELinux user (`semanage login`) so the `staff_r`→`sysadm_r` sudo transition is
+authorized at runtime. `staff_u` (not `sysadm_u`) is used so SSH login isn't gated
+by the `ssh_sysadm_login` boolean. **⚠ Two bench gates before trusting:** (a)
+admin SSH login + `sudo` + every privileged op must work under `staff_u`
+confinement — if it locks out, `enable_stig=false` is the escape hatch and this
+reverts to a justified deviation; (b) reproducibility — `semanage` rebuilds the
+SELinux policy store, so confirm the verity hash is stable across two builds (and
+normalize in 2d hygiene if it drifts).
+
## Things that are NOT deviations (kept / enforced)
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
@@ -44,6 +124,9 @@ Posture decisions (2026-06-18, lab phase):
everything else stays RPM-trust-only. (fapolicyd blocking that binary was the
original kiosk-won't-start cause.)
- **auditd** enabled; STIG audit rules applied; logs locally.
+- **USBGuard** enabled + enforcing (`block` target) with a HID/hub allowlist and
+ `AuditBackend=LinuxAudit` (see #12) — blocks USB mass storage / unknown device
+ classes at the console while keeping keyboard/mouse working.
- **sshd hardening**: root login off, password & keyboard-interactive auth off,
key-only (drop-in `50-fleet-hardening.conf`); STIG ciphers/MACs/KEX applied.
- **No per-host state**: SSH host keys stripped at build, regenerated at first
diff --git a/files/stig/diskless-tailoring.xml b/files/stig/diskless-tailoring.xml
index d38acc5..2f253b5 100644
--- a/files/stig/diskless-tailoring.xml
+++ b/files/stig/diskless-tailoring.xml
@@ -99,5 +99,68 @@
prompt for one — NOPASSWD is required for it to function. Documented. -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/serving/audit-collector/README.md b/serving/audit-collector/README.md
index 24a0185..3f5c7bd 100644
--- a/serving/audit-collector/README.md
+++ b/serving/audit-collector/README.md
@@ -44,6 +44,42 @@ logger -n 127.0.0.1 -P 514 -T -t audispd "test audit record"
ls audit-collector/logs/ # a 127.0.0.1/ dir appears
```
+## Serving-host firewall — open 514/tcp (easy to miss)
+
+This container uses **host networking**, so the **serving host's own firewall
+governs port 514** — Docker does *not* manage iptables for host-net containers
+(the usual "Docker bypasses the host firewall" behaviour only applies to
+*published* bridge ports, which insert rules into the `DOCKER` chain ahead of the
+host firewall). The symptom when it's closed is deceptive: the container is
+healthy, `ss -tlnp` shows `0.0.0.0:514` listening, DNS/DHCP from the same host
+work, but booted hosts sit in `SYN-SENT` and rsyslog logs `cannot connect to
+audit.fleet.lab:514 ... Connection timed out` (timed out = **dropped**, not
+refused). The logs aren't lost — the sender's `action.resumeRetryCount="-1"`
+buffers them and drains once the port opens.
+
+Open 514/tcp **scoped to the boot LAN** (don't expose it globally):
+
+```bash
+# ufw (Debian/Ubuntu serving host):
+sudo ufw allow from 192.168.50.0/24 to any port 514 proto tcp
+sudo ufw reload && sudo ufw status numbered
+
+# firewalld (RHEL/Rocky serving host):
+sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=192.168.50.0/24 port port=514 protocol=tcp accept'
+sudo firewall-cmd --reload
+```
+
+Verify SYNs now complete (SYN → SYN-ACK) and a sender dir appears:
+
+```bash
+sudo tcpdump -ni any 'tcp port 514 and host ' # SYN-ACK now returns
+ls -R audit-collector/logs/ # / appears within ~1 min
+```
+
+> If you ever move the collector from host networking to **published ports**
+> (`514:514`), this firewall rule stops applying — published Docker ports bypass
+> ufw/firewalld and must be controlled via Docker (`DOCKER-USER` chain) instead.
+
## What's still needed (the sender — in the image)
This container only **receives**. To make hosts actually send, the hardened