Merge pull request 'fapolicyd-tmpfs' (#16) from fapolicyd-tmpfs into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/16
This commit is contained in:
@@ -809,6 +809,21 @@ build {
|
||||
"semodule -i /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; }",
|
||||
# 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",
|
||||
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
|
||||
]
|
||||
@@ -874,6 +889,25 @@ build {
|
||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||
" fapolicyd-cli --update 2>/dev/null || true",
|
||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + 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",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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*
|
||||
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)
|
||||
|
||||
- **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