agent: read chrony status from tracking.log instead of the command socket

Bench on comet-beacon-54 proved every command-socket route is blocked for the
hardened agent: chronyd's unix socket is owner-write-only (0755, so even a
chrony-group member can't connect), and STIG closes the UDP command port
(cmdport 0). Rather than re-open a port or expose chrony's privileged socket,
enable chronyd's tracking log and have the agent READ it — no port, no socket,
no group, no SELinux domain transition, full hardening intact.

- chrony.conf: enable 'log tracking' (idempotent) so chronyd appends
  source/stratum/offset to /var/log/chrony/tracking.log. Write volume is a ~130B
  line per clock update (backing off to ~maxpoll when stable) and capped by the
  chrony RPM's logrotate — negligible on the RAM overlay. cmdport 0 stays, so no
  STIG deviation.
- agent: chronyTracking() now reads the last data line of tracking.log via
  parseTrackingLog() (source f[2], stratum f[3], offset f[6], leap/sync f[7]);
  replaces the chronyc exec + parseChrony. Unit test swapped accordingly.
- revert the two abandoned approaches: drop the bindcmdaddress line (394ad10)
  and the fleet_agent_chronyc SELinux nnp_transition module + provisioner block
  (e6fd4a2, .te deleted) — no chronyc exec means no transition to allow. Removed
  stig-deviations #16 and #17 (both waivers no longer needed).

Needs a workstation image rebuild + reboot. BENCH-VERIFY: tracking.log is
world-readable so the DynamicUser agent can read it; under enforcing
unconfined_service_t can read chronyd_var_log_t (add a minimal file-read allow
if denied); and the offset sign matches chronyc on a synced host.
This commit is contained in:
Audiobuzz
2026-07-10 09:36:37 +00:00
parent 394ad10824
commit 4ca0b6dd06
5 changed files with 78 additions and 218 deletions
-81
View File
@@ -248,87 +248,6 @@ surface rather than detecting abuse of it. Requires a write primitive to a root-
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_chronyc` (added 2026-07-10)
Not a STIG-rule waiver — a **minimal local `allow` module** shipped so the
`fleet-agent` telemetry client can read chrony status under enforcing SELinux +
NoNewPrivileges. Source: `files/stig/fleet-agent-chronyc.te`; built + `semodule
-i`'d in the 2c-STIG provisioner (right after #15).
**The one rule:** `allow unconfined_service_t chronyc_t:process2 nnp_transition;`
**Why:** `fleet-agent` reports the host's clock health (offset / NTP source /
stratum) by shelling out to `chronyc -n tracking`. The agent runs as
`unconfined_service_t`, and the shipped chrony policy has a `type_transition` that
moves the executed `chronyc` into the confined `chronyc_t` domain. But the
`fleet-agent.service` unit is hardened with **`NoNewPrivileges`** — set explicitly
and *also* forced implicitly by `DynamicUser=yes`, `SystemCallFilter=`, and
`MemoryDenyWriteExecute=yes` — and SELinux forbids an NNP process from
transitioning domains unless `process2:nnp_transition` is granted for that
source→target pair. The base policy grants it for some daemons but not
`unconfined_service_t → chronyc_t`, so under enforcing the exec is **denied**
(audit `comet-beacon-54` 2026-07-10: `avc: denied { nnp_transition }
comm="fleet-agent" scontext=…unconfined_service_t tcontext=…chronyc_t
tclass=process2 permissive=0`). Result: `chronyc` never runs, the agent's
`chronyTracking()` returns empty, and every `chrony_*` health field stays unset
(the manager's Monitor chrony row shows `—`). The other probes are unaffected —
`uptime_seconds`/cpu/mem read `/proc` directly and need no domain transition.
**Scope:** one perm (`nnp_transition`) on one class (`process2`), two well-known
types. It permits a transition to a **more-confined** domain (`chronyc_t`) — the
exact safe direction `nnp_transition` exists to gate — and **loosens nothing** in
the agent's own sandbox (NNP, DynamicUser, syscall filter, MDWE all stay). It does
not grant any file read/write. Installed unconditionally alongside #14/#15 (the
rule is inert on a build without the agent). **BENCH-VERIFY:** (a) the AVC is gone
on an enforcing boot; (b) the manager's Monitor chrony row populates with
offset/source/stratum from the live NTP source; (c) no **follow-on** `chronyc_t`
AVC under the agent's mount namespace (extend the `.te` if any appears); (d)
**reproducibility** — `semodule -i` rebuilds the policy store, so re-check verity
hash stability across two builds (same concern as #12/#14/#15). NB: only shipped
when `enable_stig=true`; a non-STIG enforcing build would still see the AVC.
**Not the whole story — see #17.** Permissive-mode bench (`comet-beacon-54`,
2026-07-10) showed the chrony fields STILL empty with SELinux permissive, proving
the module isn't the only blocker: chronyd's command interface was reachable only
via its **unix socket**, which the hardened agent can't use. #17 is the companion
fix; this module remains needed for the enforcing exec transition.
## 17. chronyd loopback UDP command socket for the fleet-agent probe (added 2026-07-10)
Not a STIG-rule waiver — a **one-line `chrony.conf` addition**
(`bindcmdaddress 127.0.0.1`) so the hardened `fleet-agent` can read clock health.
**Why:** the agent reports offset/source/stratum via `chronyc tracking`. On chrony
4.x (RHEL 9) the command interface defaults to the **unix socket only**
(`/run/chrony/chronyd.sock`), usable solely by root or the `chrony` group **and**
needing a writable `/run/chrony` for chronyc's reply socket. The agent unit is
hardened — `DynamicUser=yes` (ephemeral uid, group `tss` only, not `chrony`) and
`ProtectSystem=strict` (`/run` read-only) — so it cannot open the unix socket nor
create a reply socket. Bench (`comet-beacon-54`, 2026-07-10): as a normal user
`chronyc tracking` → `506 Cannot talk to daemon`; only `sudo chronyc` worked;
`chronyc -h 127.0.0.1` failed and `ss -lnup` showed **no UDP :323** — the loopback
command socket wasn't bound. Result: the probe always failed, every `chrony_*`
field stayed empty (Monitor chrony row `—`), and SELinux permissive did NOT help
(this is a sandbox/socket issue, orthogonal to #16).
**The change:** bind chronyd's command socket on `127.0.0.1` (UDP 323, loopback)
in the 2c provisioner (idempotent, next to the DHCP `sourcedir` edit); the agent
probe uses `chronyc -n -h 127.0.0.1 tracking`. UDP needs no filesystem write and no
group, and rides the unit's already-allowed `AF_INET` — the one transport that
works under the sandbox. The unix-socket alternative (add `chrony` group +
`ReadWritePaths=/run/chrony`) was rejected: `/run/chrony` is `0750` (group has no
write), so chronyc still couldn't create its reply socket.
**Scope / risk:** the command socket is bound **loopback-only** and is
**monitoring-only** — chronyd still requires the root unix socket for any state
change (`makestep`, `add server`, etc.), so a local caller can *read* clock status
but cannot alter time. No network exposure (127.0.0.1, not the fleet LAN). This is
also chrony's historical pre-4.x default. **BENCH-VERIFY:** (a) `ss -lnup | grep
:323` shows the loopback socket after boot; (b) the Monitor chrony row populates;
(c) under enforcing, no follow-on `chronyc_t → chronyd_t` UDP AVC (extend
`fleet-agent-chronyc.te` if so — see #16). Only applied when the NTP/DHCP provisioner
runs (always, independent of `enable_stig`).
## Things that are NOT deviations (kept / enforced)
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).