From 4ae25cd4d241392158607ac96edd2e92717ee7ba Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 21:50:47 +0100 Subject: [PATCH] Keep temporary sudo grants bounded through lifecycle failures --- bin/omarchy-security-functions | 90 ++++++-- bin/omarchy-sudo-passwordless | 133 ++++++++---- docs/passwordless-sudo.md | 25 +++ manual/48-security.md | 2 + test/shell.d/nopasswd-sudo-expiry-test.sh | 70 ++++-- .../passwordless-grant-lifecycle-test.sh | 199 ++++++++++++++++++ 6 files changed, 440 insertions(+), 79 deletions(-) create mode 100644 docs/passwordless-sudo.md create mode 100644 test/shell.d/passwordless-grant-lifecycle-test.sh diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions index 2f3d2242..890d4430 100755 --- a/bin/omarchy-security-functions +++ b/bin/omarchy-security-functions @@ -1,10 +1,7 @@ #!/bin/bash # omarchy:hidden=true -# omarchy:summary=Provide internal fail-closed helpers for security-sensitive commands - -# Shared fail-closed primitives for security-sensitive Omarchy commands. This -# file is sourced from the same package-owned bin directory as its consumers. +# omarchy:summary=Provide internal helpers for command-scoped sudo authentication if [[ ${BASH_SOURCE[0]} == "$0" ]]; then echo "omarchy-security-functions is an internal function library." >&2 @@ -12,25 +9,63 @@ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then fi omarchy_security_require_privileged_bash_startup() { - local pid=${1:-$$} - - [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || return 1 + [[ $- == *p* ]] || return 1 /usr/bin/env -i /usr/bin/bash -p -c ' mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 - [[ $executable == /usr/bin/bash ]] - [[ ${argv[0]:-} == /bin/bash || ${argv[0]:-} == /usr/bin/bash ]] - [[ ${argv[1]:-} == -p ]] - ' omarchy-bash-startup "$pid" + [[ $executable == "/usr/bin/bash" && + ( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) && + ${argv[1]:-} == "-p" ]] + ' omarchy-bash-startup "$$" +} + +omarchy_security_sanitize_bash_environment() { + local script=$1 + shift + local entry name environment_fd environment_pid + local -a unsets=() + + # Read the raw environment: privileged Bash ignores exported functions, but + # leaves their records for ordinary child interpreters to import later. + exec {environment_fd}< <(/usr/bin/env -0) + environment_pid=$! + while IFS= read -r -d '' entry <&"$environment_fd"; do + name=${entry%%=*} + case "$name" in + BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%) + unsets+=(-u "$name") + ;; + esac + done + exec {environment_fd}<&- + wait "$environment_pid" || return 1 + if (( ${#unsets[@]} > 0 )); then + exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@" + fi +} + +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi } omarchy_security_sudo_supports_no_update() { - LC_ALL=C /usr/bin/sudo -h 2>&1 | - /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' + local help + help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help" } omarchy_security_revoke_sudo_timestamp() { - /usr/bin/sudo -k >/dev/null 2>&1 + /usr/bin/sudo -k } omarchy_security_exit_with_revoked_sudo() { @@ -51,6 +86,27 @@ omarchy_security_install_signal_exit_traps() { trap 'exit 143' TERM } +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_enable_no_update_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + if ! omarchy_security_sudo_supports_no_update; then + echo "This sudo does not support --no-update; refusing mixed-trust work." >&2 + return 1 + fi + if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then + echo "The command-scoped sudo wrapper is missing." >&2 + return 1 + fi + PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" + OMARCHY_SUDO_NO_UPDATE=1 + export PATH OMARCHY_SUDO_NO_UPDATE +} + omarchy_security_run_sudo_cleanup_trap() { local status=$? @@ -58,12 +114,6 @@ omarchy_security_run_sudo_cleanup_trap() { "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" } -omarchy_security_install_sudo_cleanup_traps() { - OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} - trap omarchy_security_run_sudo_cleanup_trap EXIT - omarchy_security_install_signal_exit_traps -} - omarchy_security_assert_root_directory() { local path=$1 expected_mode=$2 canonical owner actual_mode diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 717e8aaf..5f7cd1dc 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -4,14 +4,20 @@ # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then + echo "Refusing an unsafe Bash startup for passwordless sudo." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 if [[ ${BASH_SOURCE[0]} == "$0" ]]; then omarchy_security_require_privileged_bash_startup || { echo "Refusing an unsafe Bash startup for passwordless sudo." >&2 exit 126 } - unset BASH_ENV ENV + omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126 fi set -euo pipefail @@ -22,7 +28,9 @@ readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless +readonly STATUS_INACTIVE=3 usage() { echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 @@ -31,15 +39,15 @@ usage() { } valid_minutes() { - [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= MAX_MINUTES)) + [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) } valid_uid() { - [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= 4294967294)) + [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) } valid_account_name() { - [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 )) } resolve_account() { @@ -191,10 +199,13 @@ remove_known_legacy_rules() { # must not survive the next boot. Do not require the account to still # exist: a deleted account could otherwise make the rule immortal and a # later username reuse could activate it again. - /usr/bin/rm -f -- "$file" || failed=1 - [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || - /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ - "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + if /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]; then + [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || + /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ + "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + else + failed=1 + fi else classification=$? # An unreadable candidate cannot be proven inert. A symlink, non-file, @@ -213,6 +224,7 @@ cleanup_uid_locked() { # Remove policy first. A failed timer stop can only leave an inert cleanup # job behind, never extend passwordless access. /usr/bin/rm -f -- "$(rule_file "$uid")" || return 1 + [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 [[ -z $timer ]] || stop_timer "$timer" } @@ -246,6 +258,7 @@ cleanup_all_locked() { verify_boot_cleanup() { local owner mode canonical current active_rules + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 [[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1 [[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1 @@ -288,7 +301,7 @@ start_expiry_timer() { # a monotonic OnActiveSec timer pauses while the machine is suspended. /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ --timer-property=AccuracySec=1s --unit="$timer" \ - -- "$INSTALLED_SELF" __expire "$uid" || return 1 + -- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1 /usr/bin/systemctl is-active --quiet "${timer}.timer" } @@ -306,6 +319,20 @@ publish_rule() { /usr/bin/rm -f -- "$tmp" } +abort_enable_locked() { + local uid=$1 timer=$2 old_timer=$3 pending_state=$4 + # Publication can install policy and then fail while cleaning its temporary + # file. Never disarm either expiry job until policy revocation is confirmed. + if cleanup_uid_locked "$uid"; then + stop_timer "$timer" + [[ -z $old_timer ]] || stop_timer "$old_timer" + else + echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 + fi + /usr/bin/rm -f -- "$pending_state" || true + return 1 +} + enable_locked() { local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now resolve_account "$uid" || return 1 @@ -329,34 +356,26 @@ enable_locked() { # grant rather than extending it. pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1 if ! start_expiry_timer "$uid" "$expires" "$timer"; then - /usr/bin/rm -f -- "$pending_state" - # Preserve the predecessor fix's fail-closed extension semantics: a caller - # must never mistake a failed replacement for a safely extended grant. - # The old timer is still armed here, but revoking the old rule as well is - # the unambiguous failure state. - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then - stop_timer "$timer" - /usr/bin/rm -f -- "$pending_state" - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi - if ! publish_rule "$uid" "$ACCOUNT_NAME"; then - stop_timer "$timer" - /usr/bin/rm -f -- "$(state_file "$uid")" "$(rule_file "$uid")" + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi now=$(current_epoch) || { - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 } - if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer"; then + if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! verify_boot_cleanup; then # The timer may have expired or failed between its initial verification and # rule publication. Revoke synchronously so a suspended or heavily loaded # machine cannot turn a short grant into a reboot-long one. - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" @@ -364,40 +383,68 @@ enable_locked() { status_locked() { local uid="$1" record state_name expires timer now remainder - resolve_account "$uid" || return 1 - [[ -f $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 + resolve_account "$uid" || return 2 + if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then + return "$STATUS_INACTIVE" + fi record=$(read_state_record "$uid") || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } state_name=${record%%$'\t'*} remainder=${record#*$'\t'} expires=${remainder%%$'\t'*} timer=${record##*$'\t'} [[ $state_name == "$ACCOUNT_NAME" ]] || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } now=$(current_epoch) || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } ((10#$now < 10#$expires)) || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } /usr/bin/systemctl is-active --quiet "${timer}.timer" || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } } +revoke_inactive_grant() { + if cleanup_uid_locked "$1"; then + return "$STATUS_INACTIVE" + else + echo "Could not revoke invalid or expired passwordless sudo. Administrator cleanup is required." >&2 + return 2 + fi +} + +expire_locked() { + local uid=$1 timer=${2:-} current_timer status + if [[ -n $timer ]]; then + current_timer=$(read_state_timer "$uid" 2>/dev/null || true) + # A delayed predecessor must not revoke a newer, independently timed grant. + [[ -z $current_timer || $current_timer == "$timer" ]] || return 0 + cleanup_uid_locked "$uid" + elif status_locked "$uid"; then + # Compatibility with already scheduled UID-only jobs: enforce the current + # grant's expiry instead of letting an old timer shorten its replacement. + return 0 + else + status=$? + (( status == STATUS_INACTIVE )) + fi +} + root_dispatch() { local action="$1" shift case "$action" in __status) - (($# == 1)) && verify_sudo_caller "$1" || return 1 + (($# == 1)) && verify_sudo_caller "$1" || return 2 with_root_lock status_locked "$1" ;; __enable) @@ -409,8 +456,9 @@ root_dispatch() { with_root_lock cleanup_uid_locked "$1" ;; __expire) - (($# == 1)) && ((EUID == 0)) && valid_uid "$1" || return 1 - with_root_lock cleanup_uid_locked "$1" + (($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1 + [[ -z ${2:-} ]] || valid_timer_for_uid "$1" "$2" || return 1 + with_root_lock expire_locked "$@" ;; __cleanup-all) (($# == 0)) && ((EUID == 0)) || return 1 @@ -459,6 +507,11 @@ if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes." fi else + status=$? + if (( status != STATUS_INACTIVE )); then + echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2 + exit 1 + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md new file mode 100644 index 00000000..b97160e4 --- /dev/null +++ b/docs/passwordless-sudo.md @@ -0,0 +1,25 @@ +# Temporary passwordless sudo + +`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. + +## Grant lifecycle + +Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required. + +An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists. + +Each new expiry callback carries its timer identity. A delayed predecessor cannot revoke a newer grant. Already scheduled UID-only callbacks remain compatible by checking the current grant's expiry. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; it does not run during routine non-boot tmpfiles maintenance. + +## Package ownership + +The packaging companion must put the publication/expiry command, `omarchy-security-functions` and `omarchy-nopasswd-sudo.conf` in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. + +Before settings removal or upgrade, its scriptlet acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. Failed scriptlet cleanup returns an error and prints recovery guidance; a package-manager scriptlet failure must not be represented as an automatic transaction rollback. + +The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. + +## Validation + +`test/shell.d/nopasswd-sudo-expiry-test.sh` covers the public interface, cold authentication, timer setup, boot cleanup, package transitions and lock contention. `test/shell.d/passwordless-grant-lifecycle-test.sh` covers publication/cleanup failures, error status, supported account syntax, predecessor callbacks and the shared package-removal lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. + +These tests use private filesystem fixtures and mapped privileged commands. Package archive ownership, actual install/upgrade/removal, real calendar expiry, suspend/resume and boot cleanup must also be validated in a disposable VM before claiming release readiness. Changes to the common library require integration checks on the downstream update, migration, installer, package-picker and diagnostic PRs. diff --git a/manual/48-security.md b/manual/48-security.md index 86028a23..6db0dff5 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -22,6 +22,8 @@ It works by restoring the baseline snapshot the installer takes, so it's only av 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 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough. +Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive. + 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. ## Signing Keys diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 6d54633c..06965215 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -13,7 +13,7 @@ trap 'rm -rf "$test_tmp"' EXIT function_prefix() { printf 'source %q\n' "$security_library_path" - awk '/^source .*omarchy-security-functions/ { next } /^case "\$\{1:-\}" in$/ { exit } { print }' "$command_path" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" } # Exercise the validation code itself. Leading zeroes remain numeric, but zero, @@ -23,7 +23,7 @@ function_prefix() { for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes" done - for minutes in 0 1441 -1 1m '1;id' ''; do + for minutes in 0 1441 -1 1m '1;id' '' 18446744073709551617; do ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" done ) @@ -45,8 +45,6 @@ pass "passwordless sudo derives and validates trusted account identity" # model that publishes a token only when -N is missing. grep -Fxq '#!/bin/bash -p' "$command_path" || fail "passwordless sudo no longer suppresses Bash startup injection" -grep -F '[[ ${argv[1]:-} == -p ]]' "$security_library_path" >/dev/null || - fail "passwordless sudo accepts a decoy post-script -p" public_sudo_stub="$test_tmp/public-sudo" public_gum_stub="$test_tmp/public-gum" @@ -67,19 +65,20 @@ if [[ ${1:-} == -N ]]; then no_update=1; shift; fi [[ ${1:-} != -- ]] || shift ((no_update)) || : >"$TEST_PUBLIC_TOKEN" case "${2:-}" in - __status) exit 1 ;; + __status) exit "${TEST_PUBLIC_STATUS:-3}" ;; __enable|__disable) exit 0 ;; *) exit 2 ;; esac STUB cat >"$public_gum_stub" <<'STUB' #!/bin/bash +[[ -z ${TEST_PUBLIC_GUM_LOG:-} ]] || : >"$TEST_PUBLIC_GUM_LOG" [[ ! -e $TEST_PUBLIC_TOKEN ]] || : >"$TEST_PUBLIC_EXPLOIT" exit 1 STUB chmod 0755 "$public_sudo_stub" "$public_gum_stub" public_flow="$test_tmp/passwordless-public-flow" -/usr/bin/cp "$security_library_path" "$test_tmp/omarchy-security-functions" +/usr/bin/sed "s#/usr/bin/sudo#$public_sudo_stub#g" "$security_library_path" >"$test_tmp/omarchy-security-functions" /usr/bin/sed \ -e "s#/usr/bin/sudo#$public_sudo_stub#g" \ -e "s#/usr/bin/gum#$public_gum_stub#g" \ @@ -89,6 +88,16 @@ TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ /usr/bin/bash -p "$public_flow" 15 >/dev/null [[ ! -e $public_token && ! -e $public_exploit ]] || fail "passwordless confirmation inherited a reusable status credential" +for status in 1 2; do + if TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + TEST_PUBLIC_STATUS="$status" TEST_PUBLIC_GUM_LOG="$test_tmp/unsafe-status-confirmation" \ + /usr/bin/bash -p "$public_flow" 15 >"$test_tmp/status-error.output" 2>&1; then + fail "passwordless sudo treats status/authorization failure $status as inactive" + fi + [[ ! -e $test_tmp/unsafe-status-confirmation ]] || fail "failed status inspection opens the enable prompt" + grep -q 'Could not safely inspect passwordless sudo' "$test_tmp/status-error.output" || + fail "failed status inspection lacks recovery guidance" +done startup_env="$test_tmp/passwordless-bash-env" startup_marker="$test_tmp/passwordless-bash-env-ran" @@ -341,6 +350,9 @@ pkgs_candidates=( pkgs_root="" for candidate in "${pkgs_candidates[@]}"; do if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then + pkgs_root=$candidate/pkgbuilds + break + elif [[ -n $candidate && -d $candidate/omarchy-settings ]]; then pkgs_root=$candidate break fi @@ -348,31 +360,36 @@ done [[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" for package_name in omarchy-settings omarchy-settings-dev; do - install_script="$pkgs_root/pkgbuilds/$package_name/$package_name.install" + install_script="$pkgs_root/$package_name/$package_name.install" transformed_install="$test_tmp/$package_name.install" removal_root="$test_tmp/$package_name-remove" removal_sudoers="$removal_root/etc/sudoers.d" - mkdir -p "$removal_sudoers" + mkdir -p "$removal_sudoers" "$removal_root/run/lock" "$removal_root/etc/tmpfiles.d" : >"$removal_sudoers/99-omarchy-nopasswd-1000" : >"$removal_sudoers/99-omarchy-nopasswd-legacy-user" : >"$removal_sudoers/omarchy-dns" - ln -s ../usr/share/omarchy/etc-overrides/os-release "$removal_root/etc/os-release" - grep -Fq 'ln -s ../usr/share/omarchy/etc-overrides/os-release /etc/os-release' "$install_script" || - fail "$package_name installation does not select package-owned OS metadata" - sed "s#/etc/#$removal_root/etc/#g" "$install_script" >"$transformed_install" + ln -s ../administrator/os-release "$removal_root/etc/os-release" + package_stat="$test_tmp/package-stat" + cat >"$package_stat" <<'STUB' +#!/bin/bash +if [[ $2 == '%u' ]]; then printf '0\n'; else /usr/bin/stat "$@"; fi +STUB + chmod +x "$package_stat" + sed -e "s#/etc/#$removal_root/etc/#g" \ + -e "s#/run#$removal_root/run#g" \ + -e "s#/usr/bin/stat#$package_stat#g" "$install_script" >"$transformed_install" ( source "$transformed_install" + pre_remove + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] post_remove ) || fail "$package_name removal revokes active passwordless grants" ! find "$removal_sudoers" -name '99-omarchy-nopasswd-*' -print -quit | grep -q . || fail "$package_name removal leaves a passwordless grant behind" [[ -e $removal_sudoers/omarchy-dns ]] || fail "$package_name removal deletes an unrelated sudoers policy" - [[ -L $removal_root/etc/os-release ]] && - [[ $(readlink "$removal_root/etc/os-release") == ../usr/lib/os-release ]] || - fail "$package_name removal does not restore the standard OS selector" - - ln -sfn ../administrator/os-release "$removal_root/etc/os-release" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal changes unrelated OS metadata" : >"$removal_sudoers/99-omarchy-nopasswd-1001" ( source "$transformed_install" @@ -382,8 +399,23 @@ for package_name in omarchy-settings omarchy-settings-dev; do fail "$package_name removal overwrites an administrator OS selector" [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1001 ]] || fail "$package_name removal grant cleanup depends on OS selector state" + + ( + source "$transformed_install" + _etc_overrides_apply() { :; } + if post_install; then exit 1; fi + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_root/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + post_install + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_sudoers/99-omarchy-nopasswd-1002" + pre_upgrade + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1002 ]] + post_upgrade + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + ) || fail "$package_name restores grant availability only after boot cleanup is installed" done -pass "settings package removal revokes grants and preserves package-selector ownership" +pass "settings package transitions revoke grants and preserve unrelated configuration" # Exercise the production flock wrapper under contention. mkdir is an atomic # overlap detector; all workers must enter and leave the protected region. @@ -418,7 +450,7 @@ pass "passwordless sudo serializes concurrent operations" # Same-boot expiry calls the fixed installed cleanup command, and cleanup # removes policy before touching a timer so timer failures cannot extend it. -grep -F '"$INSTALLED_SELF" __expire "$uid"' "$command_path" >/dev/null +grep -F '"$INSTALLED_SELF" __expire "$uid" "$timer"' "$command_path" >/dev/null cleanup_body=$(awk '/^cleanup_uid_locked\(\) \{/ { in_body=1 } in_body { print } in_body && /^}/ { exit }' "$command_path") rm_line=$(grep -n '/usr/bin/rm -f' <<<"$cleanup_body" | head -1 | cut -d: -f1) stop_line=$(grep -n 'stop_timer' <<<"$cleanup_body" | tail -1 | cut -d: -f1) diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh new file mode 100644 index 00000000..c70d69e5 --- /dev/null +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -0,0 +1,199 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +children=() +cleanup() { + local status=$? + trap - EXIT + if (( ${#children[@]} )); then + kill "${children[@]}" 2>/dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi + rm -rf "$test_tmp" + exit "$status" +} +trap cleanup EXIT + +# All policy, state, locks and command mutations stay in this private fixture. +# Native visudo validates inert fragments; no test installs host sudo policy. +mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" +export TEST_GRANT_ROOT="$test_tmp" +cat >"$test_tmp/bin/stat" <<'STUB' +#!/bin/bash +case $2 in + '%u') printf '0\n' ;; + '%a') if [[ -d ${@: -1} ]]; then printf '755\n'; else printf '644\n'; fi ;; + '%u %a') if [[ -d ${@: -1} ]]; then printf '0 755\n'; else printf '0 644\n'; fi ;; + *) exec /usr/bin/stat "$@" ;; +esac +STUB +cat >"$test_tmp/bin/install" <<'STUB' +#!/bin/bash +args=() +while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac +done +exec /usr/bin/install "${args[@]}" +STUB +cat >"$test_tmp/bin/rm" <<'STUB' +#!/bin/bash +for path in "$@"; do + if [[ ${TEST_FAIL_TEMP_CLEANUP:-0} == 1 && $path == "$TEST_GRANT_ROOT/state/".sudoers.* ]]; then exit 1; fi + if [[ ${TEST_FAIL_RULE_DELETE:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/"* ]]; then exit 1; fi +done +exec /usr/bin/rm "$@" +STUB +cat >"$test_tmp/bin/systemctl" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_GRANT_ROOT/systemctl.log" +exit 0 +STUB +chmod +x "$test_tmp/bin/"* +library="$test_tmp/grant-functions.sh" +{ + printf 'source %q\n' "$ROOT/bin/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$ROOT/bin/omarchy-sudo-passwordless" +} | sed \ + -e "s|/var/lib/omarchy/sudo-passwordless|$test_tmp/state|g" \ + -e "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|g" \ + -e "s|/etc/tmpfiles.d|$test_tmp/etc/tmpfiles.d|g" \ + -e "s|/run/lock/omarchy-sudo-passwordless.lock|$test_tmp/run/lock/omarchy-sudo-passwordless.lock|g" \ + -e "s|/run/omarchy-sudo-passwordless-package-removing|$test_tmp/run/omarchy-sudo-passwordless-package-removing|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ + -e "s|/usr/bin/install|$test_tmp/bin/install|g" \ + -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + -e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \ + -e 's|/usr/bin/chown|/usr/bin/true|g' >"$library" + +printf 'r! /etc/sudoers.d/99-omarchy-nopasswd-*\n' >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +# The expected policy text is mapped along with its filename in this fixture. +sed -i "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|" "$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + +( + source "$library" + for name in 'buildbot$' audituser aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + valid_account_name "$name" || fail "supported account name rejected: $name" + printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$test_tmp/name-policy" + /usr/sbin/visudo -cf "$test_tmp/name-policy" >/dev/null + done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + ! valid_account_name "$name" || fail "invalid account name accepted" + done + ! valid_uid 18446744073709551617 || fail "overflowed UID accepted" + printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + remove_known_legacy_rules + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot\$ ]] +) || fail "supported account names and legacy cleanup disagree" +pass "provisioning-compatible names validate as sudoers and clean up correctly" + +transaction_setup() { + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + start_expiry_timer() { printf '%s\n' "$3" >>"$test_tmp/armed"; } + stop_timer() { printf '%s\n' "$1" >>"$test_tmp/stopped"; } +} + +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) && ! -e $(state_file 1000) && -s $test_tmp/stopped ]] +) || fail "post-publication cleanup failure did not revoke before timer cleanup" +pass "failed temporary cleanup after publication revokes the live policy" + +rm -f "$test_tmp/stopped" +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 TEST_FAIL_RULE_DELETE=1 enable_locked 1000 15 && exit 1 + [[ -f $(rule_file 1000) && -f $(state_file 1000) && ! -e $test_tmp/stopped ]] + if TEST_FAIL_RULE_DELETE=1 revoke_inactive_grant 1000; then exit 1; else status=$?; fi + (( status == 2 )) +) || fail "failed policy revocation disarmed expiry or claimed inactive status" +pass "failed revocation preserves expiry jobs and returns a distinct error" + +( + source "$library" + transaction_setup + current_timer=$(read_state_timer 1000) + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ -f $(rule_file 1000) ]] + expire_locked 1000 + [[ -f $(rule_file 1000) ]] + expire_locked 1000 "$current_timer" + [[ ! -e $(rule_file 1000) ]] +) || fail "a predecessor timer invalidates its replacement" +pass "old and legacy timer callbacks preserve a newer valid grant" + +( + source "$library" + transaction_setup + start_expiry_timer() { + : >"$REMOVAL_BLOCKER" + return 0 + } + enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) ]] +) || fail "publication ignores a lost package prerequisite" +rm "$test_tmp/run/omarchy-sudo-passwordless-package-removing" +pass "grant publication rechecks package availability after timer setup" + +pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} +[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds +package_script="$pkgs_path/omarchy-settings/omarchy-settings.install" +[[ -f $package_script ]] || fail "package checkout is required for shared lifecycle coverage" +sed -e "s|/etc/|$test_tmp/etc/|g" \ + -e "s|/run|$test_tmp/run|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ + -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" "$package_script" >"$test_tmp/package.install" + +worker="$test_tmp/publisher.sh" +{ + printf '#!/bin/bash\nset -euo pipefail\nsource %q\n' "$library" + declare -f transaction_setup + printf 'test_tmp=%q\ntransaction_setup\n' "$test_tmp" + cat <<'WORKER' +publish_rule() { + : >"$test_tmp/publisher.entered" + while [[ ! -e $test_tmp/publisher.release ]]; do sleep 0.02; done + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file "$1")" +} +with_root_lock enable_locked 1000 15 +WORKER +} >"$worker" +bash "$worker" >"$test_tmp/publisher.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/publisher.entered ]] || break + sleep 0.02 +done +[[ -e $test_tmp/publisher.entered ]] || fail "grant publisher did not enter the shared lock" +bash -euo pipefail -c 'source "$1"; : >"$2"; pre_remove; post_remove' bash \ + "$test_tmp/package.install" "$test_tmp/removal.started" >"$test_tmp/removal.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/removal.started ]] || break + sleep 0.02 +done +[[ -e $test_tmp/removal.started ]] || fail "package removal did not start" +touch "$test_tmp/publisher.release" +for child in "${children[@]}"; do wait "$child" || fail "shared lifecycle worker failed"; done +children=() +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "removal left a concurrently published grant" +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not block later publication" +( + source "$library" + transaction_setup + ! with_root_lock enable_locked 1000 15 +) || fail "a publisher can create a grant after package removal begins" +pass "package removal shares the grant lock and blocks later publication" + +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash "$test_tmp/package.install" >"$test_tmp/removal-failure.output" 2>&1; then + fail "package removal hid a failed policy deletion" +fi +grep -q 'Administrator cleanup is required' "$test_tmp/removal-failure.output" || fail "package deletion failure lacks recovery guidance" +pass "package removal reports cleanup failures instead of successful revocation"