Merge in main
This commit is contained in:
+80
-1
@@ -413,6 +413,17 @@ build {
|
|||||||
# AVC in Wazuh — that would also hide future rhsmcertd_t denials.)
|
# AVC in Wazuh — that would also hide future rhsmcertd_t denials.)
|
||||||
"systemctl mask rhsmcertd.service 2>/dev/null || true",
|
"systemctl mask rhsmcertd.service 2>/dev/null || true",
|
||||||
"systemctl disable rhsmcertd.service 2>/dev/null || true",
|
"systemctl disable rhsmcertd.service 2>/dev/null || true",
|
||||||
|
# Same class of noise, different trigger: dnf-makecache.timer fires ~10 min
|
||||||
|
# after boot and imports the whole dnf Python stack (dnf/libdnf/librepo/rhsm).
|
||||||
|
# Because the reproducible erofs `-T 0` reset makes every source .py look
|
||||||
|
# newer than its baked .pyc, that import REWRITES bytecode into the tmpfs
|
||||||
|
# overlay (measured 2026-07-03: ~MBs of dnf/libdnf/rhsm __pycache__) and
|
||||||
|
# throws lib_t write/unlink AVCs. An immutable diskless host never refreshes
|
||||||
|
# dnf caches (no persistent store, no runtime installs), so mask the timer to
|
||||||
|
# remove the trigger. Belt-and-braces with the compileall unchecked-hash step
|
||||||
|
# in 2d (which stops the rewrite at the source). See docs/TODO.md #16.
|
||||||
|
"systemctl mask dnf-makecache.timer 2>/dev/null || true",
|
||||||
|
"systemctl disable dnf-makecache.timer 2>/dev/null || true",
|
||||||
# Read-only erofs/verity root; runtime state on a tmpfs overlay provided
|
# Read-only erofs/verity root; runtime state on a tmpfs overlay provided
|
||||||
# by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh).
|
# by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh).
|
||||||
# RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module.
|
# RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module.
|
||||||
@@ -855,6 +866,21 @@ build {
|
|||||||
"semodule -i /tmp/chrony_dhcp_dispatcher.pp",
|
"semodule -i /tmp/chrony_dhcp_dispatcher.pp",
|
||||||
"rm -f /tmp/chrony_dhcp_dispatcher.mod /tmp/chrony_dhcp_dispatcher.pp",
|
"rm -f /tmp/chrony_dhcp_dispatcher.mod /tmp/chrony_dhcp_dispatcher.pp",
|
||||||
"semodule -l | grep -q '^chrony_dhcp_dispatcher' && echo '==> SELinux module chrony_dhcp_dispatcher installed' || { echo 'ERROR: chrony_dhcp_dispatcher module not installed' >&2; exit 1; }",
|
"semodule -l | grep -q '^chrony_dhcp_dispatcher' && echo '==> SELinux module chrony_dhcp_dispatcher installed' || { echo 'ERROR: chrony_dhcp_dispatcher module not installed' >&2; exit 1; }",
|
||||||
|
# SELinux: allow fapolicyd to statfs (getattr) tmpfs. fapolicyd is the exec
|
||||||
|
# guard for the WRITABLE overlay (dm-verity covers only the RO erofs lower);
|
||||||
|
# it statfs's mounts to decide which to watch (tmpfs is in its default
|
||||||
|
# watch_fs). This host's root is systemd.volatile=overlay, so statfs("/")
|
||||||
|
# lands on the tmpfs upper (audit: dev=tmpfs ino=1 name="/") and the shipped
|
||||||
|
# policy denies it -> fapolicyd can't fully guard the tmpfs mounts (/, /tmp,
|
||||||
|
# /dev/shm) — a real exec-guard gap. Ship a minimal allow (docs/stig-deviations.md).
|
||||||
|
# checkpolicy already ensured above. Same policy-store-rebuild reproducibility
|
||||||
|
# caveat as chrony.
|
||||||
|
"echo '==> supplemental: SELinux module fapolicyd_tmpfs (fapolicyd statfs the volatile-overlay tmpfs)'",
|
||||||
|
"checkmodule -M -m -o /tmp/fapolicyd_tmpfs.mod /tmp/stig/fapolicyd-tmpfs.te",
|
||||||
|
"semodule_package -o /tmp/fapolicyd_tmpfs.pp -m /tmp/fapolicyd_tmpfs.mod",
|
||||||
|
"semodule -i /tmp/fapolicyd_tmpfs.pp",
|
||||||
|
"rm -f /tmp/fapolicyd_tmpfs.mod /tmp/fapolicyd_tmpfs.pp",
|
||||||
|
"semodule -l | grep -q '^fapolicyd_tmpfs' && echo '==> SELinux module fapolicyd_tmpfs installed' || { echo 'ERROR: fapolicyd_tmpfs module not installed' >&2; exit 1; }",
|
||||||
"rm -rf /tmp/stig",
|
"rm -rf /tmp/stig",
|
||||||
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
|
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
|
||||||
]
|
]
|
||||||
@@ -917,9 +943,43 @@ build {
|
|||||||
# custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston
|
# custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston
|
||||||
# /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.)
|
# /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.)
|
||||||
"if command -v fapolicyd-cli >/dev/null 2>&1; then",
|
"if command -v fapolicyd-cli >/dev/null 2>&1; then",
|
||||||
|
# RAM: fapolicyd's LMDB trust DB is rebuilt at BOOT onto the tmpfs overlay,
|
||||||
|
# so its map size (db_max_size, in MB) is a fixed RAM RESERVATION, not a
|
||||||
|
# ceiling it grows into. The EL9 default (100) was measured consuming a real
|
||||||
|
# 100 MiB of overlay RAM on a booted host (2026-07-03) — ~80% of the entire
|
||||||
|
# overlay. The minimal+kiosk trust set is a fraction of that (fapolicyd
|
||||||
|
# upstream shipped db_max_size=50 for years as the default that covers a
|
||||||
|
# FULL workstation rpmdb, which is larger than this image). Halve it to
|
||||||
|
# reclaim ~50 MiB. MUST run before the --update below so the rebuilt db uses
|
||||||
|
# the smaller map. BENCH-VERIFY the exec-guard still works end-to-end (boot +
|
||||||
|
# kiosk render + admin SSH + sudo): undersize this and fapolicyd hits
|
||||||
|
# MDB_MAP_FULL, allowlisting breaks, and login/kiosk exec is bricked (keep
|
||||||
|
# fapolicyd working — it's the writable-overlay exec guard dm-verity can't
|
||||||
|
# provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on
|
||||||
|
# the bench; see docs/TODO.md.
|
||||||
|
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then sed -ri 's/^[[:space:]]*#?[[:space:]]*db_max_size[[:space:]]*=.*/db_max_size = 50/' /etc/fapolicyd/fapolicyd.conf; grep -q '^db_max_size' /etc/fapolicyd/fapolicyd.conf || printf 'db_max_size = 50\\n' >> /etc/fapolicyd/fapolicyd.conf; echo '==> fapolicyd: db_max_size set to 50 MiB (was EL9 default ~100)'; fi",
|
||||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||||
" fapolicyd-cli --update 2>/dev/null || true",
|
" fapolicyd-cli --update 2>/dev/null || true",
|
||||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'",
|
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'",
|
||||||
|
# watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the
|
||||||
|
# shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and
|
||||||
|
# therefore intercepts NO exec on / — the exec guard for the writable overlay
|
||||||
|
# (the whole reason we keep fapolicyd; dm-verity can't cover the tmpfs upper)
|
||||||
|
# is effectively OFF at runtime. VERIFIED on the bench 2026-07-03: a novel
|
||||||
|
# non-RPM binary in /tmp (overlayfs) ran freely under `permissive=0`. Add
|
||||||
|
# 'overlay' so fapolicyd actually watches the root; trust is path-based
|
||||||
|
# (integrity=none) so overlay paths resolve against the rpmdb trust as normal.
|
||||||
|
# The #15 tmpfs getattr allow supports this (fapolicyd statfs's the overlay's
|
||||||
|
# tmpfs upper). Idempotent. BENCH-VERIFY HARD — this turns app-allowlisting ON
|
||||||
|
# for the WHOLE root at runtime for the first time: boot + kiosk render + admin
|
||||||
|
# SSH + sudo + TPM enrolment must ALL still work (nothing legit is untrusted),
|
||||||
|
# a novel /tmp binary must now be BLOCKED (EPERM), and watch for new AVCs.
|
||||||
|
# enable_stig=false is the escape hatch if it regresses the boot/secure path.
|
||||||
|
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then",
|
||||||
|
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || sed -ri '/^watch_fs *=/ s/$/,overlay/' /etc/fapolicyd/fapolicyd.conf",
|
||||||
|
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || { echo 'ERROR: failed to add overlay to fapolicyd watch_fs' >&2; exit 1; }",
|
||||||
|
" echo '==> fapolicyd: watch_fs now includes overlay (guards the volatile-overlay root)'",
|
||||||
|
" fi",
|
||||||
"fi",
|
"fi",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1102,23 @@ build {
|
|||||||
# UKI initramfs shrinks too — see TODO #18. Nothing re-adds firmware after
|
# UKI initramfs shrinks too — see TODO #18. Nothing re-adds firmware after
|
||||||
# that point, so the rootfs erofs is already trimmed by the time we get here.)
|
# that point, so the rootfs erofs is already trimmed by the time we get here.)
|
||||||
#
|
#
|
||||||
|
# .pyc invalidation vs reproducible mtimes: the erofs is built `-T 0`, which
|
||||||
|
# zeroes every file's mtime. Python's DEFAULT .pyc invalidation is
|
||||||
|
# timestamp-based (the .pyc header stores the source's mtime), so at runtime
|
||||||
|
# every source .py looks newer than its baked .pyc and Python recompiles into
|
||||||
|
# the tmpfs overlay — wasting RAM and, under enforcing SELinux, throwing
|
||||||
|
# lib_t write/unlink AVCs (seen 2026-07-03: dnf/libdnf/rhsm __pycache__
|
||||||
|
# rewrites 10 min after boot). Recompile ALL bytecode with hash-based,
|
||||||
|
# UNCHECKED invalidation: the .pyc then carries a source hash flagged
|
||||||
|
# 'do not check', so Python loads it WITHOUT stat-ing the source, and never
|
||||||
|
# rewrites regardless of the `-T 0` mtimes. Bonus: hash .pyc embed no mtime,
|
||||||
|
# so they're more reproducible than timestamp .pyc (reinforces the -T 0
|
||||||
|
# invariant). MUST run LAST (after every dnf install/remove) so nothing lays
|
||||||
|
# down a fresh timestamp .pyc afterward; -f forces existing .pyc to this mode.
|
||||||
|
# Version-agnostic loop so an EL point-release python bump doesn't silently
|
||||||
|
# skip it. See docs/TODO.md #16.
|
||||||
|
"for d in /usr/lib/python3.* /usr/lib64/python3.*; do [ -d \"$d\" ] && python3 -m compileall -f -q --invalidation-mode unchecked-hash \"$d\" 2>/dev/null || true; done",
|
||||||
|
#
|
||||||
# ---- FINAL GUARD: assert the kiosk stack survived the whole pipeline ----
|
# ---- FINAL GUARD: assert the kiosk stack survived the whole pipeline ----
|
||||||
# 2026-07-03 incident: chromium installed cleanly in the 2b kiosk step (the
|
# 2026-07-03 incident: chromium installed cleanly in the 2b kiosk step (the
|
||||||
# footprint audit showed chromium-149 + chromium-common, 523 pkgs), but by
|
# footprint audit showed chromium-149 + chromium-common, 523 pkgs), but by
|
||||||
@@ -1055,7 +1132,9 @@ build {
|
|||||||
# kiosk-critical package is still installed at the very end of the pipeline
|
# kiosk-critical package is still installed at the very end of the pipeline
|
||||||
# (after remediation AND all 2d removes). This catches ANY future silent drop
|
# (after remediation AND all 2d removes). This catches ANY future silent drop
|
||||||
# — a new hard-Requires cascade, an oscap package_*_removed rule, autoremove.
|
# — a new hard-Requires cascade, an oscap package_*_removed rule, autoremove.
|
||||||
# enable_kiosk=false legitimately ships no browser, so gate on it.
|
# Run AFTER the .pyc recompile (read-only rpm -q, lays down no bytecode, so it
|
||||||
|
# doesn't violate the "recompile last" rule). enable_kiosk=false legitimately
|
||||||
|
# ships no browser, so gate on it.
|
||||||
"if [ \"${var.enable_kiosk}\" = \"true\" ] && [ -n \"${var.kiosk_url}\" ]; then for p in chromium chromium-common weston mesa-dri-drivers mesa-libEGL mesa-libgbm; do rpm -q \"$p\" >/dev/null 2>&1 || { echo \"ERROR: kiosk-critical package '$p' missing from the FINAL image — a later step removed it after the 2b kiosk install (e.g. a dnf remove whose dependency cascade took chromium, cf. the 2026-07-03 nss-mdns incident). Refusing to ship a bricked kiosk.\" >&2; exit 1; }; done; echo '==> guard: kiosk stack intact (chromium/weston/mesa present after remediation + hygiene)'; fi",
|
"if [ \"${var.enable_kiosk}\" = \"true\" ] && [ -n \"${var.kiosk_url}\" ]; then for p in chromium chromium-common weston mesa-dri-drivers mesa-libEGL mesa-libgbm; do rpm -q \"$p\" >/dev/null 2>&1 || { echo \"ERROR: kiosk-critical package '$p' missing from the FINAL image — a later step removed it after the 2b kiosk install (e.g. a dnf remove whose dependency cascade took chromium, cf. the 2026-07-03 nss-mdns incident). Refusing to ship a bricked kiosk.\" >&2; exit 1; }; done; echo '==> guard: kiosk stack intact (chromium/weston/mesa present after remediation + hygiene)'; fi",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-6
@@ -1037,7 +1037,7 @@ reasonable for a controlled fleet; the highest-assurance path is attended/firmwa
|
|||||||
or manufacturer pre-provisioning. Default recommendation: **separate provisioning
|
or manufacturer pre-provisioning. Default recommendation: **separate provisioning
|
||||||
UKI + isolated VLAN + read-back verify.**
|
UKI + isolated VLAN + read-back verify.**
|
||||||
|
|
||||||
## [ ] 11. (DESIGN) TPM 802.1X identity enrolment (TOFU + OOB fingerprint)
|
## [~] 11. (DESIGN) TPM 802.1X identity enrolment (TOFU + OOB fingerprint)
|
||||||
|
|
||||||
**Authoritative design consolidated 2026-06-28:
|
**Authoritative design consolidated 2026-06-28:
|
||||||
[tpm-enrolment-design.md](tpm-enrolment-design.md)** — unifies this item with the
|
[tpm-enrolment-design.md](tpm-enrolment-design.md)** — unifies this item with the
|
||||||
@@ -1062,7 +1062,9 @@ approve → cert). **BENCH-VERIFY:** run against real step-ca (`bootstrap.sh` ro
|
|||||||
`enrol` provisioner password readable by the broker) and confirm an issued cert
|
`enrol` provisioner password readable by the broker) and confirm an issued cert
|
||||||
authenticates EAP-TLS.
|
authenticates EAP-TLS.
|
||||||
|
|
||||||
**HOST SIDE BUILT 2026-07-02 — Go binary (`fleet-enrol`), awaiting bench.**
|
**HOST SIDE BUILT 2026-07-02 — Go binary (`fleet-enrol`); TEST-PATH ENROLMENT
|
||||||
|
BENCH-VERIFIED ON REAL HARDWARE (Dell 820, `rd.dot1x=0`, 2026-07-03).** Secure-path
|
||||||
|
(802.1X/MACsec) enrolment still to verify — see the NV-index bench finding below.
|
||||||
Pivoted from a shell hook to a static **Go + go-tpm** binary (`files/enrol/`):
|
Pivoted from a shell hook to a static **Go + go-tpm** binary (`files/enrol/`):
|
||||||
it talks to `/dev/tpmrm0` DIRECTLY (via `transport.FromReadWriter` on an
|
it talks to `/dev/tpmrm0` DIRECTLY (via `transport.FromReadWriter` on an
|
||||||
`os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment
|
`os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment
|
||||||
@@ -1157,7 +1159,7 @@ have their EK there), so enrolment could never create our key.
|
|||||||
(+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or
|
(+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or
|
||||||
BIOS "Clear TPM". Document in the bench README.
|
BIOS "Clear TPM". Document in the bench README.
|
||||||
|
|
||||||
**BENCH FINDING 2026-07-03 (FIXED) — NV index `0x01800001` collided with OEM
|
**BENCH FINDING 2026-07-03 (FIXED + BENCH-VERIFIED) — NV index `0x01800001` collided with OEM
|
||||||
platform NV; moved to `0x01800100`.** The Dell 820 kept "finding a cert" and
|
platform NV; moved to `0x01800100`.** The Dell 820 kept "finding a cert" and
|
||||||
bypassing enrolment even after repeated BIOS TPM clears. `tpm2_nvreadpublic`
|
bypassing enrolment even after repeated BIOS TPM clears. `tpm2_nvreadpublic`
|
||||||
showed the truth: `0x01800001` on that hardware is a **firmware/platform** index
|
showed the truth: `0x01800001` on that hardware is a **firmware/platform** index
|
||||||
@@ -1173,9 +1175,18 @@ through the cmdline (`extract-artifacts.sh`), `fleet-enrol -nvindex`, the dracut
|
|||||||
hooks, bench scripts, invariant #1, and docs. Also **hardened `WriteNVCert`** to
|
hooks, bench scripts, invariant #1, and docs. Also **hardened `WriteNVCert`** to
|
||||||
refuse a `platformcreate` index with a clear "pick a free owner index" error
|
refuse a `platformcreate` index with a clear "pick a free owner index" error
|
||||||
instead of a confusing undefine failure (regression `simtest/
|
instead of a confusing undefine failure (regression `simtest/
|
||||||
TestWriteNVCertRefusesPlatformIndex`). **BENCH-VERIFY:** 820 now enrols at
|
TestWriteNVCertRefusesPlatformIndex`). **BENCH-VERIFIED 2026-07-03 — the Dell 820
|
||||||
`0x01800100`; and check `tpm2_nvreadpublic` on any OTHER hardware model in the
|
enrols end-to-end at `0x01800100`.** First success on REAL TPM hardware (past the
|
||||||
fleet — if a vendor also squats `0x018001xx`, pick a value free on all of them.
|
un-clearable OEM platform-NV at `0x01800001`): validates BOTH the NV-index move
|
||||||
|
AND the `0x81000100` key-handle move on a physical TPM, not just the Proxmox
|
||||||
|
swtpm — the full host ceremony (ensure ECDSA key → CSR → broker fingerprint →
|
||||||
|
approve → leaf cert → NV write) runs on the metal. This was the hard case (the
|
||||||
|
820 previously "found a cert" and silently bypassed enrolment every boot). **STILL
|
||||||
|
BENCH-VERIFY:** (a) `tpm2_nvreadpublic` on any OTHER hardware model in the fleet —
|
||||||
|
if a vendor also squats `0x018001xx`, pick a value free on all of them; (b) the
|
||||||
|
enrolled cert actually **authenticates EAP-TLS on the secure path** (this run was
|
||||||
|
test-mode `rd.dot1x=0`; 802.1X/MACsec enrolment still unverified — see the
|
||||||
|
test-mode note above).
|
||||||
|
|
||||||
**BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM +
|
**BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM +
|
||||||
swtpm on the target (the simulator proves the ceremony, not the device) — **the
|
swtpm on the target (the simulator proves the ceremony, not the device) — **the
|
||||||
@@ -1547,6 +1558,15 @@ tailoring; retires deviation-register #4).
|
|||||||
unchecked-hash` over the Python trees BEFORE the erofs build → mtime-independent
|
unchecked-hash` over the Python trees BEFORE the erofs build → mtime-independent
|
||||||
`.pyc`, no rewrites, and MORE reproducible. (Worth doing regardless of firewalld —
|
`.pyc`, no rewrites, and MORE reproducible. (Worth doing regardless of firewalld —
|
||||||
it's the true root cause of the firewalld AVCs seen 2026-07-03.)
|
it's the true root cause of the firewalld AVCs seen 2026-07-03.)
|
||||||
|
**[x] DONE (2026-07-03):** implemented in `diskless.pkr.hcl` — (a) the compileall
|
||||||
|
`--invalidation-mode unchecked-hash` pass over `/usr/lib*/python3.*` at the end of the
|
||||||
|
2d hygiene block (last step before erofs extraction), and (b) masked
|
||||||
|
`dnf-makecache.timer` next to the existing `rhsmcertd` mask (it was the observed ~10-min
|
||||||
|
post-boot trigger that re-imported the dnf Python stack). Confirmed live on a booted host
|
||||||
|
via the overlay audit: `dnf/libdnf/librepo/rhsm` `__pycache__` rewritten at boot+10min.
|
||||||
|
**BENCH-VERIFY:** after a rebuild, no more `.pyc` files appear in the overlay
|
||||||
|
(`find / -xdev -newermt '1980-01-01' -path '*__pycache__*'` empty) and the `lib_t`
|
||||||
|
`.pyc` AVCs stop in the SIEM. Items 1 & 3 (firewalld enablement + tailoring) still OPEN.
|
||||||
3. **Tailoring:** re-select the 4 firewalld rules.
|
3. **Tailoring:** re-select the 4 firewalld rules.
|
||||||
|
|
||||||
**THE load-bearing bench gate (why #4 deferred it):** the NVMe/TCP root is an OUTBOUND
|
**THE load-bearing bench gate (why #4 deferred it):** the NVMe/TCP root is an OUTBOUND
|
||||||
@@ -1556,6 +1576,47 @@ default → should, but UNVERIFIED); a reconnect within `ctrl_loss_tmo` re-estab
|
|||||||
*through* the firewall; DHCP renew + MKA keepalives unaffected; SSH in; no AVCs; verity
|
*through* the firewall; DHCP renew + MKA keepalives unaffected; SSH in; no AVCs; verity
|
||||||
hash reproducible. If it drops the root, that is the fleet-wide outage risk — gate hard.
|
hash reproducible. If it drops the root, that is the fleet-wide outage risk — gate hard.
|
||||||
|
|
||||||
|
## [~] 17. Cap fapolicyd trust-DB RAM (`db_max_size`) — biggest overlay consumer
|
||||||
|
|
||||||
|
**Finding (overlay RAM audit, 2026-07-03, host `cygnus-cruiser-99`):** the writable root
|
||||||
|
is `systemd.volatile=overlay` (a tmpfs). Enumerating runtime-written files via the `-T 0`
|
||||||
|
trick — every baked file has mtime = epoch, so anything newer is an overlay write:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo find / -xdev -type f -newermt '1980-01-01' -printf '%b\n' | awk '{s+=$1*512} END{print s/1048576" MiB"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
showed **124.5 MiB** total, of which **`/var/lib/fapolicyd/data.mdb` = 100 MiB, FULLY
|
||||||
|
allocated** (`du` == `du --apparent-size`, not sparse) — **~80% of the entire overlay.**
|
||||||
|
fapolicyd's LMDB trust DB is rebuilt at boot onto the tmpfs overlay, so its `db_max_size`
|
||||||
|
(MB, in `/etc/fapolicyd/fapolicyd.conf`) is a **fixed RAM reservation**, not a ceiling it
|
||||||
|
grows into. The EL9 default is ~100; the minimal+kiosk trust set is a fraction of that.
|
||||||
|
|
||||||
|
**[x] DONE (2026-07-03):** `diskless.pkr.hcl` 2c-STIG-postfix now seds
|
||||||
|
`db_max_size = 50` into `fapolicyd.conf` BEFORE the `fapolicyd-cli --update` rebuild (so
|
||||||
|
the rebuilt db uses the smaller map). Value 50 chosen conservatively — it was fapolicyd's
|
||||||
|
upstream default for years and covered a FULL workstation rpmdb (larger than this image),
|
||||||
|
so it has real headroom. Reclaims ~50 MiB of overlay RAM.
|
||||||
|
|
||||||
|
**[ ] BENCH-VERIFY (STIG-critical — fapolicyd is the writable-overlay exec guard):**
|
||||||
|
1. Boot the rebuilt image; confirm kiosk renders, admin SSH (ECDSA) logs in, `sudo`
|
||||||
|
works, and custom binaries (`kiosk-session`, `fleet-hostapi`, `fleet-enrol`) still
|
||||||
|
execute — i.e. the trust DB rebuilt cleanly at 50 MiB with no `MDB_MAP_FULL`.
|
||||||
|
Undersizing bricks exec (login/kiosk) — that's the hard gate.
|
||||||
|
2. Re-run the overlay audit: `data.mdb` should now be ~50 MiB and total overlay ~75 MiB.
|
||||||
|
3. **Can go lower:** measure the real trust set with `fapolicyd-cli --dump | wc -l` on the
|
||||||
|
booted host; if the content is only ~15–25 MiB, 40 (or less) is safe and reclaims more.
|
||||||
|
|
||||||
|
**Secondary lever (free, already in flight):** the trust DB scales with trusted files, so
|
||||||
|
the kiosk weak-deps trim (commit `a193dc3`, ~100 fewer packages) shrinks the *actual*
|
||||||
|
`data.mdb` content — widening the headroom under `db_max_size`.
|
||||||
|
|
||||||
|
**Other overlay consumers noted (not fapolicyd, for context):** Chromium `/dev/shm`
|
||||||
|
(~38 MiB, its own tmpfs) + kiosk `/tmp/kiosk-profile` (on the root overlay here — `/tmp`
|
||||||
|
is NOT a separate tmpfs, ties to #15's mount separation); `/var/log/{messages,audit}`
|
||||||
|
grow unbounded in RAM (audit is local-only) — logrotate caps or SIEM offload on
|
||||||
|
long-lived hosts.
|
||||||
|
|
||||||
## [~] 18. Slim the image — locale + firmware (biggest erofs-size wins)
|
## [~] 18. Slim the image — locale + firmware (biggest erofs-size wins)
|
||||||
|
|
||||||
**Finding (image-size audit via the 2b `rpm -qa --qf '%{SIZE}'` manifest, 2026-07-03):**
|
**Finding (image-size audit via the 2b `rpm -qa --qf '%{SIZE}'` manifest, 2026-07-03):**
|
||||||
|
|||||||
@@ -167,6 +167,87 @@ re-check the verity hash is stable across two builds (same concern as the
|
|||||||
(the module install lives in the STIG-gated provisioner); a non-STIG *enforcing*
|
(the module install lives in the STIG-gated provisioner); a non-STIG *enforcing*
|
||||||
build would still see the AVC.
|
build would still see the AVC.
|
||||||
|
|
||||||
|
## 15. Local SELinux policy module `fapolicyd_tmpfs` (added 2026-07-03)
|
||||||
|
|
||||||
|
Not a STIG-rule waiver — a **minimal local `allow` module** shipped to close an
|
||||||
|
**exec-guard gap** under enforcing SELinux. Source: `files/stig/fapolicyd-tmpfs.te`;
|
||||||
|
built + `semodule -i`'d in the 2c-STIG provisioner (right after #14).
|
||||||
|
|
||||||
|
**The one rule:** `allow fapolicyd_t tmpfs_t:filesystem getattr;`
|
||||||
|
|
||||||
|
**Why:** fapolicyd is the app-allowlisting **exec guard for the writable RAM
|
||||||
|
overlay** — dm-verity covers only the read-only erofs lower, so a binary dropped
|
||||||
|
into the tmpfs overlay upper is exactly what fapolicyd must catch (this is why we
|
||||||
|
KEEP fapolicyd; see "Things that are NOT deviations"). fapolicyd `statfs`'s mounts
|
||||||
|
to decide which to watch, and **tmpfs is in its default `watch_fs`**. This image's
|
||||||
|
root is `systemd.volatile=overlay`, so `statfs("/")` returns the overlay **upper**
|
||||||
|
— a tmpfs (audit: `dev=tmpfs ino=1 name="/"`, `fapolicyd_t → tmpfs_t:filesystem
|
||||||
|
getattr`, `permissive=0`, seen 2026-07-03). The shipped policy has no such rule
|
||||||
|
(no ordinary host has its root on tmpfs), so under enforcing the `getattr` is
|
||||||
|
**denied**, fapolicyd cannot fully mark the tmpfs mounts (`/`, `/tmp`, `/dev/shm`),
|
||||||
|
and its guard over the writable overlay is **incomplete** — a real gap, not just
|
||||||
|
SIEM noise. An **`allow`** (not `dontaudit`): we *want* fapolicyd to see the tmpfs.
|
||||||
|
|
||||||
|
**Scope:** one perm (`getattr`) on one class (`filesystem`), two well-known types.
|
||||||
|
Grants no file read/write, only the `statfs` fapolicyd needs to classify + watch
|
||||||
|
the mount. **BENCH-VERIFY:** (a) the AVC is gone on an enforcing boot; (b) fapolicyd
|
||||||
|
**blocks a non-RPM executable dropped in `/tmp`** (proves the guard now covers the
|
||||||
|
overlay tmpfs) while the trusted kiosk/appliance binaries still run; (c)
|
||||||
|
**reproducibility** — `semodule -i` rebuilds the policy store, so re-check verity
|
||||||
|
hash stability across two builds (same concern as #12/#14). NB: only shipped when
|
||||||
|
`enable_stig=true`; a non-STIG enforcing build would still see the AVC and the gap.
|
||||||
|
|
||||||
|
**UPDATE 2026-07-03 — the getattr allow alone did NOT close the exec gap; the real
|
||||||
|
enabler is `watch_fs`.** Bench test on `comet-beacon-54`: with the `.te` installed
|
||||||
|
(AVC gone, `fapolicyd active`, `permissive=0`), a **novel** non-RPM executable in
|
||||||
|
`/tmp` (hash NOT in the trust db) still **ran** (`exit=0`). Root cause: the diskless
|
||||||
|
root is **overlayfs** (`stat -f /` → `overlayfs`, `/tmp` too), but the shipped
|
||||||
|
`watch_fs = ext2,ext3,ext4,tmpfs,xfs,vfat,iso9660,btrfs` has **no `overlay`**.
|
||||||
|
fapolicyd marks mounts by fs type, so it never marks the overlay = it intercepts
|
||||||
|
**no exec on `/`** — the whole root, `/usr` and `/tmp` alike. So the exec guard was
|
||||||
|
effectively **OFF at runtime** on every diskless host. Fix: the 2c-STIG fapolicyd
|
||||||
|
step now appends **`overlay` to `watch_fs`** in `fapolicyd.conf` (idempotent) so
|
||||||
|
fapolicyd watches the overlay root; trust is path-based (`integrity=none`) so overlay
|
||||||
|
paths resolve against the rpmdb trust normally. The `fapolicyd_tmpfs` `.te` above is
|
||||||
|
still needed — it supports fapolicyd statfs'ing the overlay's tmpfs upper. **This
|
||||||
|
turns app-allowlisting ON for the whole root at runtime for the first time**, so
|
||||||
|
**BENCH-VERIFY HARD:** boot + kiosk render + admin SSH + sudo + TPM enrolment all
|
||||||
|
still work (nothing legit is untrusted — the 4 custom binaries + rpmdb are trusted),
|
||||||
|
a novel `/tmp` binary is now **blocked** (EPERM), and no new AVCs (extend the `.te`
|
||||||
|
if any). `enable_stig=false` is the escape hatch if it regresses the boot/secure path.
|
||||||
|
|
||||||
|
**VERIFIED 2026-07-04 (build `fapolicyd-tmpfs-12043d3`, host `comet-beacon-54`) —
|
||||||
|
enforcing, no false positives.** `fapolicyd active (running)`, `permissive = 0`,
|
||||||
|
`watch_fs = ...,overlay` (the fix took), `stat -f / /tmp` → both `overlayfs` (so the
|
||||||
|
test hits the overlay rule, not the pre-existing tmpfs one). A novel-hash binary
|
||||||
|
(`cp /bin/true /tmp/n; printf '\0' >>/tmp/n`) was **denied**: `bash: /tmp/n: Operation
|
||||||
|
not permitted`, `exit=126`, and the audit record confirms fapolicyd did it —
|
||||||
|
`type=FANOTIFY ... resp=2` (FAN_DENY) `obj_trust=0` (target not in trust db). The
|
||||||
|
**deny sweep was empty** (`journalctl -t fapolicyd -b | grep dec=deny` → nothing),
|
||||||
|
i.e. **zero false denials**: kiosk-session/weston/chromium + the NM dispatcher scripts
|
||||||
|
all ran trusted; admin SSH + `sudo` worked under `staff_u:staff_r:staff_t`. So the
|
||||||
|
exec guard went from **effectively OFF** (pre-fix, overlay unwatched) to **enforcing
|
||||||
|
on the whole overlay root** with nothing legit broken. My NM-dispatcher false-denial
|
||||||
|
worry did not materialise.
|
||||||
|
|
||||||
|
**Scope caveat — this closes the UNTRUSTED-PATH half, not the MODIFIED-TRUSTED-PATH
|
||||||
|
half.** With `integrity = none`, trust is by **path**, so watching the overlay blocks
|
||||||
|
a *new* file at an *untrusted* path (`/tmp/n`) — proven above. It does **not** catch a
|
||||||
|
*trusted* path whose **content** was swapped: writing to e.g. `/usr/bin/bash` triggers
|
||||||
|
an overlayfs **copy-up** into the tmpfs upper — same path, new inode, **outside
|
||||||
|
dm-verity's coverage** (verity only protects the erofs lower, which is now shadowed) —
|
||||||
|
and the path-based rule still trusts it. Closing that residual gap needs either
|
||||||
|
`integrity = sha256` (fapolicyd re-hashes at exec, content ≠ trust-db hash → deny; the
|
||||||
|
rpmdb backend already carries SHA-256 digests, so it's a one-line `fapolicyd.conf`
|
||||||
|
change — cost is per-exec userspace hashing, partly redundant with verity on the
|
||||||
|
un-tampered lower) **or** an **un-shadowable `/usr`** so copy-up can't happen at all
|
||||||
|
(read-only mount → write fails EROFS, no tmpfs copy is ever created; path-trust becomes
|
||||||
|
sound again). The latter is tracked as **TODO #15** ("scope root overlay to per-subtree
|
||||||
|
tmpfs — un-shadowable `/usr`") and is the preferred fix since it removes the attack
|
||||||
|
surface rather than detecting abuse of it. Requires a write primitive to a root-owned
|
||||||
|
path either way (already-privileged context), which is fapolicyd's defense-in-depth
|
||||||
|
threat model — not an unprivileged bypass.
|
||||||
|
|
||||||
## Things that are NOT deviations (kept / enforced)
|
## Things that are NOT deviations (kept / enforced)
|
||||||
|
|
||||||
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Raw module-policy syntax (compiled with `checkmodule -M -m`), NOT the refpolicy
|
||||||
|
# `policy_module()` m4 macro — that macro is only expanded by the refpolicy
|
||||||
|
# Makefile and checkmodule rejects it. See chrony-dhcp-dispatcher.te for the why.
|
||||||
|
module fapolicyd_tmpfs 1.0.0;
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# Let fapolicyd statfs (getattr) tmpfs filesystems.
|
||||||
|
#
|
||||||
|
# fapolicyd is the app-allowlisting EXEC GUARD for the writable RAM overlay:
|
||||||
|
# dm-verity covers only the read-only erofs lower, so an executable dropped into
|
||||||
|
# the tmpfs overlay upper is exactly what fapolicyd must catch. fapolicyd statfs's
|
||||||
|
# mounts to decide which to watch, and tmpfs is in its default watch_fs. This
|
||||||
|
# host's root is `systemd.volatile=overlay`, so statfs("/") returns the overlay
|
||||||
|
# UPPER — a tmpfs (the audit shows dev=tmpfs ino=1, name="/") — and the shipped
|
||||||
|
# policy has no `fapolicyd_t tmpfs_t:filesystem getattr` rule (no normal host has
|
||||||
|
# its root on tmpfs). Result under enforcing: the getattr is DENIED, fapolicyd
|
||||||
|
# cannot fully mark the tmpfs mounts (/, /tmp, /dev/shm), and its guard over the
|
||||||
|
# writable overlay is incomplete — a real exec-guard gap, not just SIEM noise.
|
||||||
|
# This single allow closes it. Minimal + auditable; justified in
|
||||||
|
# docs/stig-deviations.md. BENCH-VERIFY: after this, fapolicyd blocks a non-RPM
|
||||||
|
# executable dropped in /tmp (the overlay). If a follow-on fapolicyd AVC on the
|
||||||
|
# overlay appears on the bench, extend here.
|
||||||
|
########################################
|
||||||
|
|
||||||
|
require {
|
||||||
|
type fapolicyd_t;
|
||||||
|
type tmpfs_t;
|
||||||
|
class filesystem getattr;
|
||||||
|
}
|
||||||
|
|
||||||
|
allow fapolicyd_t tmpfs_t:filesystem getattr;
|
||||||
Reference in New Issue
Block a user