Merge pull request 'hardening' (#3) from hardening into main

Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/3
This commit is contained in:
audiobuzz
2026-06-18 12:05:16 +00:00
16 changed files with 603 additions and 12 deletions
+33
View File
@@ -59,6 +59,10 @@ env:
# ship the graphical splash once boot is trusted. (Plymouth is in the image # ship the graphical splash once boot is trusted. (Plymouth is in the image
# either way; this only flips the cmdline.) # either way; this only flips the cmdline.)
BOOT_SPLASH: "true" 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 # 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. # 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 ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \
-var kiosk_url="${{ env.KIOSK_URL }}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \
-var boot_splash=${{ env.BOOT_SPLASH }} \ -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/<version>/. 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
+29
View File
@@ -152,6 +152,22 @@ instead of working around it.
build; the supplicant config carries both options. build; the supplicant config carries both options.
- erofs module availability on the target RHEL kernel (fallback: - erofs module availability on the target RHEL kernel (fallback:
journal-less ext4 in `extract-artifacts.sh`). 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
`$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 - `fleet` Plymouth theme: confirm (a) the script plugin package actually
installed (`/usr/lib64/plymouth/script.so` — else the build fell back to 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 `spinner`, logged as a WARNING), (b) the scripted theme + a font got bundled
@@ -205,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 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 stack lives in `serving/` (docker compose). Re-enable security by dropping the
var / setting `DISABLE_DOT1X: "false"` in the CI workflow. 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 - **Boot splash toggle**: `boot_splash=true` (default) bakes `rhgb quiet` for the
Plymouth splash; `false` bakes `plymouth.enable=0` for a verbose console boot — 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 + keep OFF while debugging the boot chain. Plymouth is always in the image +
+73 -3
View File
@@ -130,6 +130,12 @@ variable "boot_splash" {
default = true 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" { variable "sb_key" {
type = string type = string
description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM."
@@ -262,6 +268,16 @@ build {
# secure mode, reserving the physical NIC for wpa_supplicant. # secure mode, reserving the physical NIC for wpa_supplicant.
"chmod 0755 /usr/lib/dracut/modules.d/95dot1x-macsec/*.sh", "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 # Inject the fleet NIC drivers into the dracut config
"sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf",
@@ -341,15 +357,18 @@ build {
"install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini", "install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini",
# Chromium managed policy: stops the browser phoning home to Google # Chromium managed policy: stops the browser phoning home to Google
# (sign-in, variations, optimization-guide, component/auto update, metrics, # (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. # 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/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). # URL from the var (weston renderer is set via --use-pixman in the unit).
"install -d -m0755 /etc/kiosk", "install -d -m0755 /etc/kiosk",
"printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env", "printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env",
"chmod 0644 /etc/kiosk/kiosk.env", "chmod 0644 /etc/kiosk/kiosk.env",
# Label the new files so they survive into the enforcing erofs. # 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). # Boot graphical and start the kiosk (replaces the tty1 getty).
"systemctl enable kiosk.service", "systemctl enable kiosk.service",
"systemctl set-default graphical.target", "systemctl set-default graphical.target",
@@ -403,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 '<html><body>STIG hardening disabled for this build (enable_stig=false).</body></html>' > /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 -- # --- 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 # 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 # kiosk (2b) and admin (2c) provisioners, and 2b enables EPEL + installs
@@ -414,6 +478,12 @@ build {
provisioner "shell" { provisioner "shell" {
inline = [ inline = [
"set -eux", "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", "dnf clean all",
"rm -rf /var/cache/dnf/* /var/cache/PackageKit /var/cache/ldconfig/*", "rm -rf /var/cache/dnf/* /var/cache/PackageKit /var/cache/ldconfig/*",
"rm -rf /var/lib/dnf/repos/* /var/log/dnf.* /var/log/hawkey.log", "rm -rf /var/lib/dnf/repos/* /var/log/dnf.* /var/log/hawkey.log",
@@ -438,7 +508,7 @@ build {
# home dirs) — we dropped mkfs.erofs --all-root, which used to mask this by # 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 # 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). # 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 # 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 # already baked correctly) so build-oci.sh can write the UKI and push with
# the build user's `buildah login`. # the build user's `buildah login`.
+43 -9
View File
@@ -45,7 +45,24 @@ Done:
transiently grabs `en*`, fall back to also baking a static `en*`-unmanaged file transiently grabs `en*`, fall back to also baking a static `en*`-unmanaged file
for secure-only builds. 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 OpenSCAP + SCAP Security Guide (`xccdf_org.ssgproject.content_profile_stig`), run
as a Packer provisioner after `dnf install` and before the dracut/initramfs build as a Packer provisioner after `dnf install` and before the dracut/initramfs build
@@ -155,15 +172,32 @@ Path to getting here (don't re-discover):
avc`; the kiosk user is unconfined so likely none — but do NOT set permissive 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 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). 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 — (accounts/variations/optimization-guide/component+auto update/safe browsing —
seen in dnsmasq logs). Stopped via a managed policy seen in dnsmasq logs). Two layers:
(`files/kiosk/chromium-kiosk-policy.json` → `/etc/chromium/policies/managed/`) - **Flags** in `kiosk-session` (`--disable-background-networking`,
plus hardening flags in `kiosk-session` (`--disable-background-networking`, `--disable-component-update`, `--disable-features=OptimizationHints,...`) —
`--disable-component-update`, `--disable-features=OptimizationHints,...`, etc.). confirmed killing variations/optimization-guide/update queries.
Verify with the dnsmasq query log after a rebuild — only `boot.fleet.lab` - **Managed policy** `files/kiosk/chromium-kiosk-policy.json` for the
(kiosk page) + chrony NTP should remain. NTP is the OS (chrony), not Chromium; policy-governed bits (sign-in `accounts.google.com`, sync). FIX 2026-06-18:
optionally point it at the serving host to fully contain egress. 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 — - **Image size:** Chromium+Mesa add hundreds of MB to the *uncompressed* erofs —
feeds the "shrink the rootfs" concern (and #7); reconsider whether a lighter feeds the "shrink the rootfs" concern (and #7); reconsider whether a lighter
browser/render path is viable. browser/render path is viable.
+6
View File
@@ -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 - **Reference serving stack.** §7's SPDK + nginx + DHCP/DNS serving stack has a
concrete docker-compose implementation in [../serving/](../serving/), incl. concrete docker-compose implementation in [../serving/](../serving/), incl.
Docker image-volume mounting of the OCI artifact image. 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, - Assorted build-time fixes (HCL `%%{` escaping, file-provisioner staging,
dracut module naming) are documented in dracut module naming) are documented in
[../CLAUDE.md](../CLAUDE.md) → "RHEL 9.x build notes". [../CLAUDE.md](../CLAUDE.md) → "RHEL 9.x build notes".
+50
View File
@@ -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).
@@ -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 <iface>.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
+1
View File
@@ -6,6 +6,7 @@
"DeviceMetricsReportingEnabled": false, "DeviceMetricsReportingEnabled": false,
"BrowserSignin": 0, "BrowserSignin": 0,
"SigninAllowed": false,
"SyncDisabled": true, "SyncDisabled": true,
"SafeBrowsingProtectionLevel": 0, "SafeBrowsingProtectionLevel": 0,
+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
STIG tailoring for the diskless fleet image.
Extends the SSG RHEL9 STIG profile and UNSELECTS rules that are structurally
inapplicable to this architecture (immutable erofs+dm-verity root on a tmpfs
overlay, UKI/no-GRUB, autologin kiosk, key-only admin, no per-host state) or
that would be dangerous to auto-remediate (FIPS off per decision; firewall off;
AIDE = nondeterministic db; smartcard = login lockout). Every unselect here has
a matching entry in docs/stig-deviations.md with its compensating control.
Hand-written (not autotailor) on purpose: oscap reads this directly as a
tailoring file and silently ignores idrefs that don't exist in the installed
datastream, so a renamed rule can't break the build — it just won't be waived
(and will then surface in the generated deviations report for review).
Used by oscap xccdf eval against ssg-rhel9-ds.xml, selecting the tailored
profile id below and passing this file as the tailoring file.
-->
<xccdf:Tailoring xmlns:xccdf="http://checklists.nist.gov/xccdf/1.2"
id="xccdf_org.ssgproject.content_tailoring_diskless">
<xccdf:benchmark href="/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml"/>
<xccdf:version time="2026-06-18T00:00:00">1</xccdf:version>
<xccdf:Profile id="xccdf_org.ssgproject.content_profile_stig_diskless"
extends="xccdf_org.ssgproject.content_profile_stig">
<xccdf:title>DISA STIG for RHEL 9 — diskless fleet tailoring</xccdf:title>
<xccdf:description>STIG with rules inapplicable to the diskless/UKI/kiosk architecture unselected; see docs/stig-deviations.md.</xccdf:description>
<!-- FIPS: disabled for now (decision). Revisit with signing/Secure Boot. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_enable_fips_mode" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_enable_fips_mode" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_package_dracut-fips_installed" selected="false"/>
<!-- AIDE / file integrity: dm-verity is the compensating control (whole RO
root cryptographically verified). AIDE would also add a nondeterministic
db, breaking the reproducible verity hash. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_package_aide_installed" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_aide_build_database" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_aide_periodic_cron_checking" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_aide_scan_notification" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_aide_verify_acls" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_aide_verify_ext_attributes" selected="false"/>
<!-- Bootloader: UKI, no GRUB. The audit/slub/page-poison/vsyscall/etc kernel
params are set in the signed UKI cmdline (extract-artifacts.sh) instead —
the compensating control. GRUB password rules are N/A. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_uefi_password" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_admin_username" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_audit_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_audit_backlog_limit_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_slub_debug_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_page_poison_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_vsyscall_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_init_on_alloc_argument" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_grub2_pti_argument" selected="false"/>
<!-- Partitioning: single erofs root + tmpfs overlay; cannot create separate
/var,/home,/tmp,... partitions. Intent met via nodev/nosuid/noexec tmpfs
mount options (see provisioner). -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_var" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_var_log" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_var_log_audit" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_tmp" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_home" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_partition_for_var_tmp" selected="false"/>
<!-- Firewall: disabled on the isolated boot LAN (egress controlled at the
serving/firewall layer). Auto-enabling firewalld/nftables risks the
NVMe/TCP (4420) + NM paths. Re-enable as future hardening. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_service_firewalld_enabled" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_package_firewalld_installed" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_set_firewalld_default_zone" selected="false"/>
<!-- Remote audit offload: LOCAL-only for now (decision). auditd runs and logs
to the overlay; audisp-remote/rsyslog remote loghost waived. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_configure_remote_server" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_encrypt_sent_records" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_network_failure_action" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_remote_loghost" selected="false"/>
<!-- Smart card: diskless, key-based admin; no PKI/smartcard reader. Enabling
smartcard-required login would lock the host out. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_require_smart_card_login" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_sssd_enable_smartcard" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_install_smartcard_packages" selected="false"/>
<!-- Admin sudo: the baked admin is key-only (no password), so sudo can't
prompt for one — NOPASSWD is required for it to function. Documented. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_sudo_remove_nopasswd" selected="false"/>
</xccdf:Profile>
</xccdf:Tailoring>
+14
View File
@@ -27,6 +27,11 @@ NVMET_TRADDR=${NVMET_TRADDR:-NVMET_TRADDR}
# "rhgb quiet" so Plymouth shows the graphical splash; anything else bakes # "rhgb quiet" so Plymouth shows the graphical splash; anything else bakes
# "plymouth.enable=0" for a fully verbose console boot (boot-chain debugging). # "plymouth.enable=0" for a fully verbose console boot (boot-chain debugging).
BOOT_SPLASH=${BOOT_SPLASH:-true} 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 # Stable namespace UUIDs. Devices are referenced by-id
# (/dev/disk/by-id/nvme-uuid.<uuid>) rather than /dev/nvmeNnM because NVMe # (/dev/disk/by-id/nvme-uuid.<uuid>) rather than /dev/nvmeNnM because NVMe
# controller/namespace indices are NOT stable (a transient discovery controller # controller/namespace indices are NOT stable (a transient discovery controller
@@ -156,6 +161,14 @@ else
SPLASH_FRAGMENT="plymouth.enable=0 " SPLASH_FRAGMENT="plymouth.enable=0 "
fi 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="" DOT1X_FRAGMENT=""
if [ "$DISABLE_DOT1X" = "true" ]; then if [ "$DISABLE_DOT1X" = "true" ]; then
echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2 echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2
@@ -169,6 +182,7 @@ if [ "$DISABLE_DOT1X" = "true" ]; then
fi fi
printf '%s' \ printf '%s' \
"$SPLASH_FRAGMENT" \ "$SPLASH_FRAGMENT" \
"$STIG_FRAGMENT" \
"$DOT1X_FRAGMENT" \ "$DOT1X_FRAGMENT" \
"rd.neednet=1 systemd.volatile=overlay " \ "rd.neednet=1 systemd.volatile=overlay " \
"rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \ "rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \
+79
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# stig-deviations.sh <results-arf.xml> [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 <results-arf.xml> [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
+3
View File
@@ -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) # HTTP: kiosk test page is served (what the diskless kiosk loads by default)
curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK 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 # NVMe/TCP: target is listening + discoverable
ss -lntp | grep 4420 ss -lntp | grep 4420
sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem
+9
View File
@@ -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"]
+20
View File
@@ -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
+27
View File
@@ -63,11 +63,24 @@ services:
# resolve the per-environment DNS name to this serving host. # resolve the per-environment DNS name to this serving host.
- --address=/${NVME_HOST}/${SERVER_IP} - --address=/${NVME_HOST}/${SERVER_IP}
- --address=/${BOOT_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 ----
- --dhcp-authoritative - --dhcp-authoritative
- --dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},${DHCP_LEASE} - --dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},${DHCP_LEASE}
- --dhcp-option=option:router,${GATEWAY_IP} - --dhcp-option=option:router,${GATEWAY_IP}
- --dhcp-option=option:dns-server,${SERVER_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 ---- # ---- UEFI HTTP Boot ----
# Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60) # 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 # 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 # 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. # 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) ---------------------- # --- NVMe/TCP target (serves rootfs.erofs, read-only) ----------------------
nvmeof: nvmeof:
# Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no # Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no