utils/guard-lib/verify-stage1.sh
Krzysztof kuhy Rudnicki c99cb8373e feat: initial guard-lib - shared tamper-resistance primitives
Extracts the chattr+bind-mount+systemd-watcher file-locking pattern (and a
new pacman PreTransaction package-block primitive) out of screen-locker and
steam-backlog-enforcer into a standalone, config-driven library so both
projects (and steam-backlog-enforcer's new block-gaming feature) can share
one implementation instead of maintaining parallel copies.

- guardctl: CLI entry point (file-guard, package-block subcommands)
- lib/file_guard.sh: chattr +i / bind-mount / systemd-path-watcher / pacman
  hook enforcement for a single target file, keyed by /etc/guard-lib/targets
  config; includes `sync` (adopt current content as canonical - for a
  caller's own legitimate edit) distinct from `pacman-relock` (revert drift)
- lib/package_block.sh: time-locked pacman PreTransaction hook that refuses
  to (re)install a given package while a lock file's `until` timestamp is in
  the future; supports --bind-mount to compose with file-guard on the same
  lock file
- pacman-hooks/: the installed hook scripts + .hook definitions
- systemd/: guard-file@.path/.service watcher units, guard-bind-mount@.service

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
2026-07-04 11:47:59 +02:00

184 lines
5.8 KiB
Bash
Executable File

#!/bin/bash
# Live root verification for guard-lib Stage 1: proves file-guard and
# package-block actually work against a real target and real pacman
# transactions before Stage 2 touches anything in /etc that matters
# (hosts, nsswitch, resolved). Must be run as root. Always cleans up
# after itself (throwaway target + test packages), whether checks pass
# or fail, so it is safe to re-run.
set -uo pipefail
if [[ $EUID -ne 0 ]]; then
echo "verify-stage1.sh must be run as root: sudo $0" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_TARGET=/etc/guard-test-file
FG_NAME=dummy-test
PB_NAME=steam-test
PB_LOCK=/etc/guard-lib/blocks/steam-test-lock.json
HARMLESS_PKG=cowsay
BLOCKED_TEST_PKG=sl
PASS_COUNT=0
FAIL_COUNT=0
FAILURES=()
CLEANUP_DONE=0
step() {
echo
echo "=== $1 ==="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
echo " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILURES+=("$1")
echo " FAIL: $1"
}
cleanup() {
[[ "$CLEANUP_DONE" -eq 1 ]] && return
CLEANUP_DONE=1
step "Cleanup"
guardctl package-block end "$PB_NAME" >/dev/null 2>&1 && echo " ended package-block '$PB_NAME'" || true
guardctl file-guard uninstall "$FG_NAME" >/dev/null 2>&1 && echo " uninstalled file-guard '$FG_NAME'" || true
rm -f "$TEST_TARGET"
pacman -Qi "$BLOCKED_TEST_PKG" >/dev/null 2>&1 && pacman -R --noconfirm "$BLOCKED_TEST_PKG" >/dev/null 2>&1 || true
pacman -Qi "$HARMLESS_PKG" >/dev/null 2>&1 && pacman -R --noconfirm "$HARMLESS_PKG" >/dev/null 2>&1 || true
echo " removed $TEST_TARGET, $HARMLESS_PKG, $BLOCKED_TEST_PKG"
}
trap cleanup EXIT INT TERM
step "1. Install guard-lib"
if "$SCRIPT_DIR/install.sh"; then
pass "install.sh ran cleanly"
else
fail "install.sh failed"
fi
step "2. Install file-guard on a throwaway target"
echo "hello" >"$TEST_TARGET"
if guardctl file-guard install "$FG_NAME" --target "$TEST_TARGET"; then
pass "file-guard install succeeded"
else
fail "file-guard install failed"
fi
attrs="$(lsattr "$TEST_TARGET" 2>/dev/null | awk '{print $1}')"
if [[ "$attrs" == *i* ]]; then
pass "target is immutable after install (attrs: $attrs)"
else
fail "target is NOT immutable after install (attrs: $attrs)"
fi
step "3. Tamper the target and confirm auto-revert"
chattr -i "$TEST_TARGET" 2>/dev/null || true
echo "TAMPERED" >"$TEST_TARGET"
sleep 2
content="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
if [[ "$content" == "hello" ]]; then
pass "content auto-reverted to canonical after tamper"
else
fail "content did NOT revert (got: '$content')"
fi
attrs="$(lsattr "$TEST_TARGET" 2>/dev/null | awk '{print $1}')"
if [[ "$attrs" == *i* ]]; then
pass "target re-locked (immutable) after revert"
else
fail "target NOT re-locked after revert (attrs: $attrs)"
fi
step "3b. file-guard unlock: legitimate edit updates canonical and survives re-enforce"
FAKE_EDITOR="$(mktemp)"
cat >"$FAKE_EDITOR" <<'EOF'
#!/bin/bash
echo "hello-edited" >"$1"
EOF
chmod +x "$FAKE_EDITOR"
if EDITOR="$FAKE_EDITOR" guardctl file-guard unlock "$FG_NAME" <<<"verify-stage1 test"; then
pass "file-guard unlock completed"
else
fail "file-guard unlock exited non-zero"
fi
content="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
if [[ "$content" == "hello-edited" ]]; then
pass "unlock edit applied to target"
else
fail "unlock edit NOT applied to target (got: '$content')"
fi
canonical_content="$(cat "$(guardctl file-guard canonical-path "$FG_NAME")" 2>/dev/null || echo '<missing>')"
if [[ "$canonical_content" == "hello-edited" ]]; then
pass "unlock updated canonical to match edit"
else
fail "canonical NOT updated after unlock (got: '$canonical_content') - legitimate edits would be reverted by the watcher"
fi
sleep 2
content_after_wait="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
if [[ "$content_after_wait" == "hello-edited" ]]; then
pass "edit survives after re-enforce (not reverted)"
else
fail "edit was reverted after unlock (got: '$content_after_wait')"
fi
rm -f "$FAKE_EDITOR"
step "4. Confirm a harmless pacman transaction still works (critical: generic hook must not brick pacman)"
echo " (showing pacman's own output live - a slow mirror can take a minute or two, this is not a hang)"
if pacman -S --noconfirm --needed "$HARMLESS_PKG"; then
pass "pacman -S $HARMLESS_PKG succeeded"
else
fail "pacman -S $HARMLESS_PKG FAILED - the generic unlock-all/relock-all hook may be broken"
fi
if pacman -R --noconfirm "$HARMLESS_PKG"; then
pass "pacman -R $HARMLESS_PKG succeeded (cleanup)"
else
fail "pacman -R $HARMLESS_PKG failed (test package may be left installed)"
fi
step "5. package-block: block install of a throwaway package"
if guardctl package-block start "$PB_NAME" --package "$BLOCKED_TEST_PKG" --lock-file "$PB_LOCK" --days 1; then
pass "package-block start succeeded"
else
fail "package-block start failed"
fi
echo " (expecting this install to be ABORTED by the package-block hook)"
if pacman -S --noconfirm "$BLOCKED_TEST_PKG"; then
fail "pacman -S $BLOCKED_TEST_PKG succeeded while blocked (should have been aborted)"
else
pass "pacman -S $BLOCKED_TEST_PKG was blocked as expected"
fi
step "6. package-block: end restores normal installs"
if guardctl package-block end "$PB_NAME"; then
pass "package-block end succeeded"
else
fail "package-block end failed"
fi
if pacman -S --noconfirm --needed "$BLOCKED_TEST_PKG"; then
pass "pacman -S $BLOCKED_TEST_PKG succeeded after block ended"
else
fail "pacman -S $BLOCKED_TEST_PKG FAILED after block ended (should be allowed now)"
fi
cleanup
echo
echo "=================================================="
echo "Stage 1 live verification: $PASS_COUNT passed, $FAIL_COUNT failed"
if [[ "$FAIL_COUNT" -gt 0 ]]; then
echo "Failures:"
for f in "${FAILURES[@]}"; do
echo " - $f"
done
exit 1
fi
echo "ALL STAGE 1 LIVE CHECKS PASSED"
exit 0