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
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user