security-hardening: check guard-lib units, not the retired ones
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

The suite reported four failures on this host and none of them meant
protection was missing.

Tests 2, 3 and 5 checked hosts-guard.path, hosts-bind-mount.service
and nsswitch-guard.path. The 2026-07-17 guard-lib refactor replaced
those with templated units — guard-file@hosts.path,
guard-bind-mount@hosts.service, guard-file@nsswitch.path — which are
all active. That is also why the legacy units stop a second after
boot: they are superseded, and the old nsswitch guard retriggered
itself into systemd's start limit fighting the new watcher. Each check
now accepts either generation, and the bind-mount check also accepts
the read-only mount on /etc/hosts, which is the real invariant.

Test 10 now treats a *disabled* shutdown-schedule-guard as intended
rather than broken: the poweroff flow it protected was replaced by
night-lockdown-enter.sh. An enabled-but-inactive unit still fails.
That check also had a shell bug — `systemctl is-enabled` prints the
state and exits non-zero when disabled, so `|| echo disabled`
appended a second line and never matched.

Verified protection is genuinely intact rather than assuming it:
/etc/hosts is mounted ro,noatime and both it and /etc/nsswitch.conf
carry the immutable flag. Host now reports 18/0/6. A container with
faked host markers still reports 10 failures, so the suite has not
been softened into always-green.

No system state was changed.
This commit is contained in:
Krzysztof Rudnicki 2026-07-19 19:58:32 +02:00
parent ee740bf92e
commit 2d4331c703
2 changed files with 86 additions and 14 deletions

View File

@ -0,0 +1,39 @@
{
"intent": "Stop test_security_hardening.sh reporting healthy protection as broken. It checked systemd units that the guard-lib refactor renamed, and treated a deliberately retired unit as a fault.",
"scope": [
"linux_configuration/tests/test_security_hardening.sh: guard unit checks and the shutdown-schedule guard check",
"Non-goal: any change to the guard units or system state. Nothing on the host was started, stopped, enabled or disabled."
],
"changes": [
"Tests 2, 3 and 5 checked hosts-guard.path, hosts-bind-mount.service and nsswitch-guard.path. The 2026-07-17 guard-lib refactor replaced these with templated units: guard-file@hosts.path, guard-bind-mount@hosts.service and guard-file@nsswitch.path. Each check now accepts either generation, so it passes on a migrated host and on one still running the legacy units.",
"Test 3 additionally accepts the read-only bind mount on /etc/hosts itself. The mount is the actual invariant; which unit established it is an implementation detail.",
"Test 10 (shutdown-schedule-guard.path) now distinguishes disabled from enabled-but-inactive. The guard was retired along with the poweroff flow — day-specific-shutdown now hands over to night-lockdown-enter.sh — so disabled is the intended state and is reported as a skip. An enabled unit that is not running is still a failure.",
"Fixed a shell bug in that check: `systemctl is-enabled` prints the state on stdout AND exits non-zero when the state is 'disabled', so `$(... || echo disabled)` produced two lines and never compared equal."
],
"verification": [
{
"command": "bash linux_configuration/tests/test_security_hardening.sh (real host)",
"result": "pass",
"evidence": "18 passed / 0 failed / 6 skipped, up from 15/4/5. The four reported failures were stale unit names plus one intentionally retired guard, not missing protection."
},
{
"command": "systemctl is-active on the guard-lib units, plus lsattr and findmnt",
"result": "pass",
"evidence": "guard-file@hosts.path, guard-file@nsswitch.path, guard-bind-mount@hosts.service and etc-hosts.mount are all active; /etc/hosts is mounted ro,noatime; /etc/hosts and /etc/nsswitch.conf both carry the immutable flag. Protection was intact the whole time."
},
{
"command": "negative check — fake the configured-host markers inside a container with no protection installed",
"result": "pass",
"evidence": "Reports 10 failures, confirming the suite still detects genuinely absent protection and has not been softened into always-green."
}
],
"risks": [
"Accepting either unit generation means a half-migrated host — new unit running, legacy unit failed — passes. That is the current state of this machine and is harmless, but it does mean the suite will not flag leftover failed legacy units. Those remain visible in `systemctl --failed`.",
"The legacy nsswitch-guard.path and resolved-guard.path are still enabled and sit in a failed state after boot: they and the new guard-file@ watchers both enforced the same files, so each retriggered itself into systemd's start limit. Cosmetic, since the new watchers do the work, but disabling the obsolete units would clean up `systemctl --failed`. Not done here because it changes system state.",
"Test 10 keys off `is-enabled`. If the shutdown guard is ever re-enabled without being started, that correctly fails; if it is re-introduced under a new name, this check needs updating the same way tests 2/3/5 just did."
],
"rollback": [
"git revert this commit: the suite returns to checking only the legacy unit names and reports 4 failures on this host despite protection being active.",
"Validate after rollback: bash test_security_hardening.sh prints FAIL for hosts-guard.path, hosts-bind-mount.service, nsswitch-guard.path and shutdown-schedule-guard.path."
]
}

View File

@ -93,18 +93,30 @@ else
test_result "/etc/hosts is immutable" "skip" "File not found"
fi
# Test 2: hosts-guard.path is active
if systemctl is-active --quiet hosts-guard.path 2>/dev/null; then
test_result "hosts-guard.path is active" "pass"
# Test 2: the hosts file watcher is active
# The 2026-07-17 guard-lib refactor replaced the standalone hosts-guard.path
# with the templated guard-file@hosts.path. Accept either, so this passes on a
# migrated host and on one still running the legacy unit. Checking only the old
# name reported the protection as down when it was simply renamed.
if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null ||
systemctl is-active --quiet hosts-guard.path 2>/dev/null; then
test_result "hosts file watcher is active" "pass"
else
test_result "hosts-guard.path is active" "fail" "Service not running"
test_result "hosts file watcher is active" "fail" \
"neither guard-file@hosts.path nor hosts-guard.path is running"
fi
# Test 3: hosts-bind-mount.service is active
if systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null; then
test_result "hosts-bind-mount.service is active" "pass"
# Test 3: /etc/hosts is bind-mounted read-only
# guard-bind-mount@hosts.service superseded hosts-bind-mount.service. The mount
# itself is the thing that matters, so check that too — it is the real
# invariant, independent of which unit established it.
if systemctl is-active --quiet guard-bind-mount@hosts.service 2>/dev/null ||
systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null ||
findmnt -no OPTIONS /etc/hosts 2>/dev/null | grep -q '\bro\b'; then
test_result "/etc/hosts bind-mounted read-only" "pass"
else
test_result "hosts-bind-mount.service is active" "fail" "Service not running"
test_result "/etc/hosts bind-mounted read-only" "fail" \
"no bind-mount unit active and /etc/hosts is not mounted read-only"
fi
# Test 4: Canonical hosts copy exists
@ -114,11 +126,17 @@ else
test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts"
fi
# Test 5: nsswitch-guard.path is active (NEW)
if systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then
test_result "nsswitch-guard.path is active" "pass"
# Test 5: the nsswitch watcher is active
# guard-file@nsswitch.path superseded nsswitch-guard.path. The legacy unit is
# left in a failed state on migrated hosts (it and the new watcher both
# enforced the same file, so it retriggered itself into systemd's start
# limit) — that is obsolete-unit noise, not a lapse in protection.
if systemctl is-active --quiet guard-file@nsswitch.path 2>/dev/null ||
systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then
test_result "nsswitch watcher is active" "pass"
else
test_result "nsswitch-guard.path is active" "fail" "Service not running"
test_result "nsswitch watcher is active" "fail" \
"neither guard-file@nsswitch.path nor nsswitch-guard.path is running"
fi
# Test 6: /etc/nsswitch.conf is immutable (NEW)
@ -171,11 +189,26 @@ else
test_result "day-specific-shutdown.timer is active" "fail" "Timer not running"
fi
# Test 10: shutdown schedule guard is active
# Test 10: shutdown schedule guard
# The shutdown flow no longer powers the machine off — day-specific-shutdown
# now hands over to night-lockdown-enter.sh, which tears down the GUI and masks
# the TTY login while leaving the servers up. This guard was retired with the
# old poweroff path, so a *disabled* unit is the intended state, not a fault.
# An enabled-but-inactive unit would still mean something is wrong, so those
# two cases are distinguished rather than both being tolerated.
# `systemctl is-enabled` prints the state on stdout *and* exits non-zero when
# that state is "disabled", so `|| echo disabled` would append a second line
# and never compare equal. Capture stdout and discard the exit status instead.
shutdown_guard_enabled=$(systemctl is-enabled shutdown-schedule-guard.path 2>/dev/null || true)
[[ -n "$shutdown_guard_enabled" ]] || shutdown_guard_enabled=unknown
if systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then
test_result "shutdown-schedule-guard.path is active" "pass"
elif [[ "$shutdown_guard_enabled" == "disabled" ]]; then
test_result "shutdown-schedule-guard.path is active" "skip" \
"intentionally retired with the old poweroff flow (night lockdown replaced it)"
else
test_result "shutdown-schedule-guard.path is active" "fail" "Guard not running"
test_result "shutdown-schedule-guard.path is active" "fail" \
"unit is $shutdown_guard_enabled but not running"
fi
# Test 11: Unlock script has obscure name (no helpful path)