Keep temporary sudo grants bounded through lifecycle failures
This commit is contained in:
1 parent
bc235d2807
commit
4ae25cd4d2
6 files changed
+440
-79
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user