From 2d4331c703f648d2e86f5bcf97901e161b70cbc4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 19 Jul 2026 19:58:32 +0200 Subject: [PATCH] security-hardening: check guard-lib units, not the retired ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...-hardening-guard-lib-units-2026-07-19.json | 39 ++++++++++++ .../tests/test_security_hardening.sh | 61 ++++++++++++++----- 2 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/evidence/security-hardening-guard-lib-units-2026-07-19.json diff --git a/docs/superpowers/evidence/security-hardening-guard-lib-units-2026-07-19.json b/docs/superpowers/evidence/security-hardening-guard-lib-units-2026-07-19.json new file mode 100644 index 00000000..0ac4389f --- /dev/null +++ b/docs/superpowers/evidence/security-hardening-guard-lib-units-2026-07-19.json @@ -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." + ] +} diff --git a/linux_configuration/tests/test_security_hardening.sh b/linux_configuration/tests/test_security_hardening.sh index 60d69e06..a8fc5a89 100755 --- a/linux_configuration/tests/test_security_hardening.sh +++ b/linux_configuration/tests/test_security_hardening.sh @@ -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)