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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"DeviceMetricsReportingEnabled": false,
|
"DeviceMetricsReportingEnabled": false,
|
||||||
|
|
||||||
"BrowserSignin": 0,
|
"BrowserSignin": 0,
|
||||||
|
"SigninAllowed": false,
|
||||||
"SyncDisabled": true,
|
"SyncDisabled": true,
|
||||||
|
|
||||||
"SafeBrowsingProtectionLevel": 0,
|
"SafeBrowsingProtectionLevel": 0,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 " \
|
||||||
|
|||||||
Executable
+79
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user