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:
+11
-27
@@ -405,17 +405,17 @@ build {
|
|||||||
"restorecon /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",
|
"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",
|
"grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf",
|
||||||
# Bind chronyd's command socket on LOOPBACK UDP so the fleet-agent can query
|
# Enable chrony's tracking log so the fleet-agent can report clock health
|
||||||
# clock health (`chronyc -h 127.0.0.1 tracking`). chrony 4.x (RHEL 9) defaults
|
# (source/stratum/offset) WITHOUT touching chronyd's command interface. That
|
||||||
# the command interface to the UNIX socket /run/chrony/chronyd.sock ONLY
|
# interface is unusable by the hardened agent: the unix socket is
|
||||||
# (root or the chrony group + a writable /run/chrony for chronyc's reply
|
# owner-write-only (0755) and the agent is DynamicUser + ProtectSystem=strict
|
||||||
# socket). The agent runs hardened — DynamicUser (not in chrony group),
|
# (/run read-only), and STIG closes the UDP command port (cmdport 0). Instead
|
||||||
# ProtectSystem=strict (/run read-only) — so it can't use the unix socket; a
|
# chronyd appends source/stratum/offset to /var/log/chrony/tracking.log, which
|
||||||
# loopback UDP command socket needs no fs write or group and rides the unit's
|
# the agent simply READS (world-readable; no port, no socket, no SELinux domain
|
||||||
# already-allowed AF_INET. Localhost-only + monitoring-only (state changes
|
# transition). Write volume is tiny (~one ~130B line per clock update, backing
|
||||||
# still require the root unix socket), so the exposure is minimal. Documented
|
# off to ~maxpoll when stable) and capped by the chrony RPM's logrotate; on the
|
||||||
# in docs/stig-deviations.md #17.
|
# RAM overlay this is negligible. Keeps cmdport 0 intact — no STIG deviation.
|
||||||
"grep -q '^bindcmdaddress 127.0.0.1' /etc/chrony.conf || echo 'bindcmdaddress 127.0.0.1' >> /etc/chrony.conf",
|
"grep -qE '^log ([^#]*[[:space:]])?tracking' /etc/chrony.conf || echo 'log tracking' >> /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",
|
||||||
@@ -910,22 +910,6 @@ build {
|
|||||||
"semodule -i /tmp/fapolicyd_tmpfs.pp",
|
"semodule -i /tmp/fapolicyd_tmpfs.pp",
|
||||||
"rm -f /tmp/fapolicyd_tmpfs.mod /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; }",
|
"semodule -l | grep -q '^fapolicyd_tmpfs' && echo '==> SELinux module fapolicyd_tmpfs installed' || { echo 'ERROR: fapolicyd_tmpfs module not installed' >&2; exit 1; }",
|
||||||
# SELinux: let the fleet-agent's chrony probe transition to chronyc_t under
|
|
||||||
# NoNewPrivileges. The agent (unconfined_service_t) shells out to `chronyc -n
|
|
||||||
# tracking` for clock offset/source/stratum; the shipped policy transitions
|
|
||||||
# chronyc into chronyc_t, but the hardened unit's NNP (explicit + forced by
|
|
||||||
# DynamicUser/SystemCallFilter/MDWE) makes SELinux require process2:nnp_transition
|
|
||||||
# for that move, which the base policy doesn't grant -> `denied { nnp_transition }`
|
|
||||||
# -> chronyc never runs -> all chrony_* health fields stay empty. Ship a minimal
|
|
||||||
# allow (docs/stig-deviations.md). Installed unconditionally like the modules
|
|
||||||
# above (the rule is inert without the agent). checkpolicy ensured above. Same
|
|
||||||
# policy-store-rebuild reproducibility caveat as chrony/fapolicyd.
|
|
||||||
"echo '==> supplemental: SELinux module fleet_agent_chronyc (fleet-agent chronyc under NNP)'",
|
|
||||||
"checkmodule -M -m -o /tmp/fleet_agent_chronyc.mod /tmp/stig/fleet-agent-chronyc.te",
|
|
||||||
"semodule_package -o /tmp/fleet_agent_chronyc.pp -m /tmp/fleet_agent_chronyc.mod",
|
|
||||||
"semodule -i /tmp/fleet_agent_chronyc.pp",
|
|
||||||
"rm -f /tmp/fleet_agent_chronyc.mod /tmp/fleet_agent_chronyc.pp",
|
|
||||||
"semodule -l | grep -q '^fleet_agent_chronyc' && echo '==> SELinux module fleet_agent_chronyc installed' || { echo 'ERROR: fleet_agent_chronyc 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)'",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
path either way (already-privileged context), which is fapolicyd's defense-in-depth
|
||||||
threat model — not an unprivileged bypass.
|
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)
|
## 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).
|
||||||
|
|||||||
+50
-50
@@ -295,7 +295,7 @@ func selinuxState() string {
|
|||||||
return "permissive"
|
return "permissive"
|
||||||
}
|
}
|
||||||
|
|
||||||
// chrony holds the parsed `chronyc tracking` fields relative to the NTP source.
|
// chrony holds the parsed chrony tracking fields relative to the NTP source.
|
||||||
type chrony struct {
|
type chrony struct {
|
||||||
state string // synced|unsynced|"" (unavailable)
|
state string // synced|unsynced|"" (unavailable)
|
||||||
offset float64 // system time offset from NTP, seconds (signed: fast=+, slow=-)
|
offset float64 // system time offset from NTP, seconds (signed: fast=+, slow=-)
|
||||||
@@ -303,65 +303,65 @@ type chrony struct {
|
|||||||
stratum int32 // hops from a reference clock
|
stratum int32 // hops from a reference clock
|
||||||
}
|
}
|
||||||
|
|
||||||
// chronyTracking runs `chronyc tracking` once and parses it. Best-effort: a zero
|
// chronyLogPath is chrony's tracking log — enabled by `log tracking` in
|
||||||
// chrony{} (state "") if chronyc is unavailable — never fails the snapshot.
|
// chrony.conf; chronyd appends one line per clock update.
|
||||||
|
const chronyLogPath = "/var/log/chrony/tracking.log"
|
||||||
|
|
||||||
|
// chronyTracking reads the LAST line of chrony's tracking.log and parses the
|
||||||
|
// source/stratum/offset. Best-effort: a zero chrony{} if the log is
|
||||||
|
// absent/unreadable/empty — never fails the snapshot.
|
||||||
//
|
//
|
||||||
// -h 127.0.0.1 forces the LOOPBACK UDP command socket instead of chronyd's unix
|
// We read a FILE rather than query chronyd's command socket by design: that unix
|
||||||
// socket. Under the agent's hardened unit (DynamicUser — not in the chrony group;
|
// socket is owner-write-only (0755) so the hardened non-root agent (DynamicUser,
|
||||||
// ProtectSystem=strict — /run read-only) the unix socket at /run/chrony is
|
// not the chrony user) can't connect, and STIG closes the UDP command port
|
||||||
// unreachable (can't open it, nor create chronyc's reply socket there). UDP needs
|
// (cmdport 0). `log tracking` writes the same fields to a world-readable log the
|
||||||
// no fs write or group and rides the unit's already-allowed AF_INET. The image
|
// agent can read under ProtectSystem=strict — no port, no socket, no group, no
|
||||||
// binds this socket via `bindcmdaddress 127.0.0.1` in chrony.conf.
|
// SELinux domain transition. Tradeoff: the offset is as fresh as chrony's last
|
||||||
|
// update (up to ~maxpoll old), which is fine for a slow-moving health metric.
|
||||||
func chronyTracking() chrony {
|
func chronyTracking() chrony {
|
||||||
out, err := runShort("chronyc", "-n", "-h", "127.0.0.1", "tracking")
|
b, err := os.ReadFile(chronyLogPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chrony{}
|
return chrony{}
|
||||||
}
|
}
|
||||||
return parseChrony(out)
|
return parseTrackingLog(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseChrony extracts the source/offset/stratum/leap fields from `chronyc
|
// parseTrackingLog extracts source/stratum/offset/sync-state from the LAST data
|
||||||
// tracking` key:value output. Pure (unit-testable).
|
// line of chrony's tracking.log. Pure (unit-testable).
|
||||||
func parseChrony(out string) chrony {
|
//
|
||||||
c := chrony{state: "unsynced"}
|
// Column layout (chrony `log tracking`; header written by logbanner):
|
||||||
for line := range strings.SplitSeq(out, "\n") {
|
//
|
||||||
key, val, ok := strings.Cut(line, ":")
|
// Date Time IP-Address St Freq Skew Offset L Co Offset-sd Rem-corr Root-delay Root-disp
|
||||||
if !ok {
|
// f[0] f[1] f[2] f[3] f[4] f[5] f[6] f[7] ...
|
||||||
continue
|
//
|
||||||
|
// Offset (f[6]) carries chrony's sign convention (ahead/fast = +), matching our
|
||||||
|
// chrony_offset_seconds field. L (f[7]) is the leap/sync flag: N/+/- = synchronised,
|
||||||
|
// '?' = not synchronised. Header/banner lines (first field not a digit) are skipped;
|
||||||
|
// an unsynchronised reference logs 0.0.0.0, reported as an empty source.
|
||||||
|
// BENCH-VERIFY the offset sign against `chronyc tracking` on a synced host.
|
||||||
|
func parseTrackingLog(data []byte) chrony {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
f := strings.Fields(lines[i])
|
||||||
|
if len(f) < 8 || f[0] == "" || f[0][0] < '0' || f[0][0] > '9' {
|
||||||
|
continue // blank, header ("Date ..."), or banner ("=====")
|
||||||
}
|
}
|
||||||
key = strings.TrimSpace(key)
|
c := chrony{state: "unsynced"}
|
||||||
val = strings.TrimSpace(val)
|
if f[7] != "?" {
|
||||||
switch key {
|
c.state = "synced"
|
||||||
case "Leap status":
|
|
||||||
if strings.Contains(val, "Normal") {
|
|
||||||
c.state = "synced"
|
|
||||||
}
|
|
||||||
case "Stratum":
|
|
||||||
if n, err := strconv.Atoi(strings.Fields(val)[0]); err == nil {
|
|
||||||
c.stratum = int32(n)
|
|
||||||
}
|
|
||||||
case "Reference ID":
|
|
||||||
// "C0A8320A (192.168.50.10)" — prefer the parenthesised name/IP.
|
|
||||||
c.source = val
|
|
||||||
if i := strings.IndexByte(val, '('); i >= 0 {
|
|
||||||
if j := strings.IndexByte(val[i:], ')'); j > 1 {
|
|
||||||
c.source = val[i+1 : i+j]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "System time":
|
|
||||||
// "0.000012345 seconds slow of NTP time" — magnitude + direction.
|
|
||||||
fields := strings.Fields(val)
|
|
||||||
if len(fields) >= 3 {
|
|
||||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
|
||||||
if fields[2] == "slow" { // system behind NTP -> negative
|
|
||||||
v = -v
|
|
||||||
}
|
|
||||||
c.offset = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if f[2] != "0.0.0.0" {
|
||||||
|
c.source = f[2]
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(f[3]); err == nil {
|
||||||
|
c.stratum = int32(n)
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(f[6], 64); err == nil {
|
||||||
|
c.offset = v
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
return c
|
return chrony{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unitActive reports whether a systemd unit is active (best-effort).
|
// unitActive reports whether a systemd unit is active (best-effort).
|
||||||
|
|||||||
@@ -68,16 +68,16 @@ func TestParseUptime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseChrony(t *testing.T) {
|
func TestParseTrackingLog(t *testing.T) {
|
||||||
out := `Reference ID : C0A8320A (192.168.50.10)
|
// A realistic tracking.log: banner + header + two data lines. parseTrackingLog
|
||||||
Stratum : 3
|
// must take the LAST data line, skip banner/header, and read source/stratum/offset.
|
||||||
Ref time (UTC) : Fri Jul 10 06:00:00 2026
|
log := `====================================================================
|
||||||
System time : 0.000012345 seconds slow of NTP time
|
Date (UTC) Time IP Address St Freq ppm Skew ppm Offset L Co Offset sd
|
||||||
Last offset : +0.000001234 seconds
|
====================================================================
|
||||||
Frequency : 12.345 ppm slow
|
2026-07-10 05:59:00 192.168.50.10 3 -1.234 0.100 +0.000002000 N 4 1.0e-07
|
||||||
Leap status : Normal
|
2026-07-10 06:00:00 192.168.50.10 3 -1.250 0.090 -0.000012345 N 4 1.0e-07
|
||||||
`
|
`
|
||||||
c := parseChrony(out)
|
c := parseTrackingLog([]byte(log))
|
||||||
if c.state != "synced" {
|
if c.state != "synced" {
|
||||||
t.Errorf("state = %q, want synced", c.state)
|
t.Errorf("state = %q, want synced", c.state)
|
||||||
}
|
}
|
||||||
@@ -87,17 +87,18 @@ Leap status : Normal
|
|||||||
if c.stratum != 3 {
|
if c.stratum != 3 {
|
||||||
t.Errorf("stratum = %d, want 3", c.stratum)
|
t.Errorf("stratum = %d, want 3", c.stratum)
|
||||||
}
|
}
|
||||||
// "slow of NTP time" -> negative offset (sign-sensitive check).
|
// last line's Offset is -0.000012345 (sign-sensitive: must not read the +2e-6 above).
|
||||||
if c.offset >= 0 || math.Abs(c.offset+0.000012345) > 1e-9 {
|
if c.offset >= 0 || math.Abs(c.offset+0.000012345) > 1e-9 {
|
||||||
t.Errorf("offset = %v, want -0.000012345", c.offset)
|
t.Errorf("offset = %v, want -0.000012345", c.offset)
|
||||||
}
|
}
|
||||||
// unsynced when Leap status isn't Normal.
|
// unsynchronised reference: L='?' and 0.0.0.0 source.
|
||||||
if c := parseChrony("Leap status : Not synchronised\n"); c.state != "unsynced" {
|
uns := "2026-07-10 06:01:00 0.0.0.0 0 0.000 0.000 +0.000000000 ? 0 0.0e+00\n"
|
||||||
t.Errorf("state = %q, want unsynced", c.state)
|
if c := parseTrackingLog([]byte(uns)); c.state != "unsynced" || c.source != "" {
|
||||||
|
t.Errorf("unsynced parse = %+v, want state=unsynced source=''", c)
|
||||||
}
|
}
|
||||||
// empty / unavailable.
|
// empty / header-only -> zero chrony{}.
|
||||||
if c := parseChrony(""); c.state != "unsynced" || c.source != "" {
|
if c := parseTrackingLog([]byte(" Date (UTC) Time IP Address\n")); c.state != "" {
|
||||||
t.Errorf("empty parse = %+v", c)
|
t.Errorf("header-only parse = %+v, want zero", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# 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 fleet_agent_chronyc 1.0.0;
|
|
||||||
|
|
||||||
########################################
|
|
||||||
# Let the fleet-agent's chrony probe transition to chronyc_t under NoNewPrivileges.
|
|
||||||
#
|
|
||||||
# fleet-agent (telemetry) shells out to `chronyc -n tracking` to report the host's
|
|
||||||
# clock offset / NTP source / stratum. The agent runs as unconfined_service_t, and
|
|
||||||
# the shipped chrony policy has a type_transition so executing chronyc moves the
|
|
||||||
# child into the confined chronyc_t domain. BUT the fleet-agent unit is hardened
|
|
||||||
# with NoNewPrivileges (set explicitly AND forced by DynamicUser + SystemCallFilter
|
|
||||||
# + MemoryDenyWriteExecute), and SELinux forbids an NNP process from transitioning
|
|
||||||
# domains unless process2:nnp_transition is granted for that source->target pair.
|
|
||||||
# The shipped policy has no such grant, so under enforcing the exec is DENIED:
|
|
||||||
# avc: denied { nnp_transition } comm="fleet-agent"
|
|
||||||
# scontext=...:unconfined_service_t tcontext=...:chronyc_t tclass=process2
|
|
||||||
# chronyc never runs, chronyTracking() returns empty, and every chrony_* health
|
|
||||||
# field stays unset (the UI shows chrony as "—"). uptime/cpu/mem are unaffected —
|
|
||||||
# they read /proc directly and need no domain transition.
|
|
||||||
#
|
|
||||||
# This single allow lets the transition proceed to the MORE-confined chronyc_t
|
|
||||||
# domain (the safe direction NNP's nnp_transition permission exists to gate), with
|
|
||||||
# no loosening of the agent's own sandbox. Minimal + auditable; justified in
|
|
||||||
# docs/stig-deviations.md #16.
|
|
||||||
#
|
|
||||||
# NB this is necessary but NOT sufficient: the agent reaches chronyd over the
|
|
||||||
# LOOPBACK UDP command socket (`chronyc -h 127.0.0.1 tracking`; the image binds it
|
|
||||||
# via `bindcmdaddress 127.0.0.1`, see stig-deviations #17) because the unix socket
|
|
||||||
# is unusable under the agent's sandbox. Under enforcing, once chronyc runs in
|
|
||||||
# chronyc_t it must also be allowed to reach chronyd_t on UDP 323 — the base chrony
|
|
||||||
# policy is expected to cover chronyc_t's command traffic, but BENCH-VERIFY on the
|
|
||||||
# first enforcing boot and, if a FOLLOW-ON chronyc_t AVC appears (e.g. a corenet
|
|
||||||
# udp_socket/name_connect denial to chronyd), extend here.
|
|
||||||
########################################
|
|
||||||
|
|
||||||
require {
|
|
||||||
type unconfined_service_t;
|
|
||||||
type chronyc_t;
|
|
||||||
class process2 nnp_transition;
|
|
||||||
}
|
|
||||||
|
|
||||||
allow unconfined_service_t chronyc_t:process2 nnp_transition;
|
|
||||||
Reference in New Issue
Block a user