From a53357216dea29c917e01d97662114cc68a08ed8 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Thu, 18 Jun 2026 09:35:58 +0000 Subject: [PATCH 1/2] Add ntp time source and browser policy --- CLAUDE.md | 7 ++ diskless.pkr.hcl | 17 +++- docs/TODO.md | 33 ++++++-- .../dispatcher.d/10-chrony-dhcp-ntp | 36 +++++++++ files/kiosk/chromium-kiosk-policy.json | 1 + scripts/preview-plymouth.sh | 79 +++++++++++++++++++ serving/README.md | 3 + serving/chrony/Dockerfile | 9 +++ serving/chrony/chrony.conf | 20 +++++ serving/docker-compose.yml | 27 +++++++ 10 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp create mode 100755 scripts/preview-plymouth.sh create mode 100644 serving/chrony/Dockerfile create mode 100644 serving/chrony/chrony.conf diff --git a/CLAUDE.md b/CLAUDE.md index ccd0b53..30933b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,13 @@ instead of working around it. build; the supplicant config carries both options. - erofs module availability on the target RHEL kernel (fallback: journal-less ext4 in `extract-artifacts.sh`). +- 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 + `$DHCP4_NTP_SERVERS`, and (b) RHEL 9 `/etc/chrony.conf` honours + `sourcedir /run/chrony-dhcp` (the provisioner appends it if absent). If sources + are empty, the dispatcher didn't fire or the env var name differs — check + `journalctl -u NetworkManager` and `ls /run/chrony-dhcp/`. - `fleet` Plymouth theme: confirm (a) the script plugin package actually installed (`/usr/lib64/plymouth/script.so` — else the build fell back to `spinner`, logged as a WARNING), (b) the scripted theme + a font got bundled diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index a3f445b..65727d4 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -262,6 +262,16 @@ build { # secure mode, reserving the physical NIC for wpa_supplicant. "chmod 0755 /usr/lib/dracut/modules.d/95dot1x-macsec/*.sh", + # ---- NTP from DHCP (option 42) -> chrony, no public-pool egress -------- + # NM dispatcher writes DHCP-provided NTP servers into chrony's sourcedir + # (replaces the removed dhclient.d/chrony.sh bridge). Strip the baked public + # pool so the host syncs ONLY from the lab NTP server handed via DHCP — the + # workstation never reaches *.pool.ntp.org. + "install -D -m0755 /tmp/etc-stage/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", + "restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", + "sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf", + "grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf", + # Inject the fleet NIC drivers into the dracut config "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", @@ -341,15 +351,18 @@ build { "install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini", # Chromium managed policy: stops the browser phoning home to Google # (sign-in, variations, optimization-guide, component/auto update, metrics, - # safe browsing). EPEL chromium reads /etc/chromium/policies/managed/*.json. + # safe browsing). The managed-policy dir differs across chromium builds + # (/etc/chromium vs /etc/chromium-browser), and a policy in the wrong dir is + # silently ignored — so install to BOTH. Verify at runtime via chrome://policy. # Pairs with the hardening flags in kiosk-session. "install -D -m0644 /tmp/kiosk/chromium-kiosk-policy.json /etc/chromium/policies/managed/10-kiosk-lockdown.json", + "install -D -m0644 /tmp/kiosk/chromium-kiosk-policy.json /etc/chromium-browser/policies/managed/10-kiosk-lockdown.json", # URL from the var (weston renderer is set via --use-pixman in the unit). "install -d -m0755 /etc/kiosk", "printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env", "chmod 0644 /etc/kiosk/kiosk.env", # Label the new files so they survive into the enforcing erofs. - "restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/xdg/weston /etc/kiosk /etc/chromium", + "restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/xdg/weston /etc/kiosk /etc/chromium /etc/chromium-browser", # Boot graphical and start the kiosk (replaces the tty1 getty). "systemctl enable kiosk.service", "systemctl set-default graphical.target", diff --git a/docs/TODO.md b/docs/TODO.md index 5aa948d..8bf0148 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -155,15 +155,32 @@ Path to getting here (don't re-discover): avc`; the kiosk user is unconfined so likely none — but do NOT set permissive if they appear; add a policy module). Admin SSH (the key user) still works for debug even if the kiosk fails (it lands at graphical.target either way). -- **Phone-home lockdown (done 2026-06-17):** Chromium was dialing Google +- **Phone-home lockdown (done 2026-06-17/18):** Chromium was dialing Google (accounts/variations/optimization-guide/component+auto update/safe browsing — - seen in dnsmasq logs). Stopped via a managed policy - (`files/kiosk/chromium-kiosk-policy.json` → `/etc/chromium/policies/managed/`) - plus hardening flags in `kiosk-session` (`--disable-background-networking`, - `--disable-component-update`, `--disable-features=OptimizationHints,...`, etc.). - Verify with the dnsmasq query log after a rebuild — only `boot.fleet.lab` - (kiosk page) + chrony NTP should remain. NTP is the OS (chrony), not Chromium; - optionally point it at the serving host to fully contain egress. + seen in dnsmasq logs). Two layers: + - **Flags** in `kiosk-session` (`--disable-background-networking`, + `--disable-component-update`, `--disable-features=OptimizationHints,...`) — + confirmed killing variations/optimization-guide/update queries. + - **Managed policy** `files/kiosk/chromium-kiosk-policy.json` for the + policy-governed bits (sign-in `accounts.google.com`, sync). FIX 2026-06-18: + the managed-policy dir differs by build, so it's installed to BOTH + `/etc/chromium/policies/managed/` AND `/etc/chromium-browser/policies/managed/` + (a policy in the wrong dir is silently ignored — the reason + `accounts.google.com`/`android.clients.google.com` persisted). Verify via + `chrome://policy` (Status: OK per entry). + - **Serving-side sinkhole** (defense-in-depth, `serving/docker-compose.yml` + dnsmasq `--address=/google.com|googleapis.com|gstatic.com/0.0.0.0`): the + kiosk is local-only, so block egress at the resolver. Stops the *leak*; the + query still logs (answered 0.0.0.0, not forwarded) — eliminating the query + is the browser policy's job. Production: do this at the boot-LAN firewall. + - **NTP egress closed (done 2026-06-18):** the `*.pool.ntp.org` lookups were + the OS (chrony), not Chromium. Now: a `chrony` NTP server in the serving + stack (`serving/chrony/`), dnsmasq hands it via **DHCP option 42**, and the + image's NM dispatcher (`files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp`) + feeds it into chrony's `sourcedir /run/chrony-dhcp`; the baked public pool is + stripped from `/etc/chrony.conf`. Result: the workstation syncs time only + from the lab server — no external NTP egress. BENCH-VERIFY: on a booted host + `chronyc sources` shows the lab server (the DHCP one), not a public pool. - **Image size:** Chromium+Mesa add hundreds of MB to the *uncompressed* erofs — feeds the "shrink the rootfs" concern (and #7); reconsider whether a lighter browser/render path is viable. diff --git a/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp b/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp new file mode 100644 index 0000000..5941eb6 --- /dev/null +++ b/files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp @@ -0,0 +1,36 @@ +#!/bin/bash +# NetworkManager dispatcher: feed DHCP option 42 (NTP servers) into chrony. +# +# Replaces the old dhclient.d/chrony.sh bridge — we use NetworkManager's internal +# DHCP now (the network-manager dracut port), and NM exports the lease's NTP +# servers to dispatcher scripts as $DHCP4_NTP_SERVERS. chrony.conf carries +# `sourcedir /run/chrony-dhcp`, so writing .sources there and reloading +# makes chronyd use the DHCP-provided server. The image's baked public pool is +# stripped (see the provisioner), so the host syncs ONLY from the lab NTP server +# — no time egress from the workstation. +# +# Installed 0755 root:root at /etc/NetworkManager/dispatcher.d/. Lenient on +# purpose: a hiccup here must never break networking. + +interface="$1" +action="$2" + +case "$action" in + up|dhcp4-change|reapply) ;; + *) exit 0 ;; +esac + +dir=/run/chrony-dhcp +file="$dir/${interface}.sources" +mkdir -p "$dir" + +if [ -n "${DHCP4_NTP_SERVERS:-}" ]; then + : > "$file" + for s in $DHCP4_NTP_SERVERS; do + printf 'server %s iburst\n' "$s" >> "$file" + done + # Pick up the new sources without restarting chronyd. + chronyc reload sources >/dev/null 2>&1 || true +fi + +exit 0 diff --git a/files/kiosk/chromium-kiosk-policy.json b/files/kiosk/chromium-kiosk-policy.json index 1255fda..4d03e03 100644 --- a/files/kiosk/chromium-kiosk-policy.json +++ b/files/kiosk/chromium-kiosk-policy.json @@ -6,6 +6,7 @@ "DeviceMetricsReportingEnabled": false, "BrowserSignin": 0, + "SigninAllowed": false, "SyncDisabled": true, "SafeBrowsingProtectionLevel": 0, diff --git a/scripts/preview-plymouth.sh b/scripts/preview-plymouth.sh new file mode 100755 index 0000000..543b682 --- /dev/null +++ b/scripts/preview-plymouth.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# preview-plymouth.sh — fast local preview of the 'fleet' boot splash WITHOUT +# building/booting the full diskless image. The splash is just plymouthd + the +# theme + status tokens, so it runs on any Linux box with plymouth installed +# (root), driven by the same `plymouth update --status=` tokens the dracut hooks +# emit (dot1x-macsec-up.sh / nvmf-connect.sh). +# +# sudo bash scripts/preview-plymouth.sh [secure|test] +# +# Seeing it: +# - Windowed: needs the X11 renderer (Debian/Ubuntu: `apt install plymouth-x11`, +# run inside an X session). Fedora/RHEL often don't package it. +# - No X11 renderer: switch to a spare VT (Ctrl+Alt+F3), run there, watch, +# Ctrl+Alt+F1 back — plymouthd grabs DRM on that VT. +# - Headless/CI screenshot: Xvfb + x11 renderer + a grab tool. +# +# Iterate: edit files/plymouth/themes/fleet/fleet.script, re-run this. Seconds, +# not a 30-min image build. (On an already-booted diskless host the root overlay +# is writable, so you can scp the theme to /usr/share/plymouth/themes/fleet and +# run this there for a real distro/font/plugin stack.) +set -euo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "run as root (plymouthd needs it)" >&2; exit 1; } + +mode=${1:-secure} +theme_src=${THEME_SRC:-files/plymouth/themes/fleet} +dest=/usr/share/plymouth/themes/fleet + +command -v plymouthd >/dev/null 2>&1 || { + echo "ERROR: plymouthd not found — install plymouth, plymouth-scripts," >&2 + echo " plymouth-plugin-label, and the script plugin first." >&2 + exit 1 +} +[ -f "$theme_src/fleet.script" ] || { + echo "ERROR: $theme_src/fleet.script not found (run from the repo root, or set THEME_SRC)." >&2 + exit 1 +} + +echo "==> installing theme: $theme_src -> $dest" +mkdir -p "$dest" +cp -a "$theme_src"/. "$dest"/ +plymouth-set-default-theme fleet 2>/dev/null || \ + echo "WARNING: plymouth-set-default-theme failed (plymouth-scripts missing?) — continuing" >&2 + +if [ ! -e /usr/lib64/plymouth/script.so ] && [ ! -e /usr/lib/plymouth/script.so ]; then + echo "WARNING: plymouth script plugin (script.so) not found — the theme will not render." >&2 +fi + +echo "==> starting plymouthd (debug log: /tmp/plymouth-preview.log)" +plymouth quit 2>/dev/null || true +plymouthd --no-daemon --debug --debug-file=/tmp/plymouth-preview.log & +ply_pid=$! +trap 'plymouth quit 2>/dev/null || true; kill "$ply_pid" 2>/dev/null || true' EXIT + +sleep 1 +plymouth show-splash +sleep 1 + +emit() { + echo " --status=$1" + plymouth update --status="$1" || true + sleep "${2:-1.2}" +} + +echo "==> driving the '$mode' handshake sequence" +if [ "$mode" = test ]; then + emit mode-test 0.6 + emit net-active; emit net-done + emit nvme-active; emit nvme-done 1.5 +else + emit tpm-active; emit tpm-done + emit dot1x-active 1.6; emit dot1x-done + emit macsec-active; emit macsec-done + emit net-active 1.6; emit net-done + emit nvme-active 1.6; emit nvme-done 2 +fi + +echo "==> sequence complete; press Enter to quit the splash" +read -r _ || true diff --git a/serving/README.md b/serving/README.md index 1cca9eb..fe299fe 100644 --- a/serving/README.md +++ b/serving/README.md @@ -61,6 +61,9 @@ curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK # HTTP: kiosk test page is served (what the diskless kiosk loads by default) curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK +# NTP: lab time server answers (diskless hosts get it via DHCP option 42) +chronyc -h ${SERVER_IP} tracking 2>/dev/null || ntpdate -q ${SERVER_IP} + # NVMe/TCP: target is listening + discoverable ss -lntp | grep 4420 sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem diff --git a/serving/chrony/Dockerfile b/serving/chrony/Dockerfile new file mode 100644 index 0000000..edbe610 --- /dev/null +++ b/serving/chrony/Dockerfile @@ -0,0 +1,9 @@ +# Minimal chrony NTP server for the lab boot LAN. Diskless workstations get this +# host as their time source via DHCP option 42 (dnsmasq), so they never reach the +# public NTP pool (no workstation egress). Config is bind-mounted from +# chrony.conf in docker-compose.yml. Build our own so the version is pinned. +FROM alpine:3.20 +RUN apk add --no-cache chrony +# -d: run in the foreground (don't daemonise) so the container stays up and logs +# to stdout for `docker compose logs`. +ENTRYPOINT ["chronyd", "-d", "-f", "/etc/chrony/chrony.conf"] diff --git a/serving/chrony/chrony.conf b/serving/chrony/chrony.conf new file mode 100644 index 0000000..33f0d7a --- /dev/null +++ b/serving/chrony/chrony.conf @@ -0,0 +1,20 @@ +# Lab NTP server (serving stack). Syncs from upstream and serves the boot LAN so +# diskless workstations get time INTERNALLY (handed out as DHCP option 42 by +# dnsmasq) instead of egressing to the public pool. Only this host reaches the +# internet for time; the workstations never do. + +# Upstream time source (this serving host has internet; the workstations don't). +pool pool.ntp.org iburst + +# Serve the boot LAN. This is an isolated lab/boot network, so allow all; scope +# to your subnet (e.g. `allow 192.168.50.0/24`) for anything less trusted. +allow all + +# Serve our own clock at a high stratum if upstream is unreachable, so a diskless +# boot never blocks waiting for time. +local stratum 10 + +# Step the clock on large initial offsets (containers can start far off), then +# slew. driftfile is best-effort (container fs is ephemeral). +makestep 1.0 3 +driftfile /var/lib/chrony/drift diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index 793648c..03c4805 100644 --- a/serving/docker-compose.yml +++ b/serving/docker-compose.yml @@ -63,11 +63,24 @@ services: # resolve the per-environment DNS name to this serving host. - --address=/${NVME_HOST}/${SERVER_IP} - --address=/${BOOT_HOST}/${SERVER_IP} + # Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's + # call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser + # tries. Defense-in-depth behind the in-image Chromium policy/flags; in + # production this belongs in the boot-LAN firewall's egress rules. NOTE: the + # client still *emits* the query (you'll see it in this log answered locally, + # not "forwarded"); stopping the query itself is the browser policy's job. + - --address=/google.com/0.0.0.0 + - --address=/googleapis.com/0.0.0.0 + - --address=/gstatic.com/0.0.0.0 # ---- DHCP ---- - --dhcp-authoritative - --dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},${DHCP_LEASE} - --dhcp-option=option:router,${GATEWAY_IP} - --dhcp-option=option:dns-server,${SERVER_IP} + # NTP server (option 42): point diskless hosts at the lab 'ntp' service + # below so they sync time internally and never reach the public NTP pool. + # The image's NM dispatcher (10-chrony-dhcp-ntp) feeds this into chrony. + - --dhcp-option=option:ntp-server,${SERVER_IP} # ---- UEFI HTTP Boot ---- # Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60) # and hand over the UKI URL (opt 67). IP in the URL avoids a DNS round-trip @@ -96,6 +109,20 @@ services: # For HTTPS boot (production), terminate TLS here with the boot-infra CA's # cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab. + # --- NTP server (serves time to the boot LAN; handed out via DHCP opt 42) --- + ntp: + build: ./chrony + network_mode: host # serve NTP on udp/123 across the boot LAN + cap_add: + - SYS_TIME # discipline the (host-shared) clock it serves + restart: unless-stopped + volumes: + - ./chrony/chrony.conf:/etc/chrony/chrony.conf:ro + # NOTE: with host networking + SYS_TIME this chronyd disciplines the Docker + # HOST clock (the clock isn't namespaced) — intended: the serving host is the + # lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already + # runs its own NTP and you only want this to relay. + # --- NVMe/TCP target (serves rootfs.erofs, read-only) ---------------------- nvmeof: # Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no From ada4aa4b18e1540a4c4a6f0f60d7724a6db48125 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Thu, 18 Jun 2026 10:00:41 +0000 Subject: [PATCH 2/2] Add STIG hrdening --- .gitea/workflows/gitea-main-ci.yaml | 33 ++++++++++ CLAUDE.md | 22 +++++++ diskless.pkr.hcl | 59 +++++++++++++++++- docs/TODO.md | 19 +++++- docs/design-v2-errata.md | 6 ++ docs/stig-deviations.md | 50 ++++++++++++++++ files/stig/diskless-tailoring.xml | 93 +++++++++++++++++++++++++++++ scripts/extract-artifacts.sh | 14 +++++ scripts/stig-deviations.sh | 87 +++++++++++++++++++++++++++ 9 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 docs/stig-deviations.md create mode 100644 files/stig/diskless-tailoring.xml create mode 100644 scripts/stig-deviations.sh diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index 9315c38..449e2ed 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -59,6 +59,10 @@ env: # ship the graphical splash once boot is trusted. (Plymouth is in the image # either way; this only flips the cmdline.) BOOT_SPLASH: "true" + # DISA STIG hardening (OpenSCAP + SSG, diskless/UKI/kiosk tailoring). Report-only + # in CI: the scan + deviations report are published as artifacts; the build is + # never failed on score. Set "false" to skip hardening (boot/secure-path bench). + ENABLE_STIG: "true" # =========================================================================== # TEMPORARY: 802.1X + MACsec are DISABLED for this build (rd.dot1x=0) so the # UKI / rootfs / NVMe-oF boot path can be validated on an OPEN switch port. @@ -187,4 +191,33 @@ jobs: -var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \ -var boot_splash=${{ env.BOOT_SPLASH }} \ + -var enable_stig=${{ env.ENABLE_STIG }} \ . + + - + name: STIG deviations report (report-only) + # The build downloaded the post-remediation scan (stig-arf.xml + + # stig-report.html) into artifacts//. Turn the ARF into a + # readable deviations report. Never fails the build (report-only). + if: ${{ env.ENABLE_STIG == 'true' }} + run: | + dir="artifacts/${{ steps.vars.outputs.image_version }}" + if [ -s "$dir/stig-arf.xml" ]; then + bash scripts/stig-deviations.sh "$dir/stig-arf.xml" "$dir/stig-deviations.md" || true + echo "----- STIG deviations (summary) -----" + sed -n '1,40p' "$dir/stig-deviations.md" || true + else + echo "no STIG ARF found (enable_stig off or scan failed) — skipping report" + fi + + - + name: Publish STIG report artifacts + if: ${{ env.ENABLE_STIG == 'true' }} + uses: actions/upload-artifact@v3 + with: + name: stig-report-${{ steps.vars.outputs.image_version }} + path: | + artifacts/${{ steps.vars.outputs.image_version }}/stig-report.html + artifacts/${{ steps.vars.outputs.image_version }}/stig-arf.xml + artifacts/${{ steps.vars.outputs.image_version }}/stig-deviations.md + if-no-files-found: warn diff --git a/CLAUDE.md b/CLAUDE.md index 30933b1..db5dad6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,15 @@ instead of working around it. build; the supplicant config carries both options. - erofs module availability on the target RHEL kernel (fallback: journal-less ext4 in `extract-artifacts.sh`). +- STIG remediation: (a) verify the SSG datastream name on the target distro + (`ssg-rhel9-ds.xml` vs `ssg-rl9-ds.xml` — the provisioner tries both) and that + the `...content_profile_stig` profile id exists; (b) remediation must not break + boot/login (NM/NVMe, key-only sshd, kiosk, SELinux enforcing) — `enable_stig=false` + is the escape hatch; (c) **reproducibility**: build twice and confirm identical + `root_hash` in verity.json — if it drifts, oscap wrote something + 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`. - 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 @@ -212,6 +221,19 @@ These bit us bringing the build up on Rocky 9.8; they are easy to regress. stack stays in the image, dormant. The matching lab DHCP/HTTP/NVMe-oF serving stack lives in `serving/` (docker compose). Re-enable security by dropping the var / setting `DISABLE_DOT1X: "false"` in the CI workflow. +- **STIG toggle**: `enable_stig=true` (default) applies DISA STIG via + OpenSCAP/SSG in a provisioner, tailored for the diskless/UKI/kiosk architecture + (`files/stig/diskless-tailoring.xml` unselects the N/A or + dangerous-to-remediate rules; every waiver is justified in + `docs/stig-deviations.md`). STIG kernel params ride the **UKI cmdline** + (`extract-artifacts.sh`, gated by the same flag) since there's no GRUB. + **FIPS is OFF and audit offload is LOCAL-only** (decisions). CI is + **report-only**: it publishes `stig-report.html` + `stig-arf.xml` + + `stig-deviations.md` (generated by `scripts/stig-deviations.sh`) as artifacts + and never fails on score. The scanner tooling is removed from the shipped image + (2d hygiene). `enable_stig=false` is the escape hatch if hardening regresses + the boot/secure-path bench. NEVER let a waiver hide a real finding — anything + non-pass that isn't in the register shows as **REVIEW** in the report. - **Boot splash toggle**: `boot_splash=true` (default) bakes `rhgb quiet` for the Plymouth splash; `false` bakes `plymouth.enable=0` for a verbose console boot — keep OFF while debugging the boot chain. Plymouth is always in the image + diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index 65727d4..3de45f1 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -130,6 +130,12 @@ variable "boot_splash" { default = true } +variable "enable_stig" { + type = bool + description = "true (default): apply DISA STIG hardening (OpenSCAP + SSG) with a diskless/UKI/kiosk tailoring (files/stig/diskless-tailoring.xml), bake STIG kernel params into the UKI cmdline, and emit a deviations report. false: skip hardening entirely — escape hatch if a build regresses the boot/secure-path bench. FIPS is OFF and audit offload is LOCAL-only regardless (see docs/stig-deviations.md)." + default = true +} + variable "sb_key" { type = string description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." @@ -416,6 +422,51 @@ build { ] } + # --- 2c-STIG. DISA STIG hardening (OpenSCAP + SSG), diskless/UKI/kiosk tailored + # Runs after admin (so it hardens the full system incl. the admin/sshd config) + # and BEFORE hygiene 2d (so 2d can strip the scanner tooling + reports). The + # tailoring (files/stig/diskless-tailoring.xml) unselects rules that are N/A to + # this architecture or dangerous to auto-remediate (FIPS off, firewall off, + # AIDE, partitions, smartcard, GRUB) — each justified in docs/stig-deviations.md. + # FIPS=off, audit offload=local per decision. Gated by enable_stig. + provisioner "file" { + source = "files/stig" + destination = "/tmp/" + } + provisioner "shell" { + environment_vars = ["ENABLE_STIG=${var.enable_stig}"] + 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", + "echo '==> Installing OpenSCAP + SCAP Security Guide'", + "dnf install -y openscap-scanner scap-security-guide", + # SSG ships rhel9 content (usable on Rocky); some rebuilds name it rl9. + "DS=$(ls /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml /usr/share/xml/scap/ssg/content/ssg-rl9-ds.xml 2>/dev/null | head -1)", + "[ -n \"$DS\" ] || { echo 'ERROR: SSG rhel9 datastream not found' >&2; exit 1; }", + "install -D -m0644 /tmp/stig/diskless-tailoring.xml /etc/stig/diskless-tailoring.xml", + "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", + # 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", + "rm -rf /tmp/stig", + "echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'", + ] + } + # Pull the scan out to the host artifact dir for CI (before 2d removes it from + # the VM). Packer creates the local destination dir on download. Two single- + # source downloads (the file provisioner's download direction takes one source). + provisioner "file" { + direction = "download" + source = "/root/stig-report.html" + destination = "${var.artifact_dir}/${var.image_version}/stig-report.html" + } + provisioner "file" { + direction = "download" + source = "/root/stig-arf.xml" + destination = "${var.artifact_dir}/${var.image_version}/stig-arf.xml" + } + # --- 2d. Final image hygiene: strip caches/logs after the LAST dnf step -- # MUST be the last provisioner. The `dnf clean all` in step 2 runs BEFORE the # kiosk (2b) and admin (2c) provisioners, and 2b enables EPEL + installs @@ -427,6 +478,12 @@ build { provisioner "shell" { inline = [ "set -eux", + # Drop the STIG scanner tooling from the shipped image (the reports were + # already downloaded to the host) — slims the erofs + cuts attack surface. + # Keep auditd (it's part of the hardened runtime). Best-effort. + "dnf remove -y openscap-scanner scap-security-guide openscap-utils 2>/dev/null || true", + # The build-time STIG reports must not ship inside the read-only rootfs. + "rm -f /root/stig-report.html /root/stig-arf.xml", "dnf clean all", "rm -rf /var/cache/dnf/* /var/cache/PackageKit /var/cache/ldconfig/*", "rm -rf /var/lib/dnf/repos/* /var/log/dnf.* /var/log/hawkey.log", @@ -451,7 +508,7 @@ build { # home dirs) — we dropped mkfs.erofs --all-root, which used to mask this by # flattening everything to root:root. sudo is a no-op if already root, and # the runner has passwordless sudo (cf. the `sudo apt-get` install step). - "sudo bash -c \"NVMET_TRADDR='${var.nvmet_traddr}' BOOT_SPLASH='${var.boot_splash}' bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}\"", + "sudo bash -c \"NVMET_TRADDR='${var.nvmet_traddr}' BOOT_SPLASH='${var.boot_splash}' STIG='${var.enable_stig}' bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}\"", # Hand the OUTPUT FILES back to the build user (their in-erofs ownership is # already baked correctly) so build-oci.sh can write the UKI and push with # the build user's `buildah login`. diff --git a/docs/TODO.md b/docs/TODO.md index 8bf0148..2fab6e8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -45,7 +45,24 @@ Done: transiently grabs `en*`, fall back to also baking a static `en*`-unmanaged file for secure-only builds. -## [ ] 2. Apply RHEL9 STIG hardening to the built image +## [~] 2. Apply RHEL9 STIG hardening to the built image + +**IMPLEMENTED 2026-06-18 — awaiting build/bench verification.** OpenSCAP + SSG +remediation in a provisioner (`enable_stig` var, default on), tailored for the +diskless/UKI/kiosk architecture via `files/stig/diskless-tailoring.xml`. STIG +kernel params baked into the UKI cmdline (`extract-artifacts.sh`). Decisions: +**FIPS off**, **audit local-only**, **CI report-only**. Deviations are documented +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:** SSG datastream name; boot/login +still work; reproducible verity hash across two builds; triage REVIEW rows. + +Remaining (future hardening, currently waived — see the register): FIPS, +firewall, remote audit offload, per-host/rotatable admin access. + +### Original spec (retained for reference) OpenSCAP + SCAP Security Guide (`xccdf_org.ssgproject.content_profile_stig`), run as a Packer provisioner after `dnf install` and before the dracut/initramfs build diff --git a/docs/design-v2-errata.md b/docs/design-v2-errata.md index 0ee5e9a..5c7e131 100644 --- a/docs/design-v2-errata.md +++ b/docs/design-v2-errata.md @@ -78,6 +78,12 @@ These don't contradict the PDF (it doesn't specify them) but are worth recording - **Reference serving stack.** §7's SPDK + nginx + DHCP/DNS serving stack has a concrete docker-compose implementation in [../serving/](../serving/), incl. Docker image-volume mounting of the OCI artifact image. +- **STIG hardening.** DISA STIG (SSG) is applied with a diskless/UKI/kiosk + tailoring; several rule families are deviated because the architecture makes + them N/A (no partitions, no GRUB, AIDE→dm-verity) or by lab decision (FIPS off, + firewall off, audit local-only). Full rationale + compensating controls: + [stig-deviations.md](stig-deviations.md). STIG kernel params live in the UKI + cmdline (the no-GRUB compensating control). - Assorted build-time fixes (HCL `%%{` escaping, file-provisioner staging, dracut module naming) are documented in [../CLAUDE.md](../CLAUDE.md) → "RHEL 9.x build notes". diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md new file mode 100644 index 0000000..36b474b --- /dev/null +++ b/docs/stig-deviations.md @@ -0,0 +1,50 @@ +# STIG deviation register — diskless fleet image + +DISA STIG (SSG `xccdf_org.ssgproject.content_profile_stig` for RHEL 9) is applied +to the image via `oscap --remediate` with a tailoring file +([../files/stig/diskless-tailoring.xml](../files/stig/diskless-tailoring.xml)). +This register documents every **intentional** deviation — the rules unselected in +that tailoring, plus posture decisions — with the rationale and compensating +control. The per-build **generated** report +(`stig-deviations.md` / `stig-report.html`, produced by +[../scripts/stig-deviations.sh](../scripts/stig-deviations.sh) from the +post-remediation scan) cross-references this file: any non-`pass` rule **not** +listed here is flagged **REVIEW**. + +Posture decisions (2026-06-18, lab phase): +- **FIPS: OFF** — revisit alongside UKI signing / Secure Boot. +- **Audit offload: LOCAL-only** — auditd runs and logs to the tmpfs overlay; no + remote collector yet. +- **CI: report-only** — `oscap eval` publishes ARF + HTML; never fails the build. + +## Intentional deviations + +| # | STIG/SSG area (rules) | Why it doesn't apply / can't be met | Compensating control | +|---|---|---|---| +| 1 | **Separate partitions + their mount options** for `/var`, `/var/log`, `/var/log/audit`, `/tmp`, `/home`, `/var/tmp`, `/dev/shm` (`partition_for_*`, `mount_option_*`) | Root is a single immutable **erofs** image on NVMe/TCP with a **tmpfs volatile overlay** — there is no local disk to partition, and these dirs are not separate mounts. The related `mount_option_*` rules will therefore show as findings; they are covered by this entry. | The entire root is **read-only** (erofs+dm-verity) and the writable layer is **tmpfs (RAM)**, which structurally bounds the tampering / space-exhaustion risks the partition+option rules target. `noexec` on `/tmp` is intentionally NOT forced — the browser kiosk uses `/tmp` for its profile/cache and can break under `noexec`. | +| 2 | **Bootloader / GRUB** (`grub2_*`: password, admin user, and the `*_argument` kernel-param rules) | Boot is a **UKI** (systemd-stub) over UEFI HTTP boot — there is **no GRUB**. | The STIG kernel params (`audit=1`, `audit_backlog_limit`, `slub_debug=P`, `page_poison=1`, `vsyscall=none`, `init_on_alloc/free`) are baked into the **signed UKI cmdline** ([../scripts/extract-artifacts.sh](../scripts/extract-artifacts.sh)); the cmdline is immutable once signed (Secure Boot covers it), which is stronger than an editable GRUB config. | +| 3 | **AIDE / file integrity** (`package_aide_installed`, `aide_build_database`, `aide_periodic_cron_checking`, `aide_scan_notification`, `aide_verify_*`) | The root is read-only and cryptographically verified; an on-disk AIDE db would also be non-deterministic and break the reproducible verity hash. | **dm-verity** verifies the whole root filesystem against a signed root hash on every read — continuous, hardware-backed integrity that supersedes AIDE's periodic scan. | +| 4 | **Firewall** (`service_firewalld_enabled`, `package_firewalld_installed`, `set_firewalld_default_zone`) | Currently `firewall --disabled`; auto-enabling firewalld/nftables risks the NVMe/TCP (`:4420`) and NetworkManager paths during bring-up. | Isolated boot LAN; egress controlled at the serving-stack / network firewall layer. **Future hardening** — re-enable with explicit allow rules once the secure path is verified. | +| 5 | **FIPS** (`enable_fips_mode`, `grub2_enable_fips_mode`, `package_dracut-fips_installed`) | Decision: off for the lab phase (interacts with the still-unsigned UKI / Secure Boot story). | Revisit with signing. No weakened crypto is configured in the meantime. | +| 6 | **Remote audit offload** (`auditd_audispd_configure_remote_server`, `..._encrypt_sent_records`, `..._network_failure_action`, `rsyslog_remote_loghost`) | Decision: local-only for now; no collector deployed. | auditd enabled and recording locally. **Future hardening** — `audisp-remote`/rsyslog to a per-environment collector DNS name (host-generic, no per-host state). | +| 7 | **Smart card login** (`require_smart_card_login`, `sssd_enable_smartcard`, `install_smartcard_packages`) | Diskless hosts have no smartcard reader; admin access is SSH key-based. Enforcing smartcard login would lock the host out. | Admin auth is an SSH **key-only** account; root is locked; password auth disabled. | +| 8 | **NOPASSWD sudo** (`sudo_remove_nopasswd`) | The baked admin is a **passwordless, key-only** account, so `sudo` cannot prompt for a password — NOPASSWD is required for it to function at all. | Single fleet-generic admin, key-only, `wheel`; no interactive password exists to protect. Tracked for the eventual per-host/rotatable access model (SSH CA / config-agent). | +| 9 | **GUI / autologin / GDM** (gnome screensaver, GDM banner, automatic-login rules) | No GNOME/GDM is installed; the kiosk is an **intentional** autologin single-app (weston + Chromium) session on kiosk builds. | Kiosk user is unprivileged and passwordless; the session runs a single locked-down browser (no shell, no nav). Applies to kiosk builds only; non-kiosk builds don't install the GUI stack. | + +## Things that are NOT deviations (kept / enforced) + +- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). +- **auditd** enabled; STIG audit rules applied; logs locally. +- **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 + boot into the overlay; `machine-id` empty. +- Password-policy / faillock rules remain selected — harmless (no interactive + passwords) and keep the report honest. + +## How to read the generated report + +`scripts/stig-deviations.sh stig-arf.xml` emits a table of every rule whose +result is not `pass`. Each row is tagged: +- **intentional** — matches an unselected rule / area in this register. +- **REVIEW** — a real finding to triage (fix it, or add a justified entry here). diff --git a/files/stig/diskless-tailoring.xml b/files/stig/diskless-tailoring.xml new file mode 100644 index 0000000..8911d7e --- /dev/null +++ b/files/stig/diskless-tailoring.xml @@ -0,0 +1,93 @@ + + + + + 1 + + + DISA STIG for RHEL 9 — diskless fleet tailoring + STIG with rules inapplicable to the diskless/UKI/kiosk architecture unselected; see docs/stig-deviations.md. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/extract-artifacts.sh b/scripts/extract-artifacts.sh index d88f6ab..f43af81 100755 --- a/scripts/extract-artifacts.sh +++ b/scripts/extract-artifacts.sh @@ -27,6 +27,11 @@ NVMET_TRADDR=${NVMET_TRADDR:-NVMET_TRADDR} # "rhgb quiet" so Plymouth shows the graphical splash; anything else bakes # "plymouth.enable=0" for a fully verbose console boot (boot-chain debugging). BOOT_SPLASH=${BOOT_SPLASH:-true} +# STIG hardening toggle (from the enable_stig Packer var). "true" bakes the STIG +# kernel params into the (signed) UKI cmdline — the no-GRUB compensating control +# for the bootloader-argument rules (see docs/stig-deviations.md). FIPS is NOT +# included here (decision: off); add fips=1 when FIPS is turned on. +STIG=${STIG:-true} # Stable namespace UUIDs. Devices are referenced by-id # (/dev/disk/by-id/nvme-uuid.) rather than /dev/nvmeNnM because NVMe # controller/namespace indices are NOT stable (a transient discovery controller @@ -156,6 +161,14 @@ else SPLASH_FRAGMENT="plymouth.enable=0 " fi +# STIG kernel hardening params (no GRUB here, so they ride the signed UKI +# cmdline): kernel auditing on from boot, larger audit backlog, slab/page +# poisoning, no vsyscall, zero-on-alloc/free. NO fips=1 (FIPS off — decision). +STIG_FRAGMENT="" +if [ "$STIG" = "true" ]; then + STIG_FRAGMENT="audit=1 audit_backlog_limit=8192 slub_debug=P page_poison=1 vsyscall=none init_on_alloc=1 init_on_free=1 " +fi + DOT1X_FRAGMENT="" if [ "$DISABLE_DOT1X" = "true" ]; then echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2 @@ -169,6 +182,7 @@ if [ "$DISABLE_DOT1X" = "true" ]; then fi printf '%s' \ "$SPLASH_FRAGMENT" \ + "$STIG_FRAGMENT" \ "$DOT1X_FRAGMENT" \ "rd.neednet=1 systemd.volatile=overlay " \ "rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \ diff --git a/scripts/stig-deviations.sh b/scripts/stig-deviations.sh new file mode 100644 index 0000000..5737b40 --- /dev/null +++ b/scripts/stig-deviations.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# stig-deviations.sh [output.md] +# +# Turns a post-remediation OpenSCAP result (ARF or XCCDF results XML) into a +# human-readable deviations report: every rule whose result is not "pass", +# tagged "intentional" (matches an unselected rule in the diskless tailoring) or +# "REVIEW" (a real finding to triage). Writes markdown to stdout, or to the +# second arg if given. Run in CI on the ARF pulled out of the build VM (see the +# Gitea workflow); pairs with the curated register in docs/stig-deviations.md. +set -euo pipefail + +ARF=${1:?usage: stig-deviations.sh [output.md]} +OUT=${2:-/dev/stdout} +# Tailoring file whose unselected rules count as "intentional" deviations. +TAILORING=${TAILORING:-files/stig/diskless-tailoring.xml} + +[ -f "$ARF" ] || { echo "ERROR: results file not found: $ARF" >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 1; } + +python3 - "$ARF" "$TAILORING" <<'PY' > "$OUT" +import sys, xml.etree.ElementTree as ET +from collections import Counter + +arf, tailoring = sys.argv[1], sys.argv[2] +X = "{http://checklists.nist.gov/xccdf/1.2}" + +root = ET.parse(arf).getroot() + +# rule id -> (severity, title) from the benchmark embedded in the ARF +rules = {} +for r in root.iter(X + "Rule"): + rid = r.get("id") + t = r.find(X + "title") + rules[rid] = (r.get("severity", "unknown"), + (t.text or "").strip() if t is not None else "") + +# rules we deliberately unselected (intentional deviations) +waived = set() +try: + troot = ET.parse(tailoring).getroot() + for s in troot.iter(X + "select"): + if s.get("selected", "true") == "false": + waived.add(s.get("idref")) +except (FileNotFoundError, ET.ParseError): + pass + +counts = Counter() +findings = [] +for rr in root.iter(X + "rule-result"): + rid = rr.get("idref") + res_el = rr.find(X + "result") + res = res_el.text if res_el is not None else "unknown" + counts[res] += 1 + if res in ("fail", "error", "unknown", "notchecked"): + sev, title = rules.get(rid, ("unknown", "")) + tag = "intentional" if rid in waived else "REVIEW" + findings.append((sev, res, rid, title, tag)) + +sev_order = {"high": 0, "medium": 1, "low": 2, "unknown": 3} +findings.sort(key=lambda f: (sev_order.get(f[0], 9), f[2])) + +short = lambda rid: rid.replace("xccdf_org.ssgproject.content_rule_", "") +review = sum(1 for f in findings if f[4] == "REVIEW") + +print("# STIG deviations report (generated)\n") +print("Generated by `scripts/stig-deviations.sh` from the post-remediation scan. " + "See [stig-deviations.md](stig-deviations.md) for the curated register of " + "intentional deviations.\n") +print("## Summary\n") +for k in ("pass", "fixed", "fail", "error", "unknown", "notchecked", + "notapplicable", "notselected", "informational"): + if counts.get(k): + print(f"- **{k}**: {counts[k]}") +print(f"\n**{review} rule(s) need REVIEW** " + f"(non-pass and not a documented intentional deviation).\n") + +if findings: + print("## Findings (non-pass)\n") + print("| Severity | Result | Rule | Disposition | Title |") + print("|---|---|---|---|---|") + for sev, res, rid, title, tag in findings: + print(f"| {sev} | {res} | `{short(rid)}` | {tag} | {title} |") +else: + print("No fail/error/unknown/notchecked rules — clean against the tailored profile.") +PY + +[ "$OUT" = /dev/stdout ] || echo "wrote $OUT" >&2