The per-user rule elevated fine at runtime (bench-verified staff_u->sysadm_r on comet-beacon-54) but scanned fail: the OVAL patterns are ^\s*%\w+.*TYPE=/ROLE=, so only a %group rule can pass. Write the drop-in as %wheel (the admin is a wheel member; same file 2c wrote, so no unconfined per-user rule survives).
367 lines
30 KiB
Markdown
367 lines
30 KiB
Markdown
# 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; offload updated 2026-07-12):
|
||
- **FIPS: OFF** — revisit alongside UKI signing / Secure Boot.
|
||
- **Audit offload: REMOTE over syslog-TLS** — audisp-syslog + rsyslog `omfwd`
|
||
(gtls :6514, fleet-CA x509/name) to `serving/audit-collector` (see row 6;
|
||
was LOCAL-only until 2026-06-19, plaintext until 2026-07-12).
|
||
- **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`) | Offload is done via **rsyslog over TLS** rather than the `audisp-remote` native protocol (simpler, container-friendly receiver), so the audisp-remote rules + `rsyslog_remote_loghost` (whose remediation would inject a bogus loghost) stay waived — the *intent* of all four is met by the rsyslog path. **The three `rsyslog_encrypt_offload_*` rules are NO LONGER waived** (re-selected 2026-07-12): the offload is encrypted end-to-end and all three **pass** in the scan of record (build `poam-hardening-5bc6d27`; rsyslog+rsyslog-gnutls installed before the scan; remediation writes the gtls/x509 globals). Bench-verified same day incl. evil-twin rejection + lossless spool drain (TODO #8). | **Wired both ends, TLS.** Sender (image, provisioner 2f, `audit_host`): audisp-syslog → LOCAL6 + rsyslog `omfwd` over **gtls :6514**, server verified `x509/name` against the baked fleet CA (`/etc/dot1x/radius-ca.pem`, `var.fleet_ca`), disk-assisted queue → host-generic `audit.fleet.lab`. Receiver: `serving/audit-collector/` (gtls listener :6514, server cert from the fleet step-ca via `issue-cert.sh`; legacy plaintext :514 kept only for pre-TLS images during migration — close it once the fleet is on TLS images). No client certs: hosts are fleet-generic (invariant #1 — a baked fleet-wide client key would be shared secret material), and client mTLS with the per-host **TPM identity was considered + deferred 2026-07-12** (redundant with 802.1X/MACsec on the secure path, which authenticates the same TPM credential at L2; rsyslog can't use a non-exportable TPM key without a delegated-cert or PKCS#11-tunnel contraption; hard-failing the forensic safety net on client-auth errors darkens exactly the hosts whose logs matter most — full rationale + revisit trigger in `serving/audit-collector/README.md`). Sender access constrained by the isolated boot LAN + serving-host firewall. 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. |
|
||
| 10 | **Password aging / faillock on the key-only APPLIANCE ACCOUNTS** (`accounts_*_age_*`, `account_disable_post_pw_expiration`, `pam_faillock` account phase) — applied to the *system* but **exempted for the `fleetadmin` and `kiosk` users** | These two accounts are **passwordless / key-only**. The hardened PAM *account* phase (faillock + password aging/inactivity) treats a passwordless account as expired/lockable, which **locked the fleet out**: the kiosk autologin session never opened (getty kept tty1) and sshd denied the valid key with `Permission denied (publickey)` (pubkey auth succeeds, then `pam_acct_mgmt` fails). Fixed 2026-06-18. | The exemption is **scoped to these two appliance accounts only** (post-STIG `chage -M -1 -m 0 -I -1 -E -1` + `faillock --reset`; kiosk uses a dedicated `/etc/pam.d/kiosk`; sshd gets an `account sufficient pam_succeed_if.so user = fleetadmin` bypass as its first account rule). Every other account keeps the full STIG password policy + faillock. These accounts have **no password to age** and (key-only, `PasswordAuthentication no`) **no password-auth path for faillock to protect**. Folds into the per-host/rotatable access model (SSH CA / config-agent). |
|
||
|
||
| 11 | **Login banner content** (`banner_etc_issue`, `banner_etc_issue_net`, `banner_etc_motd`) | STIG requires the verbatim DoD/USG "you are accessing a U.S. Government Information System" text. This is a fictional starship fleet, not a USG system, so `/etc/issue{,.net}` + `/etc/motd` carry the fleet (ISV AURELIAN) notice instead (provisioner step 2e, `files/banner/fleet-issue`). The content rules therefore fail by design. | The banner **keeps its security function**: it still states authorized-crew-only, that the terminal is monitored/logged, and that unauthorized access is prohibited — only the wording is themed. `sshd_enable_warning_banner` (Banner is shown) still passes. |
|
||
|
||
## 12. Report triage 2026-06-19 (scan `app-9f7d8d1`)
|
||
|
||
Triage of the 21 REVIEW rows from the post-fix hardened scan (398 pass / 12 fail
|
||
/ 9 notchecked — a valid scan). All but one are dispositioned below; the rule IDs
|
||
are unselected in `diskless-tailoring.xml` (or made to pass / N/A) so they no
|
||
longer surface as REVIEW. **Remaining open: `selinux_context_elevation_for_sudo`**
|
||
(see end).
|
||
|
||
**Waived — covered by an existing register entry (Bucket A):**
|
||
- `encrypt_partitions`, `auditd_audispd_configure_sufficiently_large_partition` —
|
||
single erofs+tmpfs root, no partitions (#1).
|
||
- `grub2_password` — UKI, no GRUB (#2).
|
||
- `sysctl_crypto_fips_enabled`, `harden_sshd_macs_openssh_conf_crypto_policy`,
|
||
`harden_sshd_macs_opensshserver_conf_crypto_policy` — FIPS off (#5).
|
||
- `configured_firewalld_default_deny` — firewall off for the lab boot LAN (#4).
|
||
- `sssd_enable_certmap`, `sssd_has_trust_anchor` — no SSSD/PKI/smartcard; local
|
||
key-only host, no domain join (#7).
|
||
|
||
**Waived — architecturally absent, N/A (Bucket B):** `account_temp_expire_date`
|
||
(no temporary accounts), `accounts_user_home_paths_only` (appliance accounts,
|
||
fixed home layout), `libreswan_approved_tunnels` (no IPsec/libreswan),
|
||
`security_patches_up_to_date` (rebuild-from-current model — a scanner can't assess
|
||
patch currency against a future rebuild), `ssh_keys_passphrase_protected` (no
|
||
private keys stored on the host; host keys regenerate per boot).
|
||
|
||
**Waived — decisions (Bucket C):**
|
||
- `installed_OS_is_vendor_supported` — Rocky **is** vendor-supported; the rule
|
||
fails only because the RHEL datastream's CPE checks for an RHEL subscription.
|
||
- `network_configure_name_resolution` — single DHCP-provided resolver by design
|
||
on the boot LAN; the multi-DNS requirement is N/A to the netboot model.
|
||
- `sysctl_user_max_user_namespaces_no_remediation` — **KEEP user namespaces
|
||
enabled**: the Chromium kiosk sandbox requires them. Disabling userns would
|
||
break the kiosk.
|
||
- `accounts_authorized_local_users` — the authorized-users check doesn't know the
|
||
key-only appliance accounts (`fleetadmin`, `kiosk`); they are authorized (see
|
||
#10). *Production note: prefer setting the SSG authorized-users regex variable
|
||
to assert these two explicitly rather than waiving the whole check.*
|
||
- `package_postfix_installed` — **postfix removed** (provisioner 2c-STIG): the
|
||
appliance sends no mail and ships audit off-box via rsyslog (2f), so a local
|
||
MTA is dead attack surface. This also makes `postfix_prevent_unrestricted_relay`
|
||
N/A. Audit-failure notification relies on the remote offload, not email
|
||
(`auditd ... action_mail_acct` stays configured but is not the alerting path).
|
||
- `auditd_data_retention_space_left_action` — **consequence of removing postfix.**
|
||
STIG sets `space_left_action = email`, but with no MTA auditd logs *"Email
|
||
option is specified but /usr/lib/sendmail doesn't seem executable"* at every
|
||
boot and can't send. 2c-STIG repoints **`space_left_action = SYSLOG`** so the
|
||
75%-full alert rides the remote audit offload (2f) instead; this rule wants
|
||
`email`, so it's waived. `admin_space_left_action` stays `single` (the critical
|
||
action, unaffected) and `action_mail_acct = root` stays set.
|
||
|
||
**Remediated to pass (not waived):**
|
||
- `configure_usbguard_auditbackend` + `usbguard_allow_hid` / `usbguard_allow_hub`
|
||
/ `usbguard_allow_hid_and_hub` — **decision: ENABLE USBGuard with enforcement.**
|
||
usbguard was already installed + enabled, but its policy is generated on the
|
||
build VM (no real USB), so under the default **block** target a physical console
|
||
keyboard/mouse would be blocked. The provisioner now sets
|
||
`AuditBackend=LinuxAudit` and writes a **hardware-independent HID + hub
|
||
allowlist** (`allow with-interface match-all { 03:*:* }`, `{ 09:00:* }`, and the
|
||
composite `{ 03:*:* 09:00:* }`) so console input survives while mass storage and
|
||
other device classes are blocked. **⚠ BENCH-VERIFY physical console input on
|
||
real hardware** — if a platform's input device doesn't match the HID/hub
|
||
classes, fall back to a per-platform allow rule or re-waive
|
||
`configure_usbguard_auditbackend`.
|
||
|
||
**Remediated with confinement (decision 2026-06-20) — `selinux_context_elevation_for_sudo`:**
|
||
SSG ships no automated fix; the STIG manual remediation
|
||
(`... TYPE=sysadm_t ROLE=sysadm_r ...`) requires the admin to reach the
|
||
`sysadm_r` role, which `unconfined_u` cannot. So the provisioner (2c-STIG) now
|
||
(1) rewrites the admin sudoers drop-in to `%wheel ALL=(ALL) TYPE=sysadm_t
|
||
ROLE=sysadm_r NOPASSWD:ALL` (keeps NOPASSWD — the admin is key-only, #8), and
|
||
(2) maps the admin to the confined **`staff_u`** SELinux user (`semanage login`)
|
||
so the `staff_r`→`sysadm_r` sudo transition is authorized at runtime. `staff_u`
|
||
(not `sysadm_u`) is used so SSH login isn't gated by the `ssh_sysadm_login`
|
||
boolean. **Scan-vs-runtime finding (2026-07-12, scan `poam-hardening-5bc6d27`):**
|
||
the first cut wrote a per-USER rule (`<admin> ALL=...`), which elevates fine at
|
||
runtime (bench host `comet-beacon-54`: `sudo id` →
|
||
`staff_u:sysadm_r:sysadm_t`) but the rule still scanned **fail** — the SSG OVAL
|
||
patterns (`^\s*%\w+.*TYPE=(\w+)` / `...ROLE=(\w+)`) only match **%group**
|
||
rules. Fixed by writing the rule for `%wheel` (the admin is a wheel member);
|
||
next scan should pass. **Bench status:** (a) SSH login + sudo under `staff_u`
|
||
confinement VERIFIED 2026-07-12 (re-check after the %wheel change — behaviour
|
||
should be identical, the rule targets the same account via its group); (b)
|
||
reproducibility of the verity hash across two builds (semanage rebuilds the
|
||
policy store) — still open.
|
||
|
||
## 13. Ctrl+Alt+Del console reboot hotkey kept enabled (decision 2026-06-20)
|
||
|
||
The RHEL 9 STIG disables the console reboot hotkey via two rules, both **waived**
|
||
in the tailoring:
|
||
- `disable_ctrlaltdel_reboot` — masks `ctrl-alt-del.target` (single Ctrl+Alt+Del).
|
||
- `disable_ctrlaltdel_burstaction` — sets `CtrlAltDelBurstAction=none` in
|
||
`/etc/systemd/system.conf` (the rapid 7× burst reboot).
|
||
|
||
**Why waived:** the STIG intent is to stop an **unauthenticated console user from
|
||
causing an unplanned reboot/outage**. On this fleet that risk is structurally
|
||
absent — the hosts are **diskless and stateless** (immutable erofs+dm-verity root,
|
||
tmpfs volatile overlay), so a console reboot loses nothing and persists nothing,
|
||
and a **field technician needs to recover a wedged kiosk from the keyboard when
|
||
SSH is unavailable**. The two rules are unselected so `--remediate` leaves the
|
||
systemd default (the hotkey reboots).
|
||
|
||
**Compensating control / scope:** the hotkey only triggers a **clean stateless
|
||
reboot** — it cannot exfiltrate data, alter the read-only root, or bypass auth to
|
||
a shell. Authenticated reboot paths (`systemctl reboot` over the key-only admin
|
||
SSH session) are unaffected either way. The console itself is otherwise locked
|
||
down (kiosk autologin to a single locked browser; no getty shell on tty1;
|
||
the `debug_shell` back-door was removed from the template entirely, 2026-07-11).
|
||
If a future deployment wants the
|
||
hotkey disabled (e.g. a physically exposed terminal), re-select both rule IDs.
|
||
|
||
## 14. Local SELinux policy module `chrony_dhcp_dispatcher` (added 2026-07-03)
|
||
|
||
Not a STIG-rule waiver — a **minimal local `allow` module** shipped to keep a
|
||
legitimate function working under enforcing SELinux. Source:
|
||
`files/stig/chrony-dhcp-dispatcher.te`; built + `semodule -i`'d in the 2c-STIG
|
||
provisioner.
|
||
|
||
**The one rule:** `allow chronyd_t NetworkManager_dispatcher_custom_t:unix_dgram_socket sendto;`
|
||
|
||
**Why:** the NM dispatcher script `10-chrony-dhcp-ntp` loads DHCP option-42 NTP
|
||
servers by running `chronyc reload sources`. That `chronyc` runs in the confined
|
||
custom-dispatcher domain (`NetworkManager_dispatcher_custom_t`), so its reply
|
||
socket carries that label; the shipped policy has no rule for `chronyd_t` to
|
||
`sendto` it. Under enforcing, chronyd processes the reload but its **ack is
|
||
denied** — `chronyc` reports failure and the AVC floods the SIEM (seen
|
||
2026-07-03). An **`allow`** (not `dontaudit`) is used deliberately: it both stops
|
||
the noise and lets the reload ack succeed, so `chronyc` returns cleanly.
|
||
|
||
**Scope:** one direction, one class/perm, two well-known types. It grants no
|
||
network/file access and nothing beyond the chronyd↔dispatcher reply the DHCP-NTP
|
||
path needs. **BENCH-VERIFY:** (a) on an *enforcing* boot the AVC is gone and
|
||
`chronyc reload sources` from the dispatcher returns 0 (confirm a DHCP-handed NTP
|
||
server appears in `chronyc sources`); (b) no follow-on AVC (extend the `.te` if
|
||
one shows); (c) **reproducibility** — `semodule -i` rebuilds the policy store, so
|
||
re-check the verity hash is stable across two builds (same concern as the
|
||
`semanage` step, #12/sudo elevation). NB: only shipped when `enable_stig=true`
|
||
(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). If it regresses the boot/secure path, waive the offending rule in the
|
||
tailoring as a justified deviation — STIG is enforced-true (no `enable_stig=false`
|
||
escape hatch, POA&M P-03).
|
||
|
||
**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.
|
||
|
||
## 16. Local SELinux policy module `fleet_agent_chronylog` (added 2026-07-10)
|
||
|
||
Not a STIG-rule waiver — a **minimal local read-only `allow` module** so the
|
||
`fleet-agent` telemetry client can read chrony's tracking log under enforcing
|
||
SELinux. Source: `files/stig/fleet-agent-chronylog.te`; built + `semodule -i`'d in
|
||
the 2c-STIG provisioner (after #15).
|
||
|
||
**The rules:** `allow unconfined_service_t chronyd_var_log_t:file { open read
|
||
getattr };` (+ `dir { search getattr open }` to reach the file).
|
||
|
||
**Why:** `fleet-agent` reports clock health (source/stratum/offset) by reading
|
||
`/var/log/chrony/tracking.log` — deliberately a FILE read rather than a query to
|
||
chronyd's command interface, which the hardened agent can't use (owner-write-only
|
||
unix socket; STIG's `cmdport 0` closes the UDP port). But chrony's log dir + files
|
||
are labeled `chronyd_var_log_t`, which the shipped policy confines to `chronyd_t` +
|
||
log-admin roles. So although the file is mode `0644`, under enforcing the agent
|
||
(`unconfined_service_t`) is **denied** by type enforcement — DAC allows, TE does
|
||
not. Verified 2026-07-10 on `comet-beacon-54`: with SELinux **permissive** a
|
||
non-root read of `tracking.log` succeeds; flip to **enforcing** and even a
|
||
STIG-confined admin (`staff_t`) gets `Permission denied` (`ls -Z` on the file is
|
||
itself blocked) — the signature of a type-enforcement block, not a DAC one.
|
||
|
||
**Scope:** read-only (`open`/`read`/`getattr`) on one log type + dir search to
|
||
reach it. No write, no other chrony access, and — unlike the earlier abandoned
|
||
approach — **no domain transition** (the agent reads a file; it does not exec
|
||
`chronyc`). This replaces the never-shipped `fleet_agent_chronyc` nnp-transition
|
||
module (that whole command-socket path was dropped for reading the log instead).
|
||
|
||
**This is the SELinux half of a two-layer requirement.** `/var/log/chrony` is
|
||
mode `0750 chrony:chrony` (others have no access), so DAC also blocks the agent's
|
||
`DynamicUser` from traversing the dir — a denial that produces **no AVC** (it's
|
||
DAC, not TE). The DAC half is therefore the **`chrony` supplementary group** on
|
||
`fleet-agent.service` (traverse the `0750` dir; the log file itself is `0644`).
|
||
Both this module and that group are needed; verified 2026-07-10 — with the group
|
||
but no module the read is TE-denied, and with the module but no group it's
|
||
DAC-denied (silently, no AVC — which is what made this the hardest layer to spot).
|
||
**BENCH-VERIFY:** (a) the manager's Monitor chrony row populates on an enforcing
|
||
boot; (b) no other chrony-log AVC (extend here if one appears); (c)
|
||
**reproducibility** — `semodule -i` rebuilds the policy store, re-check verity hash
|
||
stability (same concern as #14/#15). Only shipped when `enable_stig=true`.
|
||
|
||
## 17. STIG NTP-server rules unselected under `secure_ntp` (added 2026-07-11)
|
||
|
||
**Rules waived:** `chronyd_specify_remote_server`, `chronyd_or_ntpd_set_maxpoll`
|
||
(unselected in `diskless-tailoring.xml`; plus a belt-and-suspenders re-strip in
|
||
`2c-STIG-postfix`).
|
||
|
||
**Why:** these rules remediate by writing a **static, unauthenticated**
|
||
`server <pool> maxpoll 16` into `/etc/chrony.conf`. The fleet instead configures
|
||
chrony's time source **dynamically at runtime** — a **fleet-CA-authenticated NTS**
|
||
server derived from the DHCP domain (`secure_ntp=true`, RFC 8915) or DHCP option-42
|
||
(`false`) — via the NM dispatcher writing `sourcedir /run/chrony-dhcp`, with
|
||
`ntstrustedcerts`/`nocerttimecheck` baked. The STIG *intent* (time is synchronised,
|
||
and under NTS **authenticated**) is fully met; the rules only fail because they
|
||
look for a literal baked `server` line the fleet deliberately doesn't have.
|
||
|
||
**The security reason this is not just cosmetic (F-03/P-18):** the ordering is the
|
||
trap — `2c-STIG` runs **after** the `secure_ntp` chrony step, so an un-waived
|
||
`chronyd_specify_remote_server` **re-injects** a plain `server 0.us.pool.ntp.mil
|
||
maxpoll 16` that the earlier step already commented out. That plain server is a
|
||
**silent unauthenticated fallback**: with it present, chrony will happily sync
|
||
non-NTS time, so a DHCP/DNS/NTP spoofer could shift the clock — defeating the whole
|
||
point of NTS. Leaving it in means the host is **not fail-closed**. Observed on
|
||
`comet-beacon-54` 2026-07-11: NTS working (`chronyc authdata` cookies > 0) *and* the
|
||
STIG `0.us.pool.ntp.mil` line still in `chrony.conf` and syncing in parallel.
|
||
|
||
**Fix:** unselect the two rules (so remediation injects nothing) **and**, when
|
||
`secure_ntp=true`, re-comment any `server`/`pool` in `2c-STIG-postfix` (in case a
|
||
future rule adds one) so **only** the NTS `sourcedir` + `ntstrustedcerts` remain.
|
||
Under `secure_ntp=false` the re-strip does not run (the DHCP option-42 dispatcher
|
||
path needs the handed servers).
|
||
|
||
**BENCH-VERIFY:** on a clean secure_ntp build, `grep -E '^(server|pool)'
|
||
/etc/chrony.conf` returns nothing (only `sourcedir`/`ntstrustedcerts`), and
|
||
`chronyc -N authdata` shows the NTS source with cookies > 0 as the **only** selected
|
||
source (no `0.us.pool.ntp.mil` fallback). Only applies when `enable_stig=true`.
|
||
|
||
## Things that are NOT deviations (kept / enforced)
|
||
|
||
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
||
- **fapolicyd** kept **enabled** (NOT waived) — it's the real exec-allowlist guard
|
||
for the writable RAM overlay, which dm-verity does **not** cover. The only
|
||
adjustment: the few non-RPM executables we ship are added to fapolicyd's trust DB
|
||
at build (provisioner 2c-STIG-postfix) so they run; everything else stays
|
||
RPM-trust-only. Trusted custom binaries: `/usr/local/bin/kiosk-session`,
|
||
`/usr/local/bin/fleet-sb-enroll`, `/usr/local/bin/fleet-hostapi` (the loopback
|
||
host-info agent, built from `files/hostapi`). Each is built into the immutable,
|
||
dm-verity-protected root, so trusting it doesn't weaken the overlay exec guard.
|
||
(fapolicyd blocking `kiosk-session` was the original kiosk-won't-start cause.)
|
||
- **auditd** enabled; STIG audit rules applied; logs locally.
|
||
- **USBGuard** enabled + enforcing (`block` target) with a HID/hub allowlist and
|
||
`AuditBackend=LinuxAudit` (see #12) — blocks USB mass storage / unknown device
|
||
classes at the console while keeping keyboard/mouse working.
|
||
- **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 and applied system-wide**
|
||
(kept honest in the report); they are exempted only for the two key-only
|
||
appliance accounts — see deviation #10.
|
||
|
||
## 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).
|