From 945af75aa2c0bf5b40a0155500f0774f06db6721 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Mon, 31 Aug 2026 17:24:59 +0200 Subject: [PATCH] Fail closed when passwordless sudo expiry cannot arm Remove stale passwordless sudo grants during boot and revoke a live grant immediately if its transient expiry timer cannot be created. Exercise both failure paths and the shipped tmpfiles rule against a disposable root. Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> --- bin/omarchy-sudo-passwordless | 21 +++- etc/tmpfiles.d/omarchy-nopasswd-sudo.conf | 5 + manual/48-security.md | 2 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 123 ++++++++++++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 etc/tmpfiles.d/omarchy-nopasswd-sudo.conf create mode 100644 test/shell.d/nopasswd-sudo-expiry-test.sh diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index ad157ae9975..719d881bfe7 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -13,6 +13,19 @@ if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then exit 1 fi +arm_expiry() { + if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ + rm -f -- "$NOPASSWD_FILE"; then + return 0 + fi + + echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2 + if ! sudo rm -f -- "$NOPASSWD_FILE"; then + echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2 + fi + return 1 +} + echo "Toggle passwordless sudo..." # Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up @@ -24,8 +37,7 @@ fi if sudo test -f "$NOPASSWD_FILE"; then if [[ $1 ]]; then sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null - sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm "$NOPASSWD_FILE" + arm_expiry || exit 1 echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." else sudo rm "$NOPASSWD_FILE" @@ -48,12 +60,11 @@ else if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null sudo chmod 440 "$NOPASSWD_FILE" - sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm "$NOPASSWD_FILE" + arm_expiry || exit 1 echo "" echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes." - echo "Note: if you restart before then, run omarchy-sudo-passwordless again to disable it." + echo "A restart removes the passwordless sudo rule as well." else echo "Aborted. No changes made." fi diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf new file mode 100644 index 00000000000..2c644ff1fc9 --- /dev/null +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -0,0 +1,5 @@ +# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and +# arms a transient systemd-run timer to remove it again. Transient units do not +# survive a reboot, so remove any remaining grant during early boot. Boot-only +# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 94f762ceec2..45750398e30 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh new file mode 100644 index 00000000000..f332f80e414 --- /dev/null +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +script="$ROOT/bin/omarchy-sudo-passwordless" +tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +grant="$test_tmp/grant" +calls="$test_tmp/calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash + +printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" +[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] +SH + +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$TEST_CALLS" + +case ${1:-} in +test) + [[ ${2:-} == "-f" && -f $TEST_GRANT ]] + ;; +tee) + /usr/bin/tee "$TEST_GRANT" + ;; +chmod) + /usr/bin/chmod "$2" "$TEST_GRANT" + ;; +systemd-run) + [[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]] + ;; +rm) + /usr/bin/rm -f -- "$TEST_GRANT" + ;; +systemctl) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 90 + ;; +esac +SH + +chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" + +run_command() { + TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ + "$script" "$@" +} + +: >"$calls" +enable_output=$(run_command 15) +[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled" +[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] || + fail "the enabled grant belongs to the current user" "$(cat "$grant")" +grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" || + fail "enabling arms the expiry timer" "$(cat "$calls")" +[[ $enable_output == *"automatically disable in 15 minutes"* ]] || + fail "success is reported after the timer is armed" "$enable_output" +pass "enabling arms expiry before reporting success" + +: >"$calls" +rm -f "$grant" +if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then + fail "enabling fails when the expiry timer cannot be armed" +fi +[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant" +[[ $failure_output == *"Revoking access now"* ]] || + fail "timer setup failure explains the fail-closed revocation" "$failure_output" +[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] || + fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output" +pass "timer setup failure revokes a new grant" + +: >"$calls" +printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant" +if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then + fail "updating fails when the replacement expiry timer cannot be armed" +fi +[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant" +[[ $update_output != *"timer updated"* ]] || + fail "timer update failure does not report success" "$update_output" +pass "timer update failure revokes the existing grant" + +mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") +(( ${#tmpfiles_rules[@]} == 1 )) || + fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" + +fake_root="$test_tmp/root" +sudoers_dir="$fake_root/etc/sudoers.d" +mkdir -p "$sudoers_dir" +grant_names=(alice buildbot-2 user.123 'service$') +for grant_name in "${grant_names[@]}"; do + touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name" +done +touch "$sudoers_dir/omarchy-dns" + +systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}" +[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] || + fail "boot-only cleanup leaves a live grant alone outside boot" + +systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}" +for grant_name in "${grant_names[@]}"; do + stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name" + [[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant" +done +[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" +pass "systemd-tmpfiles removes generated grants only during boot"