From 87625c2ad8cf0e0bc08676127548a532d318036e Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:48 +0100 Subject: [PATCH 01/10] OM-SEC-01: Make passwordless sudo expiry fail closed --- bin/omarchy-security-functions | 87 ++++ bin/omarchy-sudo-passwordless | 483 +++++++++++++++++++-- etc/tmpfiles.d/omarchy-nopasswd-sudo.conf | 8 +- manual/48-security.md | 2 +- migrations/1788163635.sh | 6 + test/shell.d/nopasswd-sudo-expiry-test.sh | 497 +++++++++++++++++----- 6 files changed, 945 insertions(+), 138 deletions(-) create mode 100755 bin/omarchy-security-functions create mode 100644 migrations/1788163635.sh mode change 100644 => 100755 test/shell.d/nopasswd-sudo-expiry-test.sh diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000000..2f3d2242ec5 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,87 @@ +#!/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. + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + local pid=${1:-$$} + + [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || 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" +} + +omarchy_security_sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k >/dev/null 2>&1 +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${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 + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 719d881bfe7..717e8aaf243 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,481 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}" -TIMER_NAME="omarchy-nopasswd-expire-${USER}" +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then +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 +fi + +set -euo pipefail + +readonly DEFAULT_MINUTES=15 +readonly MAX_MINUTES=1440 +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 INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless + +usage() { echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 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 +valid_minutes() { + [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= MAX_MINUTES)) +} + +valid_uid() { + [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= 4294967294)) +} + +valid_account_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] +} + +resolve_account() { + local uid="$1" entry + valid_uid "$uid" || return 1 + entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1 + IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry" + [[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1 + # Sudoers names and the legacy filename both have metacharacters. Omarchy + # accounts use this portable subset; refusing anything else is safer than + # attempting to quote privileged policy syntax. + valid_account_name "$ACCOUNT_NAME" || return 1 + ACCOUNT_UID=$((10#$uid)) +} + +verify_sudo_caller() { + local requested_uid="$1" + ((EUID == 0)) || return 1 + valid_uid "$requested_uid" || return 1 + [[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1 + ((10#$SUDO_UID == 10#$requested_uid)) || return 1 + resolve_account "$requested_uid" +} + +prepare_root_state() { + omarchy_security_assert_root_directory /var 755 || return 1 + [[ -d /var/lib && ! -L /var/lib ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1 + + if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /var/lib/omarchy || return 1 fi + omarchy_security_assert_root_directory /var/lib/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1 - 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 + omarchy_security_assert_root_directory /run 755 || return 1 + if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1 fi - return 1 + omarchy_security_assert_root_directory /run/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy } -echo "Toggle passwordless sudo..." +with_root_lock() { + local fd rc=0 + # The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy: + # those are exactly the kinds of partial-install state it must fail closed + # through. /run/lock is established by the OS before sysinit services run. + omarchy_security_assert_root_directory /run 755 || return 1 + [[ -d /run/lock && ! -L /run/lock ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1 + exec {fd}>"$LOCK_FILE" || return 1 + /usr/bin/chown root:root "$LOCK_FILE" || return 1 + /usr/bin/chmod 0600 "$LOCK_FILE" || return 1 + /usr/bin/flock -x "$fd" || return 1 + "$@" || rc=$? + /usr/bin/flock -u "$fd" || rc=1 + exec {fd}>&- + return "$rc" +} -# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up -if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then - sudo rm "$NOPASSWD_FILE" -fi +rule_file() { + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" +} -# Check for the file directly — sudo -n can stay cached or be granted by other rules -if sudo test -f "$NOPASSWD_FILE"; then - if [[ $1 ]]; then - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null - arm_expiry || exit 1 - echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." +state_file() { + printf '%s/%s.state' "$STATE_DIR" "$1" +} + +read_state_record() { + local uid="$1" file state_uid name expires timer canonical_uid + local -a lines=() + valid_uid "$uid" || return 1 + canonical_uid=$((10#$uid)) + file=$(state_file "$uid") + [[ -f $file && ! -L $file ]] || return 1 + mapfile -t lines <"$file" || return 1 + (( ${#lines[@]} == 4 )) || return 1 + [[ ${lines[0]} == UID=* && ${lines[1]} == USER=* && + ${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1 + state_uid=${lines[0]#UID=} + name=${lines[1]#USER=} + expires=${lines[2]#EXPIRES=} + timer=${lines[3]#TIMER=} + [[ $state_uid == "$canonical_uid" ]] || return 1 + valid_account_name "$name" || return 1 + [[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + [[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1 + printf '%s\t%s\t%s' "$name" "$expires" "$timer" +} + +read_state_timer() { + local record + record=$(read_state_record "$1") || return 1 + printf '%s' "${record##*$'\t'}" +} + +current_epoch() { + local now + now=$(/usr/bin/date +%s) || return 1 + [[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + printf '%s' "$now" +} + +valid_expiry() { + [[ $1 =~ ^[1-9][0-9]{0,10}$ ]] +} + +valid_timer_for_uid() { + local uid="$1" timer="$2" + valid_uid "$uid" || return 1 + uid=$((10#$uid)) + [[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]] +} + +stop_timer() { + local timer="$1" + [[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0 + /usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true + /usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true +} + +classify_generated_rule() { + local file=$1 suffix contents name + + GENERATED_RULE_LEGACY_TIMER="" + [[ -f $file && ! -L $file ]] || return 1 + contents=$(/usr/bin/cat -- "$file") || return 2 + suffix=${file##*/99-omarchy-nopasswd-} + + if [[ $suffix =~ ^[0-9]+$ ]]; then + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]] + elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then + GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" else - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null + return 1 + fi +} + +remove_known_legacy_rules() { + local file classification failed=0 + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + # A crash after publishing the numeric rule but before its state rename + # 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 + else + classification=$? + # An unreadable candidate cannot be proven inert. A symlink, non-file, + # or administrator-authored body is unrelated and remains untouched. + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +cleanup_uid_locked() { + local uid="$1" timer="" + valid_uid "$uid" || return 1 + timer=$(read_state_timer "$uid" 2>/dev/null || true) + # 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 + /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 + [[ -z $timer ]] || stop_timer "$timer" +} + +cleanup_all_locked() { + local state uid failed=0 file classification + shopt -s nullglob + for state in "$STATE_DIR"/*.state; do + uid=${state##*/} + uid=${uid%.state} + if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi + done + shopt -u nullglob + remove_known_legacy_rules || failed=1 + + # Never report a successful boot cleanup while an exact rule emitted by any + # Omarchy implementation is still active. Administrator-extended files do + # not match these complete bodies and remain untouched. + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + failed=1 + else + classification=$? + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +verify_boot_cleanup() { + local owner mode canonical current active_rules + [[ -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 + owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + + current=${BOOT_CLEANUP_FILE%/*} + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + [[ $current == / ]] && break + current=${current%/*} + [[ -n $current ]] || current=/ + done + + active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 + [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] +} + +prepare_state_file() { + local uid="$1" name="$2" expires="$3" timer="$4" tmp + tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1 + if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \ + "$uid" "$name" "$expires" "$timer" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then + /usr/bin/rm -f -- "$tmp" + return 1 + fi + printf '%s' "$tmp" +} + +start_expiry_timer() { + local uid="$1" expires="$2" timer="$3" + valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1 + # Calendar timers use CLOCK_REALTIME and catch up immediately after resume; + # 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 + /usr/bin/systemctl is-active --quiet "${timer}.timer" +} + +publish_rule() { + local uid="$1" name="$2" destination tmp + destination=$(rule_file "$uid") + tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 + if ! /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || + ! /usr/sbin/visudo -cf "$tmp" >/dev/null || + ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then + /usr/bin/rm -f -- "$tmp" + return 1 + fi + /usr/bin/rm -f -- "$tmp" +} + +enable_locked() { + local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now + resolve_account "$uid" || return 1 + valid_minutes "$minutes" || return 1 + prepare_root_state || return 1 + verify_boot_cleanup || { + echo "omarchy-sudo-passwordless: package-owned boot cleanup rule is missing or unsafe" >&2 + return 1 + } + + old_timer=$(read_state_timer "$uid" 2>/dev/null || true) + token=$(/usr/bin/tr -d '-' = 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer"; 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 + return 1 + fi + [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" +} + +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 + record=$(read_state_record "$uid") || { + cleanup_uid_locked "$uid" + return 1 + } + state_name=${record%%$'\t'*} + remainder=${record#*$'\t'} + expires=${remainder%%$'\t'*} + timer=${record##*$'\t'} + [[ $state_name == "$ACCOUNT_NAME" ]] || { + cleanup_uid_locked "$uid" + return 1 + } + now=$(current_epoch) || { + cleanup_uid_locked "$uid" + return 1 + } + ((10#$now < 10#$expires)) || { + cleanup_uid_locked "$uid" + return 1 + } + /usr/bin/systemctl is-active --quiet "${timer}.timer" || { + cleanup_uid_locked "$uid" + return 1 + } +} + +root_dispatch() { + local action="$1" + shift + case "$action" in + __status) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock status_locked "$1" + ;; + __enable) + (($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1 + with_root_lock enable_locked "$1" "$2" + ;; + __disable) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __expire) + (($# == 1)) && ((EUID == 0)) && valid_uid "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __cleanup-all) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock cleanup_all_locked + ;; + *) return 1 ;; + esac +} + +case "${1:-}" in + __status|__enable|__disable|__expire|__cleanup-all) + action=$1 + shift + root_dispatch "$action" "$@" + exit + ;; +esac + +(($# <= 1)) || usage +minutes=${1:-$DEFAULT_MINUTES} +valid_minutes "$minutes" || usage +uid=$(/usr/bin/id -u) +valid_uid "$uid" || { + echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 + exit 1 +} + +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2 + exit 1 +} + +omarchy_security_install_sudo_cleanup_traps +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not start from a cold sudo credential state." >&2 + exit 1 +} + +echo "Toggle passwordless sudo..." +if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then + if (($# == 0)); then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." + else + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes." fi else 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." + echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." echo "" echo "This is useful for AI agents that need to run sudo commands," echo "but it significantly weakens the security of your system." echo "Anyone or anything with access to your user account gets full root." echo "" - echo "Passwordless sudo will automatically disable after ${MINUTES} minutes." + echo "Passwordless sudo will automatically disable after ${minutes} minutes," + echo "including if the machine reboots before the timer fires." echo "Run this command again to disable it early." echo "" - 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" - arm_expiry || exit 1 - + if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" echo "" - echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes." - echo "A restart removes the passwordless sudo rule as well." + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." 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 index 2c644ff1fc9..be81137ad05 100644 --- a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -1,5 +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. +# omarchy-sudo-passwordless creates grants in this owned filename namespace. +# Transient expiry timers do not survive reboot, so early boot removes every +# remaining grant. The boot-only modifier prevents later tmpfiles runs from +# shortening a live, explicitly requested window. r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 45750398e30..86028a230f6 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. A restart removes the passwordless sudo rule as well. +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. 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/migrations/1788163635.sh b/migrations/1788163635.sh new file mode 100644 index 00000000000..e2fe22725d2 --- /dev/null +++ b/migrations/1788163635.sh @@ -0,0 +1,6 @@ +echo "Remove legacy temporary passwordless sudo grants" + +# This removes current numeric grants, exact legacy username grants, corrupt or +# orphaned state, and their known timers. Administrator-authored sudoers files +# whose contents do not exactly match Omarchy's generated grammar are preserved. +sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh old mode 100644 new mode 100755 index f332f80e414..6d54633ca13 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -2,122 +2,425 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -script="$ROOT/bin/omarchy-sudo-passwordless" -tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +command_path="$ROOT/bin/omarchy-sudo-passwordless" +security_library_path="$ROOT/bin/omarchy-security-functions" +tmpfiles_path="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +migration_path="$ROOT/migrations/1788163635.sh" 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" +function_prefix() { + printf 'source %q\n' "$security_library_path" + awk '/^source .*omarchy-security-functions/ { next } /^case "\$\{1:-\}" in$/ { exit } { print }' "$command_path" +} -cat >"$mock_bin/gum" <<'SH' -#!/bin/bash -exit 0 -SH +# Exercise the validation code itself. Leading zeroes remain numeric, but zero, +# negatives, oversized grants, and shell syntax are rejected. +( + source <(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 + ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" + done +) +pass "passwordless sudo validates a bounded positive duration" -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash +# The public entry point uses the kernel-backed numeric identity; $USER is +# never interpolated into a privileged filename or sudoers rule. +grep -F 'uid=$(/usr/bin/id -u)' "$command_path" >/dev/null || + fail "passwordless sudo derives the caller from id -u" +! grep -Eq '\$\{?USER\}?' "$command_path" || + fail "passwordless sudo does not trust USER for privileged policy" +grep -F '[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]]' "$command_path" >/dev/null || + fail "passwordless sudo validates sudo provenance" +pass "passwordless sudo derives and validates trusted account identity" -printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" -[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] -SH +# Status inspection and the confirmation UI are mixed-trust: a normal sudo +# status call would publish a timestamp that a hostile prompt helper could use +# even when the user declines the grant. Exercise the public flow with a sudo +# 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" -cat >"$mock_bin/sudo" <<'SH' +public_sudo_stub="$test_tmp/public-sudo" +public_gum_stub="$test_tmp/public-gum" +public_token="$test_tmp/public-token" +public_exploit="$test_tmp/public-exploit" +cat >"$public_sudo_stub" <<'STUB' #!/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) +if [[ ${1:-} == -h ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' + exit 0 +fi +if [[ ${1:-} == -k ]]; then + rm -f -- "$TEST_PUBLIC_TOKEN" exit 0 - ;; -*) - echo "unexpected sudo command: $*" >&2 - exit 90 - ;; +fi +no_update=0 +if [[ ${1:-} == -N ]]; then no_update=1; shift; fi +[[ ${1:-} != -- ]] || shift +((no_update)) || : >"$TEST_PUBLIC_TOKEN" +case "${2:-}" in + __status) exit 1 ;; + __enable|__disable) exit 0 ;; + *) exit 2 ;; esac -SH +STUB +cat >"$public_gum_stub" <<'STUB' +#!/bin/bash +[[ ! -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 \ + -e "s#/usr/bin/sudo#$public_sudo_stub#g" \ + -e "s#/usr/bin/gum#$public_gum_stub#g" \ + "$command_path" >"$public_flow" +chmod 0755 "$public_flow" +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" -chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" +startup_env="$test_tmp/passwordless-bash-env" +startup_marker="$test_tmp/passwordless-bash-env-ran" +cat >"$startup_env" <<'STUB' +: >"$TEST_STARTUP_MARKER" +set -o privileged +unset BASH_ENV +STUB +if BASH_ENV="$startup_env" TEST_STARTUP_MARKER="$startup_marker" \ + /usr/bin/bash "$public_flow" -p >/dev/null 2>&1; then + fail "passwordless sudo accepted an unsafe interpreter with a decoy -p" +fi +[[ -e $startup_marker && ! -e $public_token && ! -e $public_exploit ]] || + fail "unsafe passwordless startup reached its sudo workflow" +pass "passwordless confirmation uses a cold command-scoped credential boundary" -run_command() { - TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ - "$script" "$@" -} +# Source a path-rewritten copy so the real cleanup implementation can be +# exercised without touching /etc. Exact generated numeric rules are removed +# even after account deletion or a crash before state publication. Anything an +# administrator changed, and every symlink, is preserved. +fake_sudoers="$test_tmp/sudoers.d" +mkdir "$fake_sudoers" +rewritten="$test_tmp/passwordless-lib.sh" +function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten" +( + source "$rewritten" + printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243" + ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244" + remove_known_legacy_rules +) +[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] || + fail "boot cleanup removes a state-less numeric orphan" +[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] || + fail "boot cleanup preserves administrator-authored policy" +[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] || + fail "boot cleanup refuses sudoers symlinks" +pass "boot cleanup removes crash/deleted-account orphans conservatively" -: >"$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" +# A boot gate must not report success when deletion itself fails. Exercise the +# real cleanup and post-cleanup verification with a deterministic failing rm. +rm_failure_dir="$test_tmp/rm-failure-sudoers" +mkdir "$rm_failure_dir" +printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245" +failing_rm="$test_tmp/failing-rm" +cat >"$failing_rm" <<'FAILING_RM' +#!/bin/bash +exit 1 +FAILING_RM +chmod +x "$failing_rm" +rm_failure_lib="$test_tmp/rm-failure-lib.sh" +function_prefix | + sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \ + -e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \ + -e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib" +mkdir "$test_tmp/empty-state" +( + source "$rm_failure_lib" + ! cleanup_all_locked +) || fail "boot cleanup fails when an Omarchy rule cannot be removed" +[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] || + fail "rm-failure fixture remains available for verification" +pass "boot cleanup fails closed when policy deletion fails" + +# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite +# only the read-only EUID probe so this unprivileged test can exercise the root +# dispatcher, then assert that cleanup (which can only revoke privilege) runs. +dispatch_lib="$test_tmp/dispatch-lib.sh" +function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib" +( + source "$dispatch_lib" + called="" + cleanup_all_locked() { called=cleanup; } + with_root_lock() { "$@"; } + TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all + [[ $called == cleanup ]] +) || fail "migration cleanup dispatch accepts authenticated sudo provenance" +pass "migration can invoke fail-closed cleanup through sudo" + +# A grant cannot be published until the static unit is verified/enabled, and a +# timer setup failure removes its pending state without calling publish_rule. +transaction_dir="$test_tmp/transaction" +mkdir "$transaction_dir" +transaction_lib="$test_tmp/transaction-lib.sh" +function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib" +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 1; } + publish_rule() { return 99; } + ! enable_locked 1000 15 +) +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + publish_rule() { printf published >"$transaction_dir/published"; } + cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published && + -e $transaction_dir/failed-timer-cleanup ]] +) || fail "passwordless sudo fails closed on prerequisite/timer failure" +pass "passwordless sudo publishes no rule after partial setup failure" -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" +# Erik's predecessor fix revoked an already-active grant when an extension +# could not arm its replacement timer. Keep that fail-closed property while +# the new transaction deliberately leaves the old timer armed until the new +# one is verified. +replacement_state="$transaction_dir/1000.state" +replacement_rule="$transaction_dir/1000.rule" +replacement_stopped="$transaction_dir/old-timer-stopped" +old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule" +( + source "$transaction_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + state_file() { printf '%s' "$replacement_state"; } + rule_file() { printf '%s' "$replacement_rule"; } + prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; } + ! enable_locked 1000 30 + [[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]] +) || fail "passwordless sudo leaves an existing grant live after replacement timer failure" +pass "replacement timer failure revokes the existing grant" -fake_root="$test_tmp/root" +# Expiry is a wall-clock promise, so the transient timer must carry the exact +# absolute epoch recorded in root state. A monotonic-only --on-active timer +# pauses during suspend and can otherwise extend a short grant by hours. +timer_args="$test_tmp/timer-args" +calendar_systemd_run="$test_tmp/calendar-systemd-run" +calendar_systemctl="$test_tmp/calendar-systemctl" +cat >"$calendar_systemd_run" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TIMER_ARGS" +STUB +cat >"$calendar_systemctl" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$calendar_systemd_run" "$calendar_systemctl" +calendar_lib="$test_tmp/calendar-lib.sh" +function_prefix | + sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib" +( + source "$calendar_lib" + TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \ + omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +) || fail "passwordless sudo cannot arm its absolute expiry timer" +grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null || + fail "passwordless sudo timer does not advance across suspend" +pass "passwordless sudo arms the recorded absolute wall-clock expiry" + +# A resumed machine can briefly observe the timer as active before systemd +# dispatches its overdue service. Status must independently enforce EXPIRES and +# synchronously remove policy instead of trusting timer activity alone. +expired_state="$test_tmp/expired-state" +expired_sudoers="$test_tmp/expired-sudoers" +mkdir "$expired_state" "$expired_sudoers" +expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000" +expired_lib="$test_tmp/expired-lib.sh" +function_prefix | + sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \ + -e "s#/etc/sudoers.d#$expired_sudoers#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib" +( + source "$expired_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + ! status_locked 1000 +) || fail "passwordless sudo accepts expired root state while its timer is active" +[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] || + fail "passwordless sudo does not synchronously revoke expired state" +pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch" + +# If the transient timer fires between its first active check and publication, +# the just-created rule must be synchronously revoked instead of surviving to +# reboot. Model that narrow transition with the real enable transaction. +inactive_systemctl="$test_tmp/inactive-systemctl" +cat >"$inactive_systemctl" <<'STUB' +#!/bin/bash +exit 1 +STUB +chmod 0755 "$inactive_systemctl" +post_publish_lib="$test_tmp/post-publish-lib.sh" +sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib" +( + source "$post_publish_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 0; } + publish_rule() { : >"$transaction_dir/published-after-arm"; } + cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]] +) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes" +pass "timer expiry during publication revokes the grant synchronously" + +# Follow the maintainer's package-owned tmpfiles design: one boot-only rule +# owns this filename namespace. A routine --remove leaves live grants alone; +# early boot removes them before a user can log in. The migration only revokes +# legacy runtime state and never writes static policy into /usr. +mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path") +(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule" +[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || + fail "passwordless sudo boot cleanup does not own the exact generated namespace" +fake_root="$test_tmp/tmpfiles-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" +for name in alice buildbot-2 424242; do + : >"$sudoers_dir/99-omarchy-nopasswd-$name" done -touch "$sudoers_dir/omarchy-dns" +: >"$sudoers_dir/omarchy-dns" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[0]}" +[[ -e $sudoers_dir/99-omarchy-nopasswd-alice ]] || fail "non-boot tmpfiles run shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[0]}" +! find "$sudoers_dir" -name '99-omarchy-nopasswd-*' -print -quit | /usr/bin/grep -q . || + fail "boot cleanup left a generated passwordless grant" +[[ -e $sudoers_dir/omarchy-dns ]] || fail "boot cleanup removed an unrelated sudoers rule" +/usr/bin/grep -Fx 'sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all' "$migration_path" >/dev/null +! /usr/bin/grep -q 'omarchy-sudo-passwordless-cleanup.service' "$migration_path" || + fail "migration retained a custom boot service instead of package-owned tmpfiles" +pass "package-owned boot cleanup is narrow, boot-only, and migration-safe" -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" +# Removing the settings package also removes the tmpfiles rule. Its package +# lifecycle must therefore revoke the same owned namespace synchronously, while +# preserving every unrelated sudoers file. +pkgs_candidates=( + "${OMARCHY_PKGS_PATH:-}" + "$ROOT/../omarchy-pkgs" + "$ROOT/../../omarchy-pkgs" + "$HOME/Work/omarchy/omarchy-pkgs" + "$HOME/Work/omacom/omarchy-pkgs" +) +pkgs_root="" +for candidate in "${pkgs_candidates[@]}"; do + if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then + pkgs_root=$candidate + break + fi +done +[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" -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" +for package_name in omarchy-settings omarchy-settings-dev; do + install_script="$pkgs_root/pkgbuilds/$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" + : >"$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" + ( + source "$transformed_install" + 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" + : >"$removal_sudoers/99-omarchy-nopasswd-1001" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal handles administrator OS selector state" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + 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" done -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +pass "settings package removal revokes grants and preserves package-selector ownership" + +# Exercise the production flock wrapper under contention. mkdir is an atomic +# overlap detector; all workers must enter and leave the protected region. +lock_dir="$test_tmp/lock-runtime" +mkdir "$lock_dir" +lock_lib="$test_tmp/lock-lib.sh" +function_prefix | + sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \ + -e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \ + -e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib" +worker="$test_tmp/worker.sh" +cat >"$worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$LOCK_LIB" +prepare_root_state() { :; } +critical() { + mkdir "$LOCK_SENTINEL" + sleep 0.03 + rmdir "$LOCK_SENTINEL" + printf x >>"$LOCK_RESULTS" +} +with_root_lock critical +WORKER +chmod +x "$worker" +for _ in {1..8}; do + LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" & +done +wait +[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" +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 +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) +((rm_line < stop_line)) || fail "expiry removes sudo policy before timer cleanup" +pass "same-boot expiration is fixed-target and fail closed" From 2f816a83af81e6fcbe4196860c958b99880e6050 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 1 Sep 2026 21:35:15 +0100 Subject: [PATCH 02/10] Document privileged Bash startup exception --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e4a0084eadf..6de19cb9a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience: - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) -- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) +- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming From bfb765569d4e491b7f68f71cb14db324800eb9e5 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:51 +0100 Subject: [PATCH 03/10] Keep debug collectors outside dmesg authorization --- AGENTS.md | 2 +- bin/omarchy-debug | 61 ++++++- bin/omarchy-security-functions | 87 +++++++++ test/shell.d/debug-sudo-security-test.sh | 223 +++++++++++++++++++++++ 4 files changed, 364 insertions(+), 9 deletions(-) create mode 100755 bin/omarchy-security-functions create mode 100644 test/shell.d/debug-sudo-security-test.sh diff --git a/AGENTS.md b/AGENTS.md index e4a0084eadf..6de19cb9a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience: - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) -- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) +- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-debug b/bin/omarchy-debug index 59fbc4fce48..d26587b9cf0 100755 --- a/bin/omarchy-debug +++ b/bin/omarchy-debug @@ -1,10 +1,20 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Print debugging information # omarchy:args=[--no-sudo] [--print] # omarchy:examples=omarchy debug --print --no-sudo # omarchy:requires-sudo=true +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for debug collection." >&2 + exit 126 +} +unset BASH_ENV ENV + +set -uo pipefail + NO_SUDO=false PRINT_ONLY=false @@ -26,15 +36,51 @@ while (( $# > 0 )); do esac done -LOG_FILE="/tmp/omarchy-debug.log" +# OM-SEC-06 owns this private staging change in #8370. Keep the same shape so +# this stacked branch can focus on the later sudo-credential boundary. +log_dir="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" +LOG_FILE="$log_dir/omarchy-debug.log" +if ! mkdir -p "$log_dir" || ! install -m 600 /dev/null "$LOG_FILE"; then + echo "Error: Failed to create $LOG_FILE" >&2 + exit 1 +fi + +SUDO_BOUNDARY_ACTIVE=false +cleanup_debug_authorization() { + local status=$? + + trap - EXIT HUP INT TERM + if [[ $SUDO_BOUNDARY_ACTIVE == true ]]; then + omarchy_security_exit_with_revoked_sudo "$status" + fi + exit "$status" +} +trap cleanup_debug_authorization EXIT +omarchy_security_install_signal_exit_traps -if [[ $NO_SUDO = "true" ]]; then +if [[ $NO_SUDO == true ]]; then DMESG_OUTPUT="(skipped - --no-sudo flag used)" else - DMESG_OUTPUT="$(sudo dmesg)" + omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing privileged debug collection." >&2 + exit 1 + } + omarchy_security_revoke_sudo_timestamp || { + echo "Could not invalidate cached sudo authorization." >&2 + exit 1 + } + SUDO_BOUNDARY_ACTIVE=true + if ! DMESG_OUTPUT=$(/usr/bin/sudo -N -- /usr/bin/dmesg); then + echo "Could not collect the kernel log through command-scoped sudo." >&2 + exit 1 + fi + omarchy_security_revoke_sudo_timestamp || { + echo "Could not invalidate cached sudo authorization." >&2 + exit 1 + } fi -cat > "$LOG_FILE" <"$LOG_FILE" </dev/null || pacman -Q omarchy 2>/dev/null || echo "unknown") @@ -60,7 +106,7 @@ INSTALLED PACKAGES $({ expac -S '%n %v (%r)' $(pacman -Qqe) 2>/dev/null; comm -13 <(pacman -Sql | sort) <(pacman -Qqe | sort) | xargs -r expac -Q '%n %v (AUR)'; } | sort) EOF -if [[ $PRINT_ONLY = "true" ]]; then +if [[ $PRINT_ONLY == true ]]; then cat "$LOG_FILE" exit 0 fi @@ -75,8 +121,7 @@ ACTION=$(gum choose "${OPTIONS[@]}") case "$ACTION" in "Upload log") echo "Uploading debug log to logs.omarchy.org..." - URL=$(curl -sf -F "file=@$LOG_FILE" -Fexpires=24 https://logs.omarchy.org/) - if (( $? == 0 )) && [[ -n $URL ]]; then + if URL=$(curl -sf -F "file=@$LOG_FILE" -Fexpires=24 https://logs.omarchy.org/) && [[ -n $URL ]]; then echo "✓ Log uploaded successfully!" echo "Share this URL:" echo "" diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000000..2f3d2242ec5 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,87 @@ +#!/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. + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + local pid=${1:-$$} + + [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || 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" +} + +omarchy_security_sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k >/dev/null 2>&1 +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${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 + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh new file mode 100644 index 00000000000..f19a795a69a --- /dev/null +++ b/test/shell.d/debug-sudo-security-test.sh @@ -0,0 +1,223 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command unshare +require_command setpriv +require_command cc + +if [[ ${OMARCHY_DEBUG_SUDO_SECURITY_NS:-0} != 1 ]]; then + outer_uid=$(id -u) + outer_gid=$(id -g) + subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) + subgid=$(awk -F: -v group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping debug sudo proof" + exit 0 + fi + exec unshare --user --mount \ + --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ + env OMARCHY_DEBUG_SUDO_SECURITY_NS=1 bash "$0" +fi + +[[ $(id -u) == 0 ]] || fail "debug proof did not enter its root namespace" + +test_tmp=$(mktemp -d) +mount -t tmpfs -o mode=0755,suid tmpfs "$test_tmp" +stub_bin="$test_tmp/bin" +script_dir="$test_tmp/scripts" +test_home="$test_tmp/home" +root_dir="$test_tmp/root" +event_log="$test_tmp/events" +token="$test_tmp/sudo-token" +victim="$root_dir/published" +armed="$test_tmp/waiter-armed" +mkdir -p "$stub_bin" "$script_dir" "$test_home/runtime" "$root_dir" +touch "$event_log" +chown -R 1000:1000 "$test_home" "$event_log" +chmod 0700 "$test_home" "$test_home/runtime" +chmod 0755 "$test_tmp" "$stub_bin" "$script_dir" "$root_dir" +chmod 0600 "$event_log" + +cleanup() { + local status=$? + trap - EXIT + rm -f "$armed" + [[ ! -s $test_home/waiter.pid ]] || kill "$(<"$test_home/waiter.pid")" 2>/dev/null || true + rm -rf "$test_tmp"/* 2>/dev/null || true + umount -l "$test_tmp" 2>/dev/null || true + rmdir "$test_tmp" 2>/dev/null || true + exit "$status" +} +trap cleanup EXIT + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include + +static const char *need(const char *name) { + const char *value = getenv(name); + if (!value || !*value) exit(125); + return value; +} + +static void event(const char *message) { + int fd = open(need("TEST_EVENT_LOG"), O_WRONLY | O_APPEND); + if (fd < 0 || dprintf(fd, "%s\n", message) < 0) exit(125); + close(fd); +} + +int main(int argc, char **argv) { + const char *token = need("TEST_SUDO_TOKEN"); + int index = 1, no_update = 0, noninteractive = 0, fd; + if (argc == 2 && !strcmp(argv[1], "-h")) { + if (getenv("TEST_SUDO_NO_N")) puts("usage: sudo [-ABbEHknPS] command"); + else puts("usage: sudo [-ABbEHkNnPS] command"); + return 0; + } + if (argc == 2 && !strcmp(argv[1], "-k")) { + event("invalidate"); + if (unlink(token) && errno != ENOENT) return 121; + fd = open(need("TEST_WAITER_ARMED"), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 122; + close(fd); + return 0; + } + if (index < argc && !strcmp(argv[index], "-N")) { no_update = 1; index++; } + if (index < argc && !strcmp(argv[index], "-n")) { noninteractive = 1; index++; } + if (index < argc && !strcmp(argv[index], "--")) index++; + if (noninteractive && access(token, F_OK)) return 1; + if (no_update) { + event("grant-no-update"); + } else if (!noninteractive) { + event("publish-token"); + fd = open(token, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 123; + close(fd); + usleep(200000); + } + if (index >= argc || setgid(0) || setuid(0)) return 124; + if (!strcmp(argv[index], "/usr/bin/dmesg")) { + puts("modeled kernel log"); + return 0; + } + execv(argv[index], &argv[index]); + return 126; +} +C +cc -O2 -Wall -Wextra -o "$stub_bin/sudo" "$test_tmp/sudo.c" +chown 0:0 "$stub_bin/sudo" +chmod 4755 "$stub_bin/sudo" + +cat >"$stub_bin/inxi" <<'STUB' +#!/bin/bash +: >"$TEST_COLLECTOR_RAN" +printf 'harmless inxi output\n' +STUB +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +case "$*" in + '-Q omarchy-dev') printf 'omarchy-dev audit\n' ;; + '-Qqe'|'-Sql') : ;; + *) exit 1 ;; +esac +STUB +for command in journalctl expac; do + printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command" +done +chmod 0755 "$stub_bin/inxi" "$stub_bin/pacman" "$stub_bin/journalctl" "$stub_bin/expac" + +sed "s#/usr/bin/sudo#$stub_bin/sudo#g" \ + "$ROOT/bin/omarchy-security-functions" >"$script_dir/omarchy-security-functions" +sed "s#/usr/bin/sudo#$stub_bin/sudo#g" \ + "$ROOT/bin/omarchy-debug" >"$script_dir/omarchy-debug" +chmod 0755 "$script_dir/omarchy-security-functions" "$script_dir/omarchy-debug" + +printf 'debug-payload\n' >"$test_home/payload" +chown 1000:1000 "$test_home/payload" +chmod 0600 "$test_home/payload" + +start_waiter() { + rm -f "$victim" "$test_home/reused" "$test_home/waiter.pid" + setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" TEST_SUDO="$stub_bin/sudo" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_PAYLOAD="$test_home/payload" TEST_VICTIM="$victim" \ + bash -c ' + echo $$ >"$HOME/waiter.pid" + while [[ ! -e $TEST_WAITER_ARMED ]]; do /usr/bin/sleep 0.005; done + while [[ -e $TEST_WAITER_ARMED ]]; do + if "$TEST_SUDO" -n -- /usr/bin/install -o 0 -g 0 -m 0600 "$TEST_PAYLOAD" "$TEST_VICTIM" 2>/dev/null; then + : >"$HOME/reused" + exit 0 + fi + /usr/bin/sleep 0.005 + done + ' & +} + +run_debug() { + local command=$1 + shift + setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" \ + PATH="$stub_bin:/usr/bin:/bin" TEST_SUDO_TOKEN="$token" \ + TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_COLLECTOR_RAN="$test_home/collector" "$@" "$command" --print >/dev/null +} + +: >"$event_log" +: >"$token" +chown 1000:1000 "$token" +rm -f "$armed" "$test_home/collector" +start_waiter +run_debug "$script_dir/omarchy-debug" +rm -f "$armed" +wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true +[[ -e $test_home/collector && ! -e $victim && ! -e $test_home/reused && ! -e $token ]] || + fail "debug collector reused or retained sudo authorization" +grep -qxF grant-no-update "$event_log" || fail "debug dmesg did not use sudo --no-update" +[[ $(grep -c '^invalidate$' "$event_log") -ge 3 ]] || fail "debug did not invalidate at entry, after dmesg, and cleanup" +pass "debug starts cold and keeps collectors outside command-scoped dmesg authorization" + +: >"$event_log" +rm -f "$token" "$armed" "$test_home/collector" +mutant="$script_dir/omarchy-debug-mutant" +sed "s#$stub_bin/sudo -N --#$stub_bin/sudo --#" "$script_dir/omarchy-debug" >"$mutant" +chmod 0755 "$mutant" +start_waiter +run_debug "$mutant" +rm -f "$armed" +wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true +[[ -e $test_home/reused && -e $victim ]] || fail "removing -N did not restore the modeled credential race" +pass "sudo --no-update is mutation-tested as the load-bearing race guard" + +: >"$event_log" +rm -f "$token" "$armed" "$test_home/collector" "$victim" +if run_debug "$script_dir/omarchy-debug" TEST_SUDO_NO_N=1; then + fail "debug accepted sudo without --no-update support" +fi +[[ ! -e $test_home/collector && ! -e $token && ! -e $victim ]] || + fail "unsupported sudo reached user-resolved collectors" +pass "unsupported sudo fails before user-resolved collection" + +bash_env="$test_home/bash-env" +startup_marker="$test_home/bash-env-ran" +printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\n' >"$bash_env" +if setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" BASH_ENV="$bash_env" \ + TEST_STARTUP_MARKER="$startup_marker" TEST_SUDO_TOKEN="$token" \ + TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + /usr/bin/bash "$script_dir/omarchy-debug" -p --print >/dev/null 2>&1; then + fail "debug accepted an ordinary Bash launch with a decoy -p" +fi +[[ -e $startup_marker && ! -e $token && ! -e $victim ]] || + fail "unsafe Bash startup reached the privileged debug workflow" +pass "privileged Bash startup suppresses BASH_ENV before the first command" From 6df92a0baf3f3b902e6ab26d44c223d3e5c806dc Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 3 Sep 2026 11:23:00 +0100 Subject: [PATCH 04/10] Use login name for subordinate GID lookup --- test/shell.d/debug-sudo-security-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh index f19a795a69a..cfe3a5992ac 100644 --- a/test/shell.d/debug-sudo-security-test.sh +++ b/test/shell.d/debug-sudo-security-test.sh @@ -12,7 +12,7 @@ if [[ ${OMARCHY_DEBUG_SUDO_SECURITY_NS:-0} != 1 ]]; then outer_uid=$(id -u) outer_gid=$(id -g) subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) - subgid=$(awk -F: -v group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) + subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) if [[ -z $subuid || -z $subgid ]]; then pass "no subordinate uid/gid range; skipping debug sudo proof" exit 0 From c0a19e4f7a3c504f2b5439cb51c36e0b5e16a2cf Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 5 Sep 2026 18:02:50 +0100 Subject: [PATCH 05/10] Harden debug authorization boundary --- bin/omarchy-debug | 71 +++++--- bin/omarchy-security-functions | 12 +- test/shell.d/debug-sudo-security-test.sh | 210 ++++++++++++++++++++++- 3 files changed, 260 insertions(+), 33 deletions(-) diff --git a/bin/omarchy-debug b/bin/omarchy-debug index d26587b9cf0..b88aef2c1d8 100755 --- a/bin/omarchy-debug +++ b/bin/omarchy-debug @@ -5,13 +5,29 @@ # omarchy:examples=omarchy debug --print --no-sudo # omarchy:requires-sudo=true -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 - -omarchy_security_require_privileged_bash_startup || { - echo "Refusing an unsafe Bash startup for debug collection." >&2 - exit 126 -} -unset BASH_ENV ENV +# The privileged shebang prevents Bash startup hooks from running before this +# boundary. Run this as its own script (a clean `bash -p script` is equivalent), +# never by sourcing it into -c, stdin, an interactive shell, or another script. +# An ordinary interpreter launch can turn privileged mode on from BASH_ENV or +# exported SHELLOPTS, so inspect those immutable initial-environment traces +# with shell syntax alone, before trusting a function or command. Bash strips +# NUL separators in command substitution; an unusual unrelated name or value +# containing `BASH_ENV=` therefore fails closed, while common `VIRTUAL_ENV` +# state does not collide. +if [[ $- == *p* && $- != *[cis]* && ${BASH_SOURCE[0]} == "$0" && -r /proc/$$/environ ]] && + (( ${#BASH_SOURCE[@]} == 1 )) && { + case $(/dev/null; then + source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 + + omarchy_security_require_privileged_bash_startup "${BASH_SOURCE[0]}" || { + echo "Refusing an unsafe Bash startup for debug collection." >&2 + exit 126 + } +unset BASH_ENV ENV PS4 CDPATH GLOBIGNORE set -uo pipefail @@ -36,20 +52,12 @@ while (( $# > 0 )); do esac done -# OM-SEC-06 owns this private staging change in #8370. Keep the same shape so -# this stacked branch can focus on the later sudo-credential boundary. -log_dir="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" -LOG_FILE="$log_dir/omarchy-debug.log" -if ! mkdir -p "$log_dir" || ! install -m 600 /dev/null "$LOG_FILE"; then - echo "Error: Failed to create $LOG_FILE" >&2 - exit 1 -fi - SUDO_BOUNDARY_ACTIVE=false cleanup_debug_authorization() { local status=$? - trap - EXIT HUP INT TERM + trap - EXIT + trap '' HUP INT TERM if [[ $SUDO_BOUNDARY_ACTIVE == true ]]; then omarchy_security_exit_with_revoked_sudo "$status" fi @@ -57,6 +65,15 @@ cleanup_debug_authorization() { } trap cleanup_debug_authorization EXIT omarchy_security_install_signal_exit_traps +SUDO_BOUNDARY_ACTIVE=true + +# Invalidate before any caller-selected path is consumed or any PATH-resolved +# collector runs. Cleanup owns another invalidation from this point onward, +# including if this first attempt is interrupted. +omarchy_security_revoke_sudo_timestamp || { + echo "Could not invalidate cached sudo authorization." >&2 + exit 1 +} if [[ $NO_SUDO == true ]]; then DMESG_OUTPUT="(skipped - --no-sudo flag used)" @@ -65,11 +82,6 @@ else echo "This sudo does not support --no-update; refusing privileged debug collection." >&2 exit 1 } - omarchy_security_revoke_sudo_timestamp || { - echo "Could not invalidate cached sudo authorization." >&2 - exit 1 - } - SUDO_BOUNDARY_ACTIVE=true if ! DMESG_OUTPUT=$(/usr/bin/sudo -N -- /usr/bin/dmesg); then echo "Could not collect the kernel log through command-scoped sudo." >&2 exit 1 @@ -80,6 +92,15 @@ else } fi +# OM-SEC-06 owns this private staging change in #8370. Keep its -T guard so a +# directory at the fixed log name is rejected rather than accepted by install. +log_dir="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" +LOG_FILE="$log_dir/omarchy-debug.log" +if ! /usr/bin/mkdir -p "$log_dir" || ! /usr/bin/install -T -m 600 /dev/null "$LOG_FILE"; then + echo "Error: Failed to create $LOG_FILE" >&2 + exit 1 +fi + cat >"$LOG_FILE" <&2 (( status != 0 )) || status=1 diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh index cfe3a5992ac..350cc255c3a 100644 --- a/test/shell.d/debug-sudo-security-test.sh +++ b/test/shell.d/debug-sudo-security-test.sh @@ -35,6 +35,7 @@ event_log="$test_tmp/events" token="$test_tmp/sudo-token" victim="$root_dir/published" armed="$test_tmp/waiter-armed" +staging_marker="$test_home/staging-command-ran" mkdir -p "$stub_bin" "$script_dir" "$test_home/runtime" "$root_dir" touch "$event_log" chown -R 1000:1000 "$test_home" "$event_log" @@ -84,6 +85,13 @@ int main(int argc, char **argv) { } if (argc == 2 && !strcmp(argv[1], "-k")) { event("invalidate"); + const char *delay_marker = getenv("TEST_DELAY_INVALIDATE_MARKER"); + if (delay_marker && *delay_marker) { + fd = open(delay_marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 120; + close(fd); + usleep(500000); + } if (unlink(token) && errno != ENOENT) return 121; fd = open(need("TEST_WAITER_ARMED"), O_WRONLY | O_CREAT | O_TRUNC, 0600); if (fd < 0) return 122; @@ -119,6 +127,7 @@ chmod 4755 "$stub_bin/sudo" cat >"$stub_bin/inxi" <<'STUB' #!/bin/bash : >"$TEST_COLLECTOR_RAN" +printf 'collector\n' >>"$TEST_EVENT_LOG" printf 'harmless inxi output\n' STUB cat >"$stub_bin/pacman" <<'STUB' @@ -132,7 +141,16 @@ STUB for command in journalctl expac; do printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command" done -chmod 0755 "$stub_bin/inxi" "$stub_bin/pacman" "$stub_bin/journalctl" "$stub_bin/expac" +for command in mkdir install; do + cat >"$stub_bin/$command" <<'STUB' +#!/bin/bash +: >"$TEST_STAGING_MARKER" +"$TEST_REAL_SUDO" -n -- /usr/bin/install -o 0 -g 0 -m 0600 "$TEST_PAYLOAD" "$TEST_VICTIM" 2>/dev/null || : +exit 99 +STUB +done +chmod 0755 "$stub_bin/inxi" "$stub_bin/pacman" "$stub_bin/journalctl" "$stub_bin/expac" \ + "$stub_bin/mkdir" "$stub_bin/install" sed "s#/usr/bin/sudo#$stub_bin/sudo#g" \ "$ROOT/bin/omarchy-security-functions" >"$script_dir/omarchy-security-functions" @@ -168,24 +186,28 @@ run_debug() { shift setpriv --reuid=1000 --regid=1000 --clear-groups \ env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" \ - PATH="$stub_bin:/usr/bin:/bin" TEST_SUDO_TOKEN="$token" \ + PATH="$stub_bin:/usr/bin:/bin" VIRTUAL_ENV="$test_home/venv" TEST_SUDO_TOKEN="$token" \ TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ - TEST_COLLECTOR_RAN="$test_home/collector" "$@" "$command" --print >/dev/null + TEST_COLLECTOR_RAN="$test_home/collector" TEST_STAGING_MARKER="$staging_marker" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_PAYLOAD="$test_home/payload" TEST_VICTIM="$victim" \ + "$@" "$command" --print >/dev/null } : >"$event_log" : >"$token" chown 1000:1000 "$token" -rm -f "$armed" "$test_home/collector" +rm -f "$armed" "$test_home/collector" "$staging_marker" start_waiter run_debug "$script_dir/omarchy-debug" rm -f "$armed" wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true [[ -e $test_home/collector && ! -e $victim && ! -e $test_home/reused && ! -e $token ]] || fail "debug collector reused or retained sudo authorization" +[[ ! -e $staging_marker ]] || fail "debug resolved a staging command through hostile PATH" +[[ $(head -n 1 "$event_log") == invalidate ]] || fail "debug ran a collector before cold invalidation" grep -qxF grant-no-update "$event_log" || fail "debug dmesg did not use sudo --no-update" [[ $(grep -c '^invalidate$' "$event_log") -ge 3 ]] || fail "debug did not invalidate at entry, after dmesg, and cleanup" -pass "debug starts cold and keeps collectors outside command-scoped dmesg authorization" +pass "debug pins staging, starts cold, and keeps collectors outside command-scoped dmesg authorization" : >"$event_log" rm -f "$token" "$armed" "$test_home/collector" @@ -208,16 +230,188 @@ fi fail "unsupported sudo reached user-resolved collectors" pass "unsupported sudo fails before user-resolved collection" +: >"$event_log" +: >"$token" +chown 1000:1000 "$token" +rm -f "$armed" "$test_home/collector" "$victim" +no_sudo_output=$(setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" \ + PATH="$stub_bin:/usr/bin:/bin" TEST_SUDO_TOKEN="$token" \ + TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_COLLECTOR_RAN="$test_home/collector" TEST_STAGING_MARKER="$staging_marker" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_PAYLOAD="$test_home/payload" TEST_VICTIM="$victim" \ + "$script_dir/omarchy-debug" --no-sudo --print) +[[ $no_sudo_output == *"(skipped - --no-sudo flag used)"* && -e $test_home/collector && ! -e $token ]] || + fail "--no-sudo no longer skips dmesg while collecting the user report" +! grep -q '^grant-no-update$' "$event_log" || fail "--no-sudo invoked privileged dmesg" +[[ $(grep -c '^invalidate$' "$event_log") -ge 2 ]] || fail "--no-sudo did not protect collectors from a cached token" +[[ $(stat -c '%a' "$test_home/runtime/omarchy-debug.log") == 600 ]] || fail "debug log is not private" +pass "--no-sudo skips dmesg, revokes cached credentials, and writes a private report" + bash_env="$test_home/bash-env" startup_marker="$test_home/bash-env-ran" -printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\n' >"$bash_env" +cat >"$bash_env" <<'BASH_ENV' +: >"$TEST_STARTUP_MARKER" +set -o privileged +shift +function /usr/bin/env { return 0; } +function /usr/bin/readlink { printf '/usr/bin/bash\n'; } +function /usr/bin/sudo { + local -a forwarded=() + local argument + for argument in "$@"; do + [[ $argument == -N ]] || forwarded+=("$argument") + done + "$TEST_REAL_SUDO" "${forwarded[@]}" +} +trap 'unset BASH_ENV; set -o privileged' DEBUG +BASH_ENV +: >"$event_log" +: >"$token" +chown 1000:1000 "$token" +rm -f "$startup_marker" "$victim" "$test_home/collector" +startup_padding=$(printf '%65536s' '') if setpriv --reuid=1000 --regid=1000 --clear-groups \ env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" BASH_ENV="$bash_env" \ TEST_STARTUP_MARKER="$startup_marker" TEST_SUDO_TOKEN="$token" \ TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ + TEST_STARTUP_PADDING="$startup_padding" \ /usr/bin/bash "$script_dir/omarchy-debug" -p --print >/dev/null 2>&1; then fail "debug accepted an ordinary Bash launch with a decoy -p" fi -[[ -e $startup_marker && ! -e $token && ! -e $victim ]] || +[[ -e $startup_marker && -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || fail "unsafe Bash startup reached the privileged debug workflow" -pass "privileged Bash startup suppresses BASH_ENV before the first command" +pass "immutable startup guard rejects oversized shift, trap, and slash-function injection before the workflow" + +: >"$event_log" +rm -f "$test_home/collector" +if setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" MY_BASH_ENV=/dev/null \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_COLLECTOR_RAN="$test_home/collector" "$script_dir/omarchy-debug" --print \ + >/dev/null 2>&1; then + fail "debug accepted an ambiguous BASH_ENV substring" +fi +[[ ! -s $event_log && ! -e $test_home/collector ]] || + fail "ambiguous BASH_ENV state reached the debug workflow" +pass "ambiguous BASH_ENV-like names fail closed without rejecting VIRTUAL_ENV" + +: >"$event_log" +: >"$token" +chown 1000:1000 "$token" +rm -f "$test_home/collector" "$victim" +if setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$script_dir/omarchy-debug" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ + /usr/bin/bash -c ' + set -o privileged + function /usr/bin/env { return 0; } + function /usr/bin/readlink { printf "/usr/bin/bash\n"; } + function /usr/bin/sudo { + local -a forwarded=() + local argument + for argument in "$@"; do + [[ $argument == -N ]] || forwarded+=("$argument") + done + "$TEST_REAL_SUDO" "${forwarded[@]}" + } + BASH_ARGV0=$TARGET + source "$TARGET" --print + ' "$script_dir/omarchy-debug" >/dev/null 2>&1; then + fail "debug accepted a forged -c source launch" +fi +[[ -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || + fail "forged same-shell startup reached the privileged debug workflow" +pass "debug rejects pre-executed same-shell code even when it forges script identity" + +interactive_continued="$test_home/interactive-continued" +: >"$event_log" +: >"$token" +chown 1000:1000 "$token" +rm -f "$interactive_continued" "$test_home/collector" "$victim" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$script_dir/omarchy-debug" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ + TEST_INTERACTIVE_CONTINUED="$interactive_continued" \ + /usr/bin/bash --noprofile --norc -i >/dev/null 2>&1 <<'INTERACTIVE' +set -o privileged +function /usr/bin/env { return 0; } +function /usr/bin/sudo { "$TEST_REAL_SUDO" "$@"; } +BASH_ARGV0=$TARGET +source "$TARGET" --print +: >"$TEST_INTERACTIVE_CONTINUED" +exit +INTERACTIVE +[[ -e $interactive_continued && -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || + fail "interactive source continued into the privileged debug workflow" +pass "unsafe interactive source returns to its shell without entering the workflow" + +unreadable_environment="$root_dir/unreadable-environ" +unreadable_script="$script_dir/omarchy-debug-unreadable-proc" +: >"$unreadable_environment" +chmod 000 "$unreadable_environment" +sed 's#/proc/\$\$/environ#${TEST_PROC_ENVIRONMENT}#g' \ + "$script_dir/omarchy-debug" >"$unreadable_script" +chmod 0755 "$unreadable_script" +: >"$event_log" +rm -f "$test_home/collector" +if setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_PROC_ENVIRONMENT="$unreadable_environment" TEST_SUDO_TOKEN="$token" \ + TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_COLLECTOR_RAN="$test_home/collector" "$unreadable_script" --print \ + >/dev/null 2>&1; then + fail "debug accepted an unreadable initial environment boundary" +fi +[[ ! -s $event_log && ! -e $test_home/collector ]] || + fail "unreadable initial environment reached the debug workflow" +pass "unreadable initial environment state fails closed" + +signal_script="$script_dir/signal-cleanup" +cat >"$signal_script" <<'SIGNAL_TEST' +#!/bin/bash -p +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" +cleanup_signal_test() { + local status=$? + omarchy_security_exit_with_revoked_sudo "$status" +} +trap cleanup_signal_test EXIT +omarchy_security_install_signal_exit_traps +: >"$TEST_SIGNAL_READY" +while :; do :; done +SIGNAL_TEST +chmod 0755 "$signal_script" + +revoke_armed="$test_home/revoke-armed" +signal_ready="$test_home/signal-ready" +: >"$token" +chown 1000:1000 "$token" +rm -f "$revoke_armed" "$signal_ready" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" \ + TEST_WAITER_ARMED="$armed" TEST_DELAY_INVALIDATE_MARKER="$revoke_armed" \ + TEST_SIGNAL_READY="$signal_ready" \ + "$signal_script" & +signal_pid=$! +for attempt in {1..200}; do + [[ ! -e $signal_ready ]] || break + sleep 0.005 +done +[[ -e $signal_ready ]] || fail "signal cleanup process did not become ready" +kill -TERM "$signal_pid" +for attempt in {1..200}; do + [[ ! -e $revoke_armed ]] || break + sleep 0.005 +done +[[ -e $revoke_armed ]] || fail "signal cleanup did not begin its blocking invalidation" +kill -TERM "$signal_pid" +set +e +wait "$signal_pid" +signal_status=$? +set -e +[[ $signal_status == 143 && ! -e $token ]] || + fail "a second TERM interrupted sudo revocation" "status=$signal_status token=$([[ -e $token ]] && echo present || echo absent)" +pass "cleanup ignores a second TERM until cached sudo authorization is revoked" From 311a2aab821f4a39e834417fbfbcc7eb300f32be Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 5 Sep 2026 19:10:22 +0100 Subject: [PATCH 06/10] Move debug authorization to native boundary --- bin/omarchy | 4 +- bin/omarchy-debug | 205 +++---- .../omarchy/command-metadata/omarchy-debug | 4 + .../omarchy/security/omarchy-debug-launcher.c | 574 ++++++++++++++++++ test/shell.d/debug-sudo-security-test.sh | 425 +++++++++---- 5 files changed, 958 insertions(+), 254 deletions(-) create mode 100644 default/omarchy/command-metadata/omarchy-debug create mode 100644 default/omarchy/security/omarchy-debug-launcher.c diff --git a/bin/omarchy b/bin/omarchy index 4219109be68..6c9e25f9f36 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -168,6 +168,7 @@ register_route() { register_command() { local file="$1" local file_binary="${file##*/}" + local metadata_file="$OMARCHY_BIN_DIR/../share/omarchy/command-metadata/$file_binary" local group="" local name="" local summary="" @@ -186,6 +187,7 @@ register_command() { local has_summary="false" local metadata_errors="" + [[ -f $metadata_file && -r $metadata_file ]] || metadata_file="$file" while IFS= read -r line && (( line_count < METADATA_SCAN_LIMIT )); do line_count=$((line_count + 1)) @@ -248,7 +250,7 @@ register_command() { fallback_summary="" fi fi - done <"$file" + done <"$metadata_file" local stem="${file_binary#omarchy-}" local fallback_group="$stem" diff --git a/bin/omarchy-debug b/bin/omarchy-debug index b88aef2c1d8..cb8b4508784 100755 --- a/bin/omarchy-debug +++ b/bin/omarchy-debug @@ -5,103 +5,55 @@ # omarchy:examples=omarchy debug --print --no-sudo # omarchy:requires-sudo=true -# The privileged shebang prevents Bash startup hooks from running before this -# boundary. Run this as its own script (a clean `bash -p script` is equivalent), -# never by sourcing it into -c, stdin, an interactive shell, or another script. -# An ordinary interpreter launch can turn privileged mode on from BASH_ENV or -# exported SHELLOPTS, so inspect those immutable initial-environment traces -# with shell syntax alone, before trusting a function or command. Bash strips -# NUL separators in command substitution; an unusual unrelated name or value -# containing `BASH_ENV=` therefore fails closed, while common `VIRTUAL_ENV` -# state does not collide. -if [[ $- == *p* && $- != *[cis]* && ${BASH_SOURCE[0]} == "$0" && -r /proc/$$/environ ]] && - (( ${#BASH_SOURCE[@]} == 1 )) && { - case $(/dev/null; then - source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 - - omarchy_security_require_privileged_bash_startup "${BASH_SOURCE[0]}" || { - echo "Refusing an unsafe Bash startup for debug collection." >&2 - exit 126 - } -unset BASH_ENV ENV PS4 CDPATH GLOBIGNORE - -set -uo pipefail - -NO_SUDO=false -PRINT_ONLY=false - -while (( $# > 0 )); do - case "$1" in - --no-sudo) - NO_SUDO=true - shift - ;; - --print) - PRINT_ONLY=true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: omarchy-debug [--no-sudo] [--print]" - exit 1 - ;; - esac -done - -SUDO_BOUNDARY_ACTIVE=false -cleanup_debug_authorization() { - local status=$? - - trap - EXIT - trap '' HUP INT TERM - if [[ $SUDO_BOUNDARY_ACTIVE == true ]]; then - omarchy_security_exit_with_revoked_sudo "$status" - fi - exit "$status" -} -trap cleanup_debug_authorization EXIT -omarchy_security_install_signal_exit_traps -SUDO_BOUNDARY_ACTIVE=true - -# Invalidate before any caller-selected path is consumed or any PATH-resolved -# collector runs. Cleanup owns another invalidation from this point onward, -# including if this first attempt is interrupted. -omarchy_security_revoke_sudo_timestamp || { - echo "Could not invalidate cached sudo authorization." >&2 - exit 1 -} - -if [[ $NO_SUDO == true ]]; then - DMESG_OUTPUT="(skipped - --no-sudo flag used)" -else - omarchy_security_sudo_supports_no_update || { - echo "This sudo does not support --no-update; refusing privileged debug collection." >&2 +# The settings package installs a root-owned, non-setuid static launcher as +# /usr/bin/omarchy-debug and this Bash payload at a fixed /usr/lib path. The +# launcher is the first process: it cold-revokes sudo, performs only the fixed +# `sudo -N dmesg` operation, revokes again, then starts this collector with a +# sanitized shell. Running this source payload directly is unsupported and +# cannot reach a sudo operation because the collector itself contains none. +if [[ ${1:-} == "--omarchy-debug-native-boundary-v1" && + ${OMARCHY_DEBUG_NATIVE_BOUNDARY:-} == 1 && -r /proc/self/fd/3 ]]; then + shift + DMESG_OUTPUT=$(/usr/bin/cat <&3) || { + echo "Could not read the private kernel log staging descriptor." >&2 exit 1 } - if ! DMESG_OUTPUT=$(/usr/bin/sudo -N -- /usr/bin/dmesg); then - echo "Could not collect the kernel log through command-scoped sudo." >&2 + exec 3<&- + exec 4<&- + + set -uo pipefail + + NO_SUDO=false + PRINT_ONLY=false + + while (( $# > 0 )); do + case "$1" in + --no-sudo) + NO_SUDO=true + shift + ;; + --print) + PRINT_ONLY=true + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: omarchy-debug [--no-sudo] [--print]" + exit 1 + ;; + esac + done + + # OM-SEC-06 owns this private staging change in #8370. Keep its -T guard so a + # directory at the fixed log name is rejected rather than accepted by install. + log_dir="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" + LOG_FILE="$log_dir/omarchy-debug.log" + if ! /usr/bin/mkdir -p "$log_dir" || ! /usr/bin/install -T -m 600 /dev/null "$LOG_FILE"; then + echo "Error: Failed to create $LOG_FILE" >&2 exit 1 fi - omarchy_security_revoke_sudo_timestamp || { - echo "Could not invalidate cached sudo authorization." >&2 - exit 1 - } -fi -# OM-SEC-06 owns this private staging change in #8370. Keep its -T guard so a -# directory at the fixed log name is rejected rather than accepted by install. -log_dir="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" -LOG_FILE="$log_dir/omarchy-debug.log" -if ! /usr/bin/mkdir -p "$log_dir" || ! /usr/bin/install -T -m 600 /dev/null "$LOG_FILE"; then - echo "Error: Failed to create $LOG_FILE" >&2 - exit 1 -fi - -cat >"$LOG_FILE" <"$LOG_FILE" </dev/null || pacman -Q omarchy 2>/dev/null || echo "unknown") @@ -127,42 +79,41 @@ INSTALLED PACKAGES $({ expac -S '%n %v (%r)' $(pacman -Qqe) 2>/dev/null; comm -13 <(pacman -Sql | sort) <(pacman -Qqe | sort) | xargs -r expac -Q '%n %v (AUR)'; } | sort) EOF -if [[ $PRINT_ONLY == true ]]; then - cat "$LOG_FILE" - exit 0 -fi - -OPTIONS=("View log" "Save in current directory") -if ping -c 1 8.8.8.8 >/dev/null 2>&1; then - OPTIONS=("Upload log" "${OPTIONS[@]}") -fi + if [[ $PRINT_ONLY == true ]]; then + cat "$LOG_FILE" + exit 0 + fi -ACTION=$(gum choose "${OPTIONS[@]}") + OPTIONS=("View log" "Save in current directory") + if ping -c 1 8.8.8.8 >/dev/null 2>&1; then + OPTIONS=("Upload log" "${OPTIONS[@]}") + fi -case "$ACTION" in - "Upload log") - echo "Uploading debug log to logs.omarchy.org..." - if URL=$(curl -sf -F "file=@$LOG_FILE" -Fexpires=24 https://logs.omarchy.org/) && [[ -n $URL ]]; then - echo "✓ Log uploaded successfully!" - echo "Share this URL:" - echo "" - echo " $URL" - else - echo "Error: Failed to upload log file" - exit 1 - fi - ;; - "View log") - less "$LOG_FILE" - ;; - "Save in current directory") - cp "$LOG_FILE" "./omarchy-debug.log" - echo "✓ Log saved to $(pwd)/omarchy-debug.log" - ;; -esac + ACTION=$(gum choose "${OPTIONS[@]}") + + case "$ACTION" in + "Upload log") + echo "Uploading debug log to logs.omarchy.org..." + if URL=$(curl -sf -F "file=@$LOG_FILE" -Fexpires=24 https://logs.omarchy.org/) && [[ -n $URL ]]; then + echo "✓ Log uploaded successfully!" + echo "Share this URL:" + echo "" + echo " $URL" + else + echo "Error: Failed to upload log file" + exit 1 + fi + ;; + "View log") + less "$LOG_FILE" + ;; + "Save in current directory") + cp "$LOG_FILE" "./omarchy-debug.log" + echo "✓ Log saved to $(pwd)/omarchy-debug.log" + ;; + esac else - # In a noninteractive script this expansion terminates Bash. In a sourced - # interactive shell it only reports the failure, but the workflow is wholly - # contained in the safe branch above and cannot continue afterward. - ${BASH_VERSINFO[999999]:?Refusing an unsafe Bash startup for debug collection.} + echo "Run the packaged omarchy-debug command directly; do not invoke its Bash payload." >&2 + return 126 2>/dev/null + exit 126 fi diff --git a/default/omarchy/command-metadata/omarchy-debug b/default/omarchy/command-metadata/omarchy-debug new file mode 100644 index 00000000000..4c931cd1873 --- /dev/null +++ b/default/omarchy/command-metadata/omarchy-debug @@ -0,0 +1,4 @@ +# omarchy:summary=Print debugging information +# omarchy:args=[--no-sudo] [--print] +# omarchy:examples=omarchy debug --print --no-sudo +# omarchy:requires-sudo=true diff --git a/default/omarchy/security/omarchy-debug-launcher.c b/default/omarchy/security/omarchy-debug-launcher.c new file mode 100644 index 00000000000..6480f1c7684 --- /dev/null +++ b/default/omarchy/security/omarchy-debug-launcher.c @@ -0,0 +1,574 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef OMARCHY_DEBUG_COLLECTOR_PATH +#define OMARCHY_DEBUG_COLLECTOR_PATH "/usr/lib/omarchy/omarchy-debug-collector" +#endif + +#ifndef OMARCHY_SUDO_PATH +#define OMARCHY_SUDO_PATH "/usr/bin/sudo" +#endif + +#ifndef OMARCHY_DEBUG_TRUST_ROOT +#define OMARCHY_DEBUG_TRUST_ROOT "/" +#endif + +#define OMARCHY_BASH_PATH "/usr/bin/bash" +#define OMARCHY_DMESG_PATH "/usr/bin/dmesg" +#define OMARCHY_BOUNDARY_MARKER "--omarchy-debug-native-boundary-v1" +#ifndef DMESG_LIMIT +#define DMESG_LIMIT (8U * 1024U * 1024U) +#endif +#ifndef HELP_LIMIT +#define HELP_LIMIT (64U * 1024U) +#endif + +extern char **environ; + +enum child_phase { + CHILD_IDLE, + CHILD_WORKER, + CHILD_REVOKE, +}; + +static volatile sig_atomic_t caught_signal; +static volatile sig_atomic_t child_phase = CHILD_IDLE; +static volatile sig_atomic_t active_child = -1; + +static void handle_signal(int signo) { + pid_t child = active_child; + + if (!caught_signal) caught_signal = signo; + if (child_phase == CHILD_WORKER && child > 0) kill(-child, signo); +} + +static int install_signal_handlers(void) { + struct sigaction action = { + .sa_handler = handle_signal, + }; + + sigemptyset(&action.sa_mask); + if (sigaction(SIGHUP, &action, NULL) || sigaction(SIGINT, &action, NULL) || + sigaction(SIGTERM, &action, NULL)) { + return -1; + } + return 0; +} + +static void restore_signal_defaults(void) { + struct sigaction action = { + .sa_handler = SIG_DFL, + }; + + sigemptyset(&action.sa_mask); + sigaction(SIGHUP, &action, NULL); + sigaction(SIGINT, &action, NULL); + sigaction(SIGTERM, &action, NULL); +} + +static int prepare_exec_signals(void) { + sigset_t blocked; + sigset_t previous; + + sigemptyset(&blocked); + sigaddset(&blocked, SIGHUP); + sigaddset(&blocked, SIGINT); + sigaddset(&blocked, SIGTERM); + if (sigprocmask(SIG_BLOCK, &blocked, &previous)) return -1; + if (caught_signal) { + sigprocmask(SIG_SETMASK, &previous, NULL); + return -1; + } + restore_signal_defaults(); + return sigprocmask(SIG_SETMASK, &previous, NULL); +} + +static int raw_execve(const char *path, char *const argv[], char *const envp[]) { + return (int)syscall(SYS_execve, path, argv, envp); +} + +static bool environment_name_is(const char *entry, const char *name) { + size_t length = strlen(name); + + return !strncmp(entry, name, length) && entry[length] == '='; +} + +static bool environment_is_dangerous(const char *entry) { + static const char *const names[] = { + "BASHOPTS", "BASH_ENV", "BASH_XTRACEFD", "CDPATH", + "ENV", "GCONV_PATH", "GLOBIGNORE", "LD_AUDIT", + "IFS", "LOCPATH", "OMARCHY_DEBUG_NATIVE_BOUNDARY", + "POSIXLY_CORRECT", "PS4", "SHELLOPTS", + }; + size_t index; + + if (!strncmp(entry, "BASH_", strlen("BASH_"))) return true; + if (!strncmp(entry, "LD_", strlen("LD_"))) return true; +#ifndef OMARCHY_DEBUG_TESTING + if (environment_name_is(entry, "PATH")) return true; +#endif + for (index = 0; index < sizeof(names) / sizeof(names[0]); index++) { + if (environment_name_is(entry, names[index])) return true; + } + return false; +} + +static char **sanitized_environment(void) { + size_t count = 0; + size_t kept = 0; + char **clean; + + while (environ[count]) count++; + clean = calloc(count + 4, sizeof(*clean)); + if (!clean) return NULL; + + for (size_t index = 0; index < count; index++) { + if (!environment_is_dangerous(environ[index])) clean[kept++] = environ[index]; + } + clean[kept++] = "IFS= \t\n"; +#ifndef OMARCHY_DEBUG_TESTING + clean[kept++] = "PATH=/usr/bin:/usr/sbin:/bin:/sbin"; +#endif + clean[kept++] = "OMARCHY_DEBUG_NATIVE_BOUNDARY=1"; + clean[kept] = NULL; + return clean; +} + +static char **sudo_environment(void) { +#ifdef OMARCHY_DEBUG_TESTING + static const char *const test_names[] = { + "TEST_DELAY_INVALIDATE_MARKER", "TEST_DMESG_BYTES", "TEST_DMESG_DELAY_MARKER", + "TEST_DMESG_STATUS", "TEST_EVENT_LOG", "TEST_SUDO_NO_N", "TEST_SUDO_TOKEN", + "TEST_WAITER_ARMED", + }; + static char *clean[sizeof(test_names) / sizeof(test_names[0]) + 3]; + size_t kept = 0; + + clean[kept++] = "PATH=/usr/bin:/usr/sbin:/bin:/sbin"; + clean[kept++] = "LC_ALL=C"; + for (size_t index = 0; index < sizeof(test_names) / sizeof(test_names[0]); index++) { + for (size_t item = 0; environ[item]; item++) { + if (environment_name_is(environ[item], test_names[index])) { + clean[kept++] = environ[item]; + break; + } + } + } + clean[kept] = NULL; + return clean; +#else + static char *clean[] = { + "PATH=/usr/bin:/usr/sbin:/bin:/sbin", + "LC_ALL=C", + NULL, + }; + return clean; +#endif +} + +static int wait_for_child(pid_t child, int *status) { + pid_t result; + + do { + result = waitpid(child, status, 0); + } while (result < 0 && errno == EINTR); + active_child = -1; + child_phase = CHILD_IDLE; + return result == child ? 0 : -1; +} + +static int command_status(int status) { + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return 125; +} + +static pid_t spawn_command(char *const argv[], int stdout_fd, bool merge_stderr, + enum child_phase phase) { + sigset_t blocked; + sigset_t previous; + pid_t child; + + sigemptyset(&blocked); + sigaddset(&blocked, SIGHUP); + sigaddset(&blocked, SIGINT); + sigaddset(&blocked, SIGTERM); + if (sigprocmask(SIG_BLOCK, &blocked, &previous)) return -1; + child = fork(); + if (child < 0) { + sigprocmask(SIG_SETMASK, &previous, NULL); + return -1; + } + if (!child) { + if (setpgid(0, 0)) _exit(127); + if (phase == CHILD_WORKER) { + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() == 1) _exit(127); + } + if (sigprocmask(SIG_SETMASK, &previous, NULL)) _exit(127); + if (stdout_fd >= 0 && dup2(stdout_fd, STDOUT_FILENO) < 0) _exit(127); + if (merge_stderr && dup2(STDOUT_FILENO, STDERR_FILENO) < 0) _exit(127); + raw_execve(OMARCHY_SUDO_PATH, argv, sudo_environment()); + _exit(127); + } + + setpgid(child, child); +#ifdef OMARCHY_DEBUG_TESTING + { + const char *marker = getenv("TEST_POST_FORK_DELAY_MARKER"); + if (marker && *marker && phase == CHILD_WORKER) { + int fd = open(marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) close(fd); + usleep(500000); + } + } +#endif + active_child = child; + child_phase = phase; + if (caught_signal && phase == CHILD_WORKER) kill(-child, caught_signal); + sigprocmask(SIG_SETMASK, &previous, NULL); + return child; +} + +static int revoke_timestamp(void) { + char *const argv[] = {OMARCHY_SUDO_PATH, "-k", NULL}; + int status; + pid_t child = spawn_command(argv, -1, false, CHILD_REVOKE); + + if (child < 0 || wait_for_child(child, &status)) return 125; + return command_status(status); +} + +static int write_all(int fd, const void *buffer, size_t length) { + const unsigned char *cursor = buffer; + + while (length) { + ssize_t written = write(fd, cursor, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) return -1; + cursor += written; + length -= (size_t)written; + } + return 0; +} + +static int capture_command(char *const argv[], int output_fd, size_t limit, + bool merge_stderr, bool *overflowed) { + unsigned char buffer[16384]; + size_t total = 0; + int pipefd[2]; + int status = 0; + pid_t child; + + *overflowed = false; + if (pipe2(pipefd, O_CLOEXEC)) return 125; + child = spawn_command(argv, pipefd[1], merge_stderr, CHILD_WORKER); + close(pipefd[1]); + if (child < 0) { + close(pipefd[0]); + return 125; + } + + while (true) { + ssize_t received = read(pipefd[0], buffer, sizeof(buffer)); + if (received < 0 && errno == EINTR) continue; + if (received < 0) { + kill(-child, SIGKILL); + break; + } + if (!received) break; + if (total > limit || (size_t)received > limit - total) { + *overflowed = true; + kill(-child, SIGKILL); + break; + } + if (write_all(output_fd, buffer, (size_t)received)) { + kill(-child, SIGKILL); + break; + } + total += (size_t)received; + } + close(pipefd[0]); + if (wait_for_child(child, &status)) return 125; + if (*overflowed) return 124; + return command_status(status); +} + +static bool sudo_help_supports_no_update(int help_fd) { + char buffer[HELP_LIMIT + 1]; + ssize_t length; + + if (lseek(help_fd, 0, SEEK_SET) < 0) return false; + length = read(help_fd, buffer, HELP_LIMIT); + if (length < 0) return false; + buffer[length] = '\0'; + + for (ssize_t index = 0; index < length; index++) { + if (buffer[index] == '-' && index + 1 < length && buffer[index + 1] == 'N') { + char before = index ? buffer[index - 1] : ' '; + char after = index + 2 < length ? buffer[index + 2] : ' '; + if (before == ' ' || before == '\t' || before == '\n' || before == '[' || + before == ',') { + if (after == ' ' || after == '\t' || after == '\n' || after == ']' || + after == ',') { + return true; + } + } + } + if (buffer[index] == '[') { + for (ssize_t end = index + 1; end < length && buffer[end] != ']'; end++) { + if (buffer[end] == 'N') return true; + } + } + } + return false; +} + +static int create_memfd(const char *name) { + return (int)syscall(SYS_memfd_create, name, MFD_CLOEXEC | MFD_ALLOW_SEALING); +} + +static int seal_and_rewind(int fd) { + if (fcntl(fd, F_ADD_SEALS, + F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)) { + return -1; + } + return lseek(fd, 0, SEEK_SET) < 0 ? -1 : 0; +} + +static bool trusted_metadata(const struct stat *metadata, bool directory) { + if (metadata->st_uid != 0 || (metadata->st_mode & (S_IWGRP | S_IWOTH))) return false; + return directory ? S_ISDIR(metadata->st_mode) : S_ISREG(metadata->st_mode); +} + +static int pin_script(void) { + char path[PATH_MAX]; + char *component; + char *next; + char *save = NULL; + const char *relative; + size_t root_length = strlen(OMARCHY_DEBUG_TRUST_ROOT); + struct stat metadata; + int parent; + int fd; + + if (!root_length || root_length >= sizeof(path) || + strncmp(OMARCHY_DEBUG_COLLECTOR_PATH, OMARCHY_DEBUG_TRUST_ROOT, root_length)) { + return -1; + } + relative = OMARCHY_DEBUG_COLLECTOR_PATH + root_length; + if (root_length > 1 && *relative != '/') return -1; + while (*relative == '/') relative++; + if (!*relative || strlen(relative) >= sizeof(path)) return -1; + memcpy(path, relative, strlen(relative) + 1); + + parent = open(OMARCHY_DEBUG_TRUST_ROOT, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (parent < 0 || fstat(parent, &metadata) || !trusted_metadata(&metadata, true)) { + if (parent >= 0) close(parent); + return -1; + } + + component = strtok_r(path, "/", &save); + while (component) { + next = strtok_r(NULL, "/", &save); + if (!strcmp(component, ".") || !strcmp(component, "..")) { + close(parent); + return -1; + } + if (next) { + fd = openat(parent, component, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + close(parent); + if (fd < 0 || fstat(fd, &metadata) || !trusted_metadata(&metadata, true)) { + if (fd >= 0) close(fd); + return -1; + } + parent = fd; + } else { + fd = openat(parent, component, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + close(parent); + if (fd < 0 || fstat(fd, &metadata) || !trusted_metadata(&metadata, false)) { + if (fd >= 0) close(fd); + return -1; + } + return fd; + } + component = next; + } + close(parent); + return -1; +} + +static int pin_descriptors(int dmesg_fd, int script_fd) { + int high_dmesg = fcntl(dmesg_fd, F_DUPFD_CLOEXEC, 10); + int high_script; + + if (high_dmesg < 0) return -1; + high_script = fcntl(script_fd, F_DUPFD_CLOEXEC, high_dmesg + 1); + if (high_script < 0) { + close(high_dmesg); + return -1; + } + close(dmesg_fd); + close(script_fd); + if (dup3(high_dmesg, 3, 0) < 0) { + close(high_script); + close(high_dmesg); + return -1; + } + if (dup3(high_script, 4, 0) < 0) { + close(3); + close(high_script); + close(high_dmesg); + return -1; + } + close(high_script); + close(high_dmesg); + return 0; +} + +static int interrupted_status(void) { + return caught_signal ? 128 + caught_signal : 1; +} + +static void revoke_and_exit(const char *message, int status) { + int revoke_status = revoke_timestamp(); + + if (revoke_status) fprintf(stderr, "Could not invalidate cached sudo authorization.\n"); + if (message) fprintf(stderr, "%s\n", message); + exit(caught_signal ? interrupted_status() : status); +} + +int main(int argc, char **argv) { + bool no_sudo = false; + bool overflowed = false; + int script_fd; + int dmesg_fd; + int help_fd = -1; + int status; + int first_option = 1; + char **bash_environment; + char **bash_argv; + size_t bash_argc; + + script_fd = pin_script(); + if (script_fd < 0) { + fprintf(stderr, "Refusing an untrusted omarchy-debug payload.\n"); + return 126; + } +#ifdef OMARCHY_DEBUG_TESTING + { + const char *marker = getenv("TEST_AFTER_PIN_DELAY_MARKER"); + if (marker && *marker) { + int fd = open(marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) close(fd); + usleep(500000); + } + } +#endif + + for (int index = first_option; index < argc; index++) { + if (!strcmp(argv[index], "--no-sudo")) { + no_sudo = true; + } else if (strcmp(argv[index], "--print")) { + fprintf(stderr, "Unknown option: %s\n", argv[index]); + fprintf(stderr, "Usage: omarchy-debug [--no-sudo] [--print]\n"); + close(script_fd); + return 1; + } + } + + if (install_signal_handlers()) { + fprintf(stderr, "Could not establish the debug signal boundary.\n"); + close(script_fd); + return 1; + } + if (revoke_timestamp()) { + fprintf(stderr, "Could not invalidate cached sudo authorization.\n"); + close(script_fd); + return 1; + } + if (caught_signal) revoke_and_exit(NULL, interrupted_status()); + + dmesg_fd = create_memfd("omarchy-debug-dmesg"); + if (dmesg_fd < 0) revoke_and_exit("Could not create private debug staging memory.", 1); + + if (no_sudo) { + static const char skipped[] = "(skipped - --no-sudo flag used)\n"; + if (write_all(dmesg_fd, skipped, sizeof(skipped) - 1)) { + revoke_and_exit("Could not stage the kernel log status.", 1); + } + } else { + char *const help_argv[] = {OMARCHY_SUDO_PATH, "-h", NULL}; + char *const dmesg_argv[] = { + OMARCHY_SUDO_PATH, "-N", "--", OMARCHY_DMESG_PATH, NULL, + }; + + help_fd = create_memfd("omarchy-debug-sudo-help"); + if (help_fd < 0) revoke_and_exit("Could not verify sudo --no-update support.", 1); + status = capture_command(help_argv, help_fd, HELP_LIMIT, true, &overflowed); + if (caught_signal) revoke_and_exit(NULL, interrupted_status()); + if (status || overflowed || !sudo_help_supports_no_update(help_fd)) { + revoke_and_exit("This sudo does not support --no-update; refusing privileged debug collection.", 1); + } + close(help_fd); + help_fd = -1; + + status = capture_command(dmesg_argv, dmesg_fd, DMESG_LIMIT, false, &overflowed); + if (caught_signal) revoke_and_exit(NULL, interrupted_status()); + if (overflowed) { + revoke_and_exit("Kernel log exceeds the safe debug collection limit.", 1); + } + if (status) { + revoke_and_exit("Could not collect the kernel log through command-scoped sudo.", 1); + } + } + + if (seal_and_rewind(dmesg_fd)) revoke_and_exit("Could not seal private debug staging memory.", 1); + if (revoke_timestamp()) { + fprintf(stderr, "Could not invalidate cached sudo authorization.\n"); + return 1; + } + if (caught_signal) revoke_and_exit(NULL, interrupted_status()); + + bash_environment = sanitized_environment(); + if (!bash_environment) { + fprintf(stderr, "Could not sanitize the debug collector environment.\n"); + return 1; + } + bash_argc = (size_t)argc + 4; + bash_argv = calloc(bash_argc, sizeof(*bash_argv)); + if (!bash_argv) { + fprintf(stderr, "Could not prepare the debug collector.\n"); + return 1; + } + bash_argv[0] = OMARCHY_BASH_PATH; + bash_argv[1] = "-p"; + bash_argv[2] = "/proc/self/fd/4"; + bash_argv[3] = OMARCHY_BOUNDARY_MARKER; + for (int index = first_option; index < argc; index++) bash_argv[index + 3] = argv[index]; + bash_argv[argc + 3] = NULL; + + if (pin_descriptors(dmesg_fd, script_fd)) { + fprintf(stderr, "Could not pin private debug descriptors.\n"); + return 1; + } + if (prepare_exec_signals()) return interrupted_status(); + raw_execve(OMARCHY_BASH_PATH, bash_argv, bash_environment); + fprintf(stderr, "Could not start the debug collector.\n"); + return 126; +} diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh index 350cc255c3a..bc81a4816ac 100644 --- a/test/shell.d/debug-sudo-security-test.sh +++ b/test/shell.d/debug-sudo-security-test.sh @@ -7,6 +7,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command unshare require_command setpriv require_command cc +require_command readelf if [[ ${OMARCHY_DEBUG_SUDO_SECURITY_NS:-0} != 1 ]]; then outer_uid=$(id -u) @@ -28,7 +29,7 @@ fi test_tmp=$(mktemp -d) mount -t tmpfs -o mode=0755,suid tmpfs "$test_tmp" stub_bin="$test_tmp/bin" -script_dir="$test_tmp/scripts" +collector="$test_tmp/lib/omarchy/omarchy-debug-collector" test_home="$test_tmp/home" root_dir="$test_tmp/root" event_log="$test_tmp/events" @@ -36,11 +37,12 @@ token="$test_tmp/sudo-token" victim="$root_dir/published" armed="$test_tmp/waiter-armed" staging_marker="$test_home/staging-command-ran" -mkdir -p "$stub_bin" "$script_dir" "$test_home/runtime" "$root_dir" +launcher="$test_tmp/omarchy-debug" +mkdir -p "$stub_bin" "$test_home/runtime" "$root_dir" "$test_tmp/lib/omarchy" touch "$event_log" chown -R 1000:1000 "$test_home" "$event_log" chmod 0700 "$test_home" "$test_home/runtime" -chmod 0755 "$test_tmp" "$stub_bin" "$script_dir" "$root_dir" +chmod 0755 "$test_tmp" "$stub_bin" "$root_dir" chmod 0600 "$event_log" cleanup() { @@ -78,6 +80,7 @@ static void event(const char *message) { int main(int argc, char **argv) { const char *token = need("TEST_SUDO_TOKEN"); int index = 1, no_update = 0, noninteractive = 0, fd; + if (argc == 2 && !strcmp(argv[1], "-h")) { if (getenv("TEST_SUDO_NO_N")) puts("usage: sudo [-ABbEHknPS] command"); else puts("usage: sudo [-ABbEHkNnPS] command"); @@ -113,14 +116,28 @@ int main(int argc, char **argv) { } if (index >= argc || setgid(0) || setuid(0)) return 124; if (!strcmp(argv[index], "/usr/bin/dmesg")) { - puts("modeled kernel log"); - return 0; + const char *delay_marker = getenv("TEST_DMESG_DELAY_MARKER"); + const char *bytes_value = getenv("TEST_DMESG_BYTES"); + const char *status_value = getenv("TEST_DMESG_STATUS"); + if (delay_marker && *delay_marker) { + fd = open(delay_marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 119; + close(fd); + sleep(5); + } + if (bytes_value && *bytes_value) { + size_t bytes = strtoul(bytes_value, NULL, 10); + while (bytes--) putchar('K'); + } else { + puts("modeled kernel log"); + } + return status_value ? atoi(status_value) : 0; } execv(argv[index], &argv[index]); return 126; } C -cc -O2 -Wall -Wextra -o "$stub_bin/sudo" "$test_tmp/sudo.c" +cc -O2 -Wall -Wextra -Werror -o "$stub_bin/sudo" "$test_tmp/sudo.c" chown 0:0 "$stub_bin/sudo" chmod 4755 "$stub_bin/sudo" @@ -152,11 +169,53 @@ done chmod 0755 "$stub_bin/inxi" "$stub_bin/pacman" "$stub_bin/journalctl" "$stub_bin/expac" \ "$stub_bin/mkdir" "$stub_bin/install" -sed "s#/usr/bin/sudo#$stub_bin/sudo#g" \ - "$ROOT/bin/omarchy-security-functions" >"$script_dir/omarchy-security-functions" -sed "s#/usr/bin/sudo#$stub_bin/sudo#g" \ - "$ROOT/bin/omarchy-debug" >"$script_dir/omarchy-debug" -chmod 0755 "$script_dir/omarchy-security-functions" "$script_dir/omarchy-debug" +build_launcher() { + local output=$1 + local payload=$2 + local source=${3:-$ROOT/default/omarchy/security/omarchy-debug-launcher.c} + + cc -static-pie -O2 -Wall -Wextra -Werror -DOMARCHY_DEBUG_TESTING=1 -DDMESG_LIMIT=4096 \ + "-DOMARCHY_DEBUG_COLLECTOR_PATH=\"$payload\"" "-DOMARCHY_SUDO_PATH=\"$stub_bin/sudo\"" \ + "-DOMARCHY_DEBUG_TRUST_ROOT=\"$test_tmp\"" \ + -o "$output" "$source" +} + +cp "$ROOT/bin/omarchy-debug" "$collector" +build_launcher "$launcher" "$collector" +chmod 0755 "$launcher" "$collector" +chown 0:0 "$launcher" "$collector" +readelf -l "$launcher" | grep -q 'INTERP' && fail "debug launcher unexpectedly has a dynamic interpreter" +readelf -d "$launcher" | grep -q 'NEEDED' && fail "debug launcher unexpectedly needs a shared library" +readelf -W -l "$launcher" | + awk '$1 == "GNU_STACK" { found = 1; if ($NF ~ /E/) bad = 1 } END { exit !(found && !bad) }' || + fail "debug launcher has a missing or executable GNU stack" +ldd_output=$(ldd "$launcher" 2>&1 || true) +[[ $ldd_output == *"statically linked"* || $ldd_output == *"not a dynamic executable"* ]] || + fail "ldd did not recognize the debug launcher as static" +[[ $(stat -c '%u:%a' "$launcher") == 0:755 && $(stat -c '%u:%a' "$collector") == 0:755 ]] || + fail "debug boundary artifacts are not root-owned 0755" +pass "debug uses a root-owned non-setuid static launcher and pinned collector" + +metadata="$ROOT/default/omarchy/command-metadata/omarchy-debug" +for key in summary args examples requires-sudo; do + metadata_line=$(grep -F "# omarchy:$key=" "$metadata") + collector_line=$(grep -F "# omarchy:$key=" "$collector") + [[ $metadata_line == "$collector_line" ]] || + fail "installed debug metadata drifted for $key" +done +installed="$test_tmp/installed" +mkdir -p "$installed/usr/bin" "$installed/usr/share/omarchy/command-metadata" +cp "$ROOT/bin/omarchy" "$installed/usr/bin/omarchy" +cp "$launcher" "$installed/usr/bin/omarchy-debug" +cp "$metadata" "$installed/usr/share/omarchy/command-metadata/omarchy-debug" +installed_help=$("$installed/usr/bin/omarchy" debug --help) +[[ $installed_help == *"omarchy debug [--no-sudo] [--print]"* && + $installed_help == *"Print debugging information"* ]] || + fail "installed ELF layout lost debug CLI metadata" +"$installed/usr/bin/omarchy" commands --json | + jq -e '.commands[] | select(.binary == "omarchy-debug" and .requires_sudo == true)' >/dev/null || + fail "installed ELF layout lost debug sudo metadata" +pass "installed ELF entrypoint retains sidecar CLI help and sudo metadata" printf 'debug-payload\n' >"$test_home/payload" chown 1000:1000 "$test_home/payload" @@ -198,7 +257,7 @@ run_debug() { chown 1000:1000 "$token" rm -f "$armed" "$test_home/collector" "$staging_marker" start_waiter -run_debug "$script_dir/omarchy-debug" +run_debug "$launcher" rm -f "$armed" wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true [[ -e $test_home/collector && ! -e $victim && ! -e $test_home/reused && ! -e $token ]] || @@ -206,24 +265,30 @@ wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true [[ ! -e $staging_marker ]] || fail "debug resolved a staging command through hostile PATH" [[ $(head -n 1 "$event_log") == invalidate ]] || fail "debug ran a collector before cold invalidation" grep -qxF grant-no-update "$event_log" || fail "debug dmesg did not use sudo --no-update" -[[ $(grep -c '^invalidate$' "$event_log") -ge 3 ]] || fail "debug did not invalidate at entry, after dmesg, and cleanup" -pass "debug pins staging, starts cold, and keeps collectors outside command-scoped dmesg authorization" +[[ $(grep -c '^invalidate$' "$event_log") -ge 2 ]] || fail "debug did not invalidate at entry and after dmesg" +pass "debug starts cold, pins staging, and keeps collectors outside fixed dmesg authorization" : >"$event_log" rm -f "$token" "$armed" "$test_home/collector" -mutant="$script_dir/omarchy-debug-mutant" -sed "s#$stub_bin/sudo -N --#$stub_bin/sudo --#" "$script_dir/omarchy-debug" >"$mutant" -chmod 0755 "$mutant" +mutant_source="$test_tmp/launcher-without-no-update.c" +mutant_collector="$test_tmp/omarchy-debug-mutant-collector" +mutant_launcher="$test_tmp/omarchy-debug-mutant" +sed 's/OMARCHY_SUDO_PATH, "-N", "--"/OMARCHY_SUDO_PATH, "--"/' \ + "$ROOT/default/omarchy/security/omarchy-debug-launcher.c" >"$mutant_source" +cp "$ROOT/bin/omarchy-debug" "$mutant_collector" +build_launcher "$mutant_launcher" "$mutant_collector" "$mutant_source" +chmod 0755 "$mutant_launcher" "$mutant_collector" +chown 0:0 "$mutant_launcher" "$mutant_collector" start_waiter -run_debug "$mutant" +run_debug "$mutant_launcher" rm -f "$armed" wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true [[ -e $test_home/reused && -e $victim ]] || fail "removing -N did not restore the modeled credential race" -pass "sudo --no-update is mutation-tested as the load-bearing race guard" +pass "sudo --no-update is mutation-tested as the load-bearing worker guard" : >"$event_log" rm -f "$token" "$armed" "$test_home/collector" "$victim" -if run_debug "$script_dir/omarchy-debug" TEST_SUDO_NO_N=1; then +if run_debug "$launcher" TEST_SUDO_NO_N=1; then fail "debug accepted sudo without --no-update support" fi [[ ! -e $test_home/collector && ! -e $token && ! -e $victim ]] || @@ -236,182 +301,290 @@ chown 1000:1000 "$token" rm -f "$armed" "$test_home/collector" "$victim" no_sudo_output=$(setpriv --reuid=1000 --regid=1000 --clear-groups \ env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" \ - PATH="$stub_bin:/usr/bin:/bin" TEST_SUDO_TOKEN="$token" \ - TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + PATH="$stub_bin:/usr/bin:/bin" MY_BASH_ENV=/dev/null VIRTUAL_ENV="$test_home/venv" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ TEST_COLLECTOR_RAN="$test_home/collector" TEST_STAGING_MARKER="$staging_marker" \ TEST_REAL_SUDO="$stub_bin/sudo" TEST_PAYLOAD="$test_home/payload" TEST_VICTIM="$victim" \ - "$script_dir/omarchy-debug" --no-sudo --print) + "$launcher" --no-sudo --print) [[ $no_sudo_output == *"(skipped - --no-sudo flag used)"* && -e $test_home/collector && ! -e $token ]] || fail "--no-sudo no longer skips dmesg while collecting the user report" ! grep -q '^grant-no-update$' "$event_log" || fail "--no-sudo invoked privileged dmesg" [[ $(grep -c '^invalidate$' "$event_log") -ge 2 ]] || fail "--no-sudo did not protect collectors from a cached token" [[ $(stat -c '%a' "$test_home/runtime/omarchy-debug.log") == 600 ]] || fail "debug log is not private" -pass "--no-sudo skips dmesg, revokes cached credentials, and writes a private report" +pass "--no-sudo preserves benign environment, revokes credentials, and writes a private report" bash_env="$test_home/bash-env" startup_marker="$test_home/bash-env-ran" +constructor_marker="$test_home/constructor-ran" +interpose_marker="$test_home/interposed-exec" +startup_padding=$(printf '%65536s' '') cat >"$bash_env" <<'BASH_ENV' : >"$TEST_STARTUP_MARKER" set -o privileged -shift +shift || : function /usr/bin/env { return 0; } function /usr/bin/readlink { printf '/usr/bin/bash\n'; } -function /usr/bin/sudo { - local -a forwarded=() - local argument - for argument in "$@"; do - [[ $argument == -N ]] || forwarded+=("$argument") - done - "$TEST_REAL_SUDO" "${forwarded[@]}" -} -trap 'unset BASH_ENV; set -o privileged' DEBUG +function /usr/bin/sudo { "$TEST_REAL_SUDO" "${@/-N/}"; } +trap 'set +o privileged' DEBUG BASH_ENV +cat >"$test_tmp/preload.c" <<'C' +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +static char *real_sudo; +static char *interpose_marker; + +static void mark(const char *path) { + int fd; + if (!path || !*path) return; + fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) close(fd); +} + +__attribute__((constructor)) static void attack_startup(void) { + const char *value = getenv("TEST_REAL_SUDO"); + if (value) real_sudo = strdup(value); + value = getenv("TEST_INTERPOSE_MARKER"); + if (value) interpose_marker = strdup(value); + mark(getenv("TEST_CONSTRUCTOR_MARKER")); + value = getenv("TEST_ATTACK_BASH_ENV"); + if (value) setenv("BASH_ENV", value, 1); + unsetenv("LD_PRELOAD"); +} + +int execve(const char *path, char *const argv[], char *const envp[]) { + char *forwarded[64]; + int source = 0, target = 0; + if (real_sudo && !strcmp(path, real_sudo)) { + mark(interpose_marker); + while (argv[source] && target < 63) { + if (strcmp(argv[source], "-N")) forwarded[target++] = argv[source]; + source++; + } + forwarded[target] = NULL; + return syscall(SYS_execve, path, forwarded, envp); + } + return syscall(SYS_execve, path, argv, envp); +} +C +cc -shared -fPIC -O2 -Wall -Wextra -Werror -o "$test_tmp/preload.so" "$test_tmp/preload.c" + : >"$event_log" : >"$token" chown 1000:1000 "$token" -rm -f "$startup_marker" "$victim" "$test_home/collector" -startup_padding=$(printf '%65536s' '') -if setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" BASH_ENV="$bash_env" \ - TEST_STARTUP_MARKER="$startup_marker" TEST_SUDO_TOKEN="$token" \ - TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ - TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ - TEST_STARTUP_PADDING="$startup_padding" \ - /usr/bin/bash "$script_dir/omarchy-debug" -p --print >/dev/null 2>&1; then - fail "debug accepted an ordinary Bash launch with a decoy -p" -fi -[[ -e $startup_marker && -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || - fail "unsafe Bash startup reached the privileged debug workflow" -pass "immutable startup guard rejects oversized shift, trap, and slash-function injection before the workflow" +rm -f "$armed" "$startup_marker" "$constructor_marker" "$interpose_marker" "$victim" "$test_home/collector" +start_waiter +run_debug "$launcher" LD_PRELOAD="$test_tmp/preload.so" TEST_ATTACK_BASH_ENV="$bash_env" \ + TEST_STARTUP_MARKER="$startup_marker" TEST_CONSTRUCTOR_MARKER="$constructor_marker" \ + TEST_INTERPOSE_MARKER="$interpose_marker" BASH_ENV="$bash_env" BASH_COMPAT=42 \ + BASH_LOADABLES_PATH="$test_home/loadables" POSIXLY_CORRECT=1 \ + TEST_STARTUP_PADDING="$startup_padding" +rm -f "$armed" +wait "$(<"$test_home/waiter.pid")" 2>/dev/null || true +[[ ! -e $constructor_marker && ! -e $startup_marker && ! -e $interpose_marker ]] || + fail "hostile pre-main code entered the static or sanitized debug boundary" +[[ ! -e $token && ! -e $victim && ! -e $test_home/reused ]] || + fail "constructor erasure or execve interposition reused authorization" +grep -qxF grant-no-update "$event_log" || fail "native entry lost fixed sudo -N under LD_PRELOAD" +pass "static entry defeats constructor erasure and execve/-N interposition" : >"$event_log" -rm -f "$test_home/collector" -if setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" MY_BASH_ENV=/dev/null \ - TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ - TEST_COLLECTOR_RAN="$test_home/collector" "$script_dir/omarchy-debug" --print \ - >/dev/null 2>&1; then - fail "debug accepted an ambiguous BASH_ENV substring" -fi -[[ ! -s $event_log && ! -e $test_home/collector ]] || - fail "ambiguous BASH_ENV state reached the debug workflow" -pass "ambiguous BASH_ENV-like names fail closed without rejecting VIRTUAL_ENV" +: >"$token" +chown 1000:1000 "$token" +rm -f "$startup_marker" "$constructor_marker" "$interpose_marker" "$test_home/collector" +set +e +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" LD_PRELOAD="$test_tmp/preload.so" \ + TEST_ATTACK_BASH_ENV="$bash_env" TEST_STARTUP_MARKER="$startup_marker" \ + TEST_CONSTRUCTOR_MARKER="$constructor_marker" TEST_INTERPOSE_MARKER="$interpose_marker" \ + TEST_REAL_SUDO="$stub_bin/sudo" TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" \ + TEST_WAITER_ARMED="$armed" TEST_COLLECTOR_RAN="$test_home/collector" \ + /usr/bin/bash "$collector" -p --print >/dev/null 2>&1 +ordinary_status=$? +set -e +[[ $ordinary_status != 0 && -e $constructor_marker && -e $startup_marker && -e $token ]] || + fail "ordinary Bash constructor regression did not exercise and reject hostile startup" +[[ ! -s $event_log && ! -e $interpose_marker && ! -e $test_home/collector ]] || + fail "ordinary hostile Bash reached a launcher-owned sudo operation" +pass "ordinary Bash startup cannot enter the native authorization boundary" : >"$event_log" : >"$token" chown 1000:1000 "$token" rm -f "$test_home/collector" "$victim" if setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$script_dir/omarchy-debug" \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$collector" \ TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ /usr/bin/bash -c ' set -o privileged function /usr/bin/env { return 0; } - function /usr/bin/readlink { printf "/usr/bin/bash\n"; } - function /usr/bin/sudo { - local -a forwarded=() - local argument - for argument in "$@"; do - [[ $argument == -N ]] || forwarded+=("$argument") - done - "$TEST_REAL_SUDO" "${forwarded[@]}" - } + function /usr/bin/sudo { "$TEST_REAL_SUDO" "$@"; } BASH_ARGV0=$TARGET source "$TARGET" --print - ' "$script_dir/omarchy-debug" >/dev/null 2>&1; then - fail "debug accepted a forged -c source launch" + ' "$collector" >/dev/null 2>&1; then + fail "collector accepted a forged same-shell source launch" fi [[ -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || - fail "forged same-shell startup reached the privileged debug workflow" -pass "debug rejects pre-executed same-shell code even when it forges script identity" + fail "forged same-shell source reached collection or sudo" +pass "forged same-shell startup cannot reach the collector or native sudo boundary" interactive_continued="$test_home/interactive-continued" : >"$event_log" -: >"$token" -chown 1000:1000 "$token" rm -f "$interactive_continued" "$test_home/collector" "$victim" +set +e setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$script_dir/omarchy-debug" \ + env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TARGET="$collector" \ TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ TEST_REAL_SUDO="$stub_bin/sudo" TEST_COLLECTOR_RAN="$test_home/collector" \ TEST_INTERACTIVE_CONTINUED="$interactive_continued" \ /usr/bin/bash --noprofile --norc -i >/dev/null 2>&1 <<'INTERACTIVE' set -o privileged -function /usr/bin/env { return 0; } function /usr/bin/sudo { "$TEST_REAL_SUDO" "$@"; } BASH_ARGV0=$TARGET source "$TARGET" --print : >"$TEST_INTERACTIVE_CONTINUED" exit INTERACTIVE +set -e [[ -e $interactive_continued && -e $token && ! -s $event_log && ! -e $victim && ! -e $test_home/collector ]] || - fail "interactive source continued into the privileged debug workflow" -pass "unsafe interactive source returns to its shell without entering the workflow" - -unreadable_environment="$root_dir/unreadable-environ" -unreadable_script="$script_dir/omarchy-debug-unreadable-proc" -: >"$unreadable_environment" -chmod 000 "$unreadable_environment" -sed 's#/proc/\$\$/environ#${TEST_PROC_ENVIRONMENT}#g' \ - "$script_dir/omarchy-debug" >"$unreadable_script" -chmod 0755 "$unreadable_script" + fail "interactive source did not return safely without entering the collector" +pass "interactive source returns without collection or native sudo activity" + +! grep -q '/proc/\$\$/environ' "$ROOT/bin/omarchy-debug" || + fail "debug still trusts an in-process /proc environment startup proof" +pass "native authorization no longer depends on readable or bounded proc environment text" + : >"$event_log" -rm -f "$test_home/collector" -if setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" \ - TEST_PROC_ENVIRONMENT="$unreadable_environment" TEST_SUDO_TOKEN="$token" \ - TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ - TEST_COLLECTOR_RAN="$test_home/collector" "$unreadable_script" --print \ +wrong_script="$test_tmp/not-omarchy-debug" +cp "$collector" "$wrong_script" +chmod 0755 "$wrong_script" +for forged in "$wrong_script" "$collector" /dev/null; do + if setpriv --reuid=1000 --regid=1000 --clear-groups env -i "$launcher" "$forged" --print \ >/dev/null 2>&1; then - fail "debug accepted an unreadable initial environment boundary" + fail "launcher accepted caller-selected payload $forged" + fi +done +[[ ! -s $event_log ]] || fail "rejected payload selection reached sudo" +chmod 0775 "$collector" +if run_debug "$launcher" 2>/dev/null; then + fail "launcher accepted a group-writable packaged collector" fi -[[ ! -s $event_log && ! -e $test_home/collector ]] || - fail "unreadable initial environment reached the debug workflow" -pass "unreadable initial environment state fails closed" +chmod 0755 "$collector" +[[ ! -s $event_log ]] || fail "wrong-mode collector reached sudo" +chmod 0775 "$test_tmp/lib/omarchy" +run_debug "$launcher" 2>/dev/null && fail "launcher accepted a writable collector ancestor" +chmod 0755 "$test_tmp/lib/omarchy" +chown 1000:1000 "$test_tmp/lib" +run_debug "$launcher" 2>/dev/null && fail "launcher accepted a caller-owned collector ancestor" +chown 0:0 "$test_tmp/lib" +mv "$test_tmp/lib/omarchy" "$test_tmp/lib/omarchy-real" +ln -s omarchy-real "$test_tmp/lib/omarchy" +run_debug "$launcher" 2>/dev/null && fail "launcher followed a collector ancestor symlink" +unlink "$test_tmp/lib/omarchy" +mv "$test_tmp/lib/omarchy-real" "$test_tmp/lib/omarchy" +[[ ! -s $event_log ]] || fail "untrusted collector ancestry reached sudo" +pass "forged argv and untrusted collector file or ancestry fail before sudo" -signal_script="$script_dir/signal-cleanup" -cat >"$signal_script" <<'SIGNAL_TEST' +after_pin="$test_home/after-pin" +replacement_ran="$test_home/replacement-ran" +original_collector="$test_tmp/original-collector" +: >"$event_log" +rm -f "$after_pin" "$replacement_ran" "$test_home/collector" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_AFTER_PIN_DELAY_MARKER="$after_pin" TEST_REPLACEMENT_RAN="$replacement_ran" \ + TEST_COLLECTOR_RAN="$test_home/collector" "$launcher" --no-sudo --print >/dev/null & +replace_pid=$! +for attempt in {1..200}; do + [[ ! -e $after_pin ]] || break + sleep 0.005 +done +[[ -e $after_pin ]] || fail "collector pin race did not become ready" +mv "$collector" "$original_collector" +cat >"$collector" <<'REPLACEMENT' #!/bin/bash -p -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" -cleanup_signal_test() { - local status=$? - omarchy_security_exit_with_revoked_sudo "$status" -} -trap cleanup_signal_test EXIT -omarchy_security_install_signal_exit_traps -: >"$TEST_SIGNAL_READY" -while :; do :; done -SIGNAL_TEST -chmod 0755 "$signal_script" +: >"$TEST_REPLACEMENT_RAN" +REPLACEMENT +chmod 0755 "$collector" +wait "$replace_pid" +[[ -e $test_home/collector && ! -e $replacement_ran ]] || + fail "post-validation collector replacement changed the executed payload" +mv "$original_collector" "$collector" +pass "collector execution stays pinned to the validated inode across replacement" + +: >"$event_log" +rm -f "$test_home/collector" +if run_debug "$launcher" TEST_DMESG_BYTES=4097 2>"$test_home/oversized-error"; then + fail "debug accepted an oversized kernel log" +fi +grep -q 'exceeds the safe debug collection limit' "$test_home/oversized-error" || + fail "oversized kernel log did not report its bounded failure" +[[ ! -e $test_home/collector && $(tail -n 1 "$event_log") == invalidate ]] || + fail "oversized kernel log was published or not followed by revocation" +pass "oversized kernel output fails closed before Bash collection" + +: >"$event_log" +rm -f "$test_home/collector" +if run_debug "$launcher" TEST_DMESG_STATUS=9 2>/dev/null; then + fail "debug accepted a failed privileged dmesg worker" +fi +[[ ! -e $test_home/collector && $(tail -n 1 "$event_log") == invalidate ]] || + fail "failed dmesg reached collection or skipped final revocation" +pass "privileged worker failure is reaped and revoked before collection" revoke_armed="$test_home/revoke-armed" -signal_ready="$test_home/signal-ready" : >"$token" chown 1000:1000 "$token" -rm -f "$revoke_armed" "$signal_ready" +rm -f "$revoke_armed" "$test_home/collector" setpriv --reuid=1000 --regid=1000 --clear-groups \ - env -i HOME="$test_home" TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" \ - TEST_WAITER_ARMED="$armed" TEST_DELAY_INVALIDATE_MARKER="$revoke_armed" \ - TEST_SIGNAL_READY="$signal_ready" \ - "$signal_script" & + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_DELAY_INVALIDATE_MARKER="$revoke_armed" TEST_COLLECTOR_RAN="$test_home/collector" \ + "$launcher" --no-sudo --print >/dev/null 2>&1 & signal_pid=$! -for attempt in {1..200}; do - [[ ! -e $signal_ready ]] || break - sleep 0.005 -done -[[ -e $signal_ready ]] || fail "signal cleanup process did not become ready" -kill -TERM "$signal_pid" for attempt in {1..200}; do [[ ! -e $revoke_armed ]] || break sleep 0.005 done -[[ -e $revoke_armed ]] || fail "signal cleanup did not begin its blocking invalidation" +[[ -e $revoke_armed ]] || fail "native signal cleanup did not begin blocking revocation" +kill -TERM "$signal_pid" kill -TERM "$signal_pid" set +e wait "$signal_pid" signal_status=$? set -e -[[ $signal_status == 143 && ! -e $token ]] || - fail "a second TERM interrupted sudo revocation" "status=$signal_status token=$([[ -e $token ]] && echo present || echo absent)" -pass "cleanup ignores a second TERM until cached sudo authorization is revoked" +[[ $signal_status == 143 && ! -e $token && ! -e $test_home/collector ]] || + fail "a second TERM interrupted native sudo revocation" "status=$signal_status" +pass "native cleanup ignores repeated TERM until cached authorization is revoked" + +post_fork="$test_home/post-fork" +dmesg_ready="$test_home/dmesg-ready" +: >"$token" +chown 1000:1000 "$token" +rm -f "$post_fork" "$dmesg_ready" "$test_home/collector" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" \ + TEST_DMESG_DELAY_MARKER="$dmesg_ready" TEST_POST_FORK_DELAY_MARKER="$post_fork" \ + TEST_COLLECTOR_RAN="$test_home/collector" "$launcher" --print >/dev/null 2>&1 & +worker_pid=$! +for attempt in {1..200}; do + [[ ! -e $post_fork ]] || break + sleep 0.005 +done +[[ -e $post_fork ]] || fail "post-fork signal race window did not become ready" +kill -TERM "$worker_pid" +kill -TERM "$worker_pid" +set +e +wait "$worker_pid" +worker_status=$? +set -e +[[ $worker_status == 143 && ! -e $token && ! -e $test_home/collector ]] || + fail "post-fork signals were lost before child publication" "status=$worker_status" +pass "blocked post-fork signals publish, terminate, reap, and revoke the fixed worker" From 4cdb3903d9556295a5649d11eb5e541450ddd854 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 5 Sep 2026 19:24:13 +0100 Subject: [PATCH 07/10] Keep debug authentication in foreground --- .../omarchy/security/omarchy-debug-launcher.c | 30 ++++---- test/shell.d/debug-sudo-security-test.sh | 74 +++++++++++++++++++ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/default/omarchy/security/omarchy-debug-launcher.c b/default/omarchy/security/omarchy-debug-launcher.c index 6480f1c7684..d9ddff89610 100644 --- a/default/omarchy/security/omarchy-debug-launcher.c +++ b/default/omarchy/security/omarchy-debug-launcher.c @@ -55,7 +55,7 @@ static void handle_signal(int signo) { pid_t child = active_child; if (!caught_signal) caught_signal = signo; - if (child_phase == CHILD_WORKER && child > 0) kill(-child, signo); + if (child_phase == CHILD_WORKER && child > 0) kill(child, signo); } static int install_signal_handlers(void) { @@ -71,15 +71,17 @@ static int install_signal_handlers(void) { return 0; } -static void restore_signal_defaults(void) { +static int restore_signal_defaults(void) { struct sigaction action = { .sa_handler = SIG_DFL, }; sigemptyset(&action.sa_mask); - sigaction(SIGHUP, &action, NULL); - sigaction(SIGINT, &action, NULL); - sigaction(SIGTERM, &action, NULL); + if (sigaction(SIGHUP, &action, NULL) || sigaction(SIGINT, &action, NULL) || + sigaction(SIGTERM, &action, NULL)) { + return -1; + } + return 0; } static int prepare_exec_signals(void) { @@ -95,7 +97,10 @@ static int prepare_exec_signals(void) { sigprocmask(SIG_SETMASK, &previous, NULL); return -1; } - restore_signal_defaults(); + if (restore_signal_defaults()) { + sigprocmask(SIG_SETMASK, &previous, NULL); + return -1; + } return sigprocmask(SIG_SETMASK, &previous, NULL); } @@ -155,7 +160,7 @@ static char **sudo_environment(void) { static const char *const test_names[] = { "TEST_DELAY_INVALIDATE_MARKER", "TEST_DMESG_BYTES", "TEST_DMESG_DELAY_MARKER", "TEST_DMESG_STATUS", "TEST_EVENT_LOG", "TEST_SUDO_NO_N", "TEST_SUDO_TOKEN", - "TEST_WAITER_ARMED", + "TEST_PROMPT_MARKER", "TEST_WAITER_ARMED", }; static char *clean[sizeof(test_names) / sizeof(test_names[0]) + 3]; size_t kept = 0; @@ -216,10 +221,10 @@ static pid_t spawn_command(char *const argv[], int stdout_fd, bool merge_stderr, return -1; } if (!child) { - if (setpgid(0, 0)) _exit(127); if (phase == CHILD_WORKER) { if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() == 1) _exit(127); } + if (restore_signal_defaults()) _exit(127); if (sigprocmask(SIG_SETMASK, &previous, NULL)) _exit(127); if (stdout_fd >= 0 && dup2(stdout_fd, STDOUT_FILENO) < 0) _exit(127); if (merge_stderr && dup2(STDOUT_FILENO, STDERR_FILENO) < 0) _exit(127); @@ -227,7 +232,6 @@ static pid_t spawn_command(char *const argv[], int stdout_fd, bool merge_stderr, _exit(127); } - setpgid(child, child); #ifdef OMARCHY_DEBUG_TESTING { const char *marker = getenv("TEST_POST_FORK_DELAY_MARKER"); @@ -240,7 +244,7 @@ static pid_t spawn_command(char *const argv[], int stdout_fd, bool merge_stderr, #endif active_child = child; child_phase = phase; - if (caught_signal && phase == CHILD_WORKER) kill(-child, caught_signal); + if (caught_signal && phase == CHILD_WORKER) kill(child, caught_signal); sigprocmask(SIG_SETMASK, &previous, NULL); return child; } @@ -288,17 +292,17 @@ static int capture_command(char *const argv[], int output_fd, size_t limit, ssize_t received = read(pipefd[0], buffer, sizeof(buffer)); if (received < 0 && errno == EINTR) continue; if (received < 0) { - kill(-child, SIGKILL); + kill(child, SIGKILL); break; } if (!received) break; if (total > limit || (size_t)received > limit - total) { *overflowed = true; - kill(-child, SIGKILL); + kill(child, SIGKILL); break; } if (write_all(output_fd, buffer, (size_t)received)) { - kill(-child, SIGKILL); + kill(child, SIGKILL); break; } total += (size_t)received; diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh index bc81a4816ac..3c49d3dab84 100644 --- a/test/shell.d/debug-sudo-security-test.sh +++ b/test/shell.d/debug-sudo-security-test.sh @@ -8,6 +8,7 @@ require_command unshare require_command setpriv require_command cc require_command readelf +require_command script if [[ ${OMARCHY_DEBUG_SUDO_SECURITY_NS:-0} != 1 ]]; then outer_uid=$(id -u) @@ -79,6 +80,7 @@ static void event(const char *message) { int main(int argc, char **argv) { const char *token = need("TEST_SUDO_TOKEN"); + const char *prompt_marker; int index = 1, no_update = 0, noninteractive = 0, fd; if (argc == 2 && !strcmp(argv[1], "-h")) { @@ -114,6 +116,21 @@ int main(int argc, char **argv) { close(fd); usleep(200000); } + prompt_marker = getenv("TEST_PROMPT_MARKER"); + if (prompt_marker && *prompt_marker) { + char response[64]; + int tty; + + fd = open(prompt_marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0 || dprintf(fd, "%ld %ld\n", (long)getppid(), (long)getpid()) < 0) return 118; + close(fd); + tty = open("/dev/tty", O_RDWR); + if (tty < 0 || dprintf(tty, "Password: ") < 0 || read(tty, response, sizeof(response)) <= 0) { + return 117; + } + close(tty); + event("prompt-complete"); + } if (index >= argc || setgid(0) || setuid(0)) return 124; if (!strcmp(argv[index], "/usr/bin/dmesg")) { const char *delay_marker = getenv("TEST_DMESG_DELAY_MARKER"); @@ -252,6 +269,63 @@ run_debug() { "$@" "$command" --print >/dev/null } +pty_debug_command() { + local prompt_marker=$1 + local -a command=( + setpriv --reuid=1000 --regid=1000 --clear-groups + env -i HOME="$test_home" XDG_RUNTIME_DIR="$test_home/runtime" + PATH="$stub_bin:/usr/bin:/bin" VIRTUAL_ENV="$test_home/venv" + TEST_SUDO_TOKEN="$token" TEST_EVENT_LOG="$event_log" TEST_WAITER_ARMED="$armed" + TEST_PROMPT_MARKER="$prompt_marker" TEST_COLLECTOR_RAN="$test_home/collector" + TEST_STAGING_MARKER="$staging_marker" TEST_REAL_SUDO="$stub_bin/sudo" + TEST_PAYLOAD="$test_home/payload" TEST_VICTIM="$victim" "$launcher" --print + ) + + printf '%q ' "${command[@]}" +} + +prompt_marker="$test_home/prompt-ready" +: >"$event_log" +rm -f "$armed" "$prompt_marker" "$test_home/collector" "$token" +prompt_command=$(pty_debug_command "$prompt_marker") +if ! printf 'test password\n' | /usr/bin/timeout 5 /usr/bin/script -qefc "$prompt_command" /dev/null \ + >/dev/null; then + fail "cold sudo authentication could not complete through the foreground PTY" +fi +[[ -e $prompt_marker && -e $test_home/collector && ! -e $token ]] || + fail "PTY authentication did not reach collection or retained authorization" +grep -qxF prompt-complete "$event_log" || fail "PTY authentication did not read its password" +pass "cold sudo authentication reads and completes in the foreground PTY" + +: >"$event_log" +rm -f "$armed" "$prompt_marker" "$test_home/collector" "$token" +prompt_command=$(pty_debug_command "$prompt_marker") +pty_input="$test_home/prompt-input" +mkfifo "$pty_input" +exec 9<>"$pty_input" +/usr/bin/timeout 5 /usr/bin/script -qefc "$prompt_command" /dev/null \ + <"$pty_input" >/dev/null & +pty_supervisor=$! +for attempt in {1..200}; do + [[ ! -s $prompt_marker ]] || break + sleep 0.005 +done +[[ -s $prompt_marker ]] || fail "sudo did not begin its foreground PTY prompt" +read -r prompt_launcher_pid prompt_worker_pid <"$prompt_marker" +kill -TERM "$prompt_launcher_pid" +kill -TERM "$prompt_launcher_pid" +set +e +wait "$pty_supervisor" +prompt_status=$? +set -e +exec 9>&- +[[ $prompt_status == 143 && ! -e $test_home/collector && $(tail -n 1 "$event_log") == invalidate ]] || + fail "termination during the password prompt did not reap and revoke" "status=$prompt_status" +if kill -0 "$prompt_worker_pid" 2>/dev/null; then + fail "password-prompting sudo worker survived launcher termination" +fi +pass "termination during a foreground sudo prompt reaps and revokes" + : >"$event_log" : >"$token" chown 1000:1000 "$token" From 4ae25cd4d241392158607ac96edd2e92717ee7ba Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 21:50:47 +0100 Subject: [PATCH 08/10] 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 2f3d2242ec5..890d4430d80 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 717e8aaf243..5f7cd1dc750 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 00000000000..b97160e460f --- /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 86028a230f6..6db0dff5f17 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 6d54633ca13..06965215f0a 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 00000000000..c70d69e5ebf --- /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" From a6385e60b836f41895fd016bf170c9b62128c59a Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 22:17:32 +0100 Subject: [PATCH 09/10] Bound sudo policy natively and guard expiry package transactions --- bin/omarchy-sudo-passwordless | 70 ++++++++++++++----- .../hooks/05-omarchy-passwordless-revoke.hook | 12 ++++ docs/passwordless-sudo.md | 6 +- .../passwordless-grant-lifecycle-test.sh | 38 +++++++++- 4 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 default/libalpm/hooks/05-omarchy-passwordless-revoke.hook diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 5f7cd1dc750..92d7ae168a7 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -28,6 +28,7 @@ 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 PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -182,7 +183,11 @@ classify_generated_rule() { if [[ $suffix =~ ^[0-9]+$ ]]; then name=${contents%' ALL=(ALL) NOPASSWD: ALL'} - valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]] + if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + fi + name=${contents%%' ALL=(ALL) NOTAFTER='*} + valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" else @@ -256,17 +261,16 @@ cleanup_all_locked() { return "$failed" } -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 - owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1 - mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1 +verify_root_policy_file() { + local file=$1 owner mode canonical current + [[ -f $file && ! -L $file ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$file") || return 1 + [[ $canonical == "$file" ]] || return 1 + owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1 [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 - current=${BOOT_CLEANUP_FILE%/*} + current=${file%/*} while :; do [[ -d $current && ! -L $current ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$current") || return 1 @@ -277,9 +281,36 @@ verify_boot_cleanup() { current=${current%/*} [[ -n $current ]] || current=/ done +} +verify_boot_cleanup() { + local active_rules hook + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 + verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1 active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 - [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] + [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1 + verify_root_policy_file "$PACKAGE_HOOK" || return 1 + hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1 + [[ $hook == '[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail' ]] +} + +package_removing_locked() { + # ALPM must abort before removing the helper or boot cleanup if revocation + # fails. The marker also blocks publication after this lock is released. + (umask 077; : >"$REMOVAL_BLOCKER") || return 1 + /usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1 + cleanup_all_locked } prepare_state_file() { @@ -306,10 +337,13 @@ start_expiry_timer() { } publish_rule() { - local uid="$1" name="$2" destination tmp + local uid="$1" name="$2" expires="$3" destination tmp deadline + valid_expiry "$expires" || return 1 + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + [[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1 destination=$(rule_file "$uid") tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 - if ! /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$tmp" || + if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" || ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || ! /usr/sbin/visudo -cf "$tmp" >/dev/null || ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then @@ -339,7 +373,7 @@ enable_locked() { valid_minutes "$minutes" || return 1 prepare_root_state || return 1 verify_boot_cleanup || { - echo "omarchy-sudo-passwordless: package-owned boot cleanup rule is missing or unsafe" >&2 + echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2 return 1 } @@ -363,7 +397,7 @@ enable_locked() { abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi - if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi @@ -464,12 +498,16 @@ root_dispatch() { (($# == 0)) && ((EUID == 0)) || return 1 with_root_lock cleanup_all_locked ;; + __package-removing) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock package_removing_locked + ;; *) return 1 ;; esac } case "${1:-}" in - __status|__enable|__disable|__expire|__cleanup-all) + __status|__enable|__disable|__expire|__cleanup-all|__package-removing) action=$1 shift root_dispatch "$action" "$@" diff --git a/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook new file mode 100644 index 00000000000..f0bce15d324 --- /dev/null +++ b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook @@ -0,0 +1,12 @@ +[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index b97160e460f..0e805e6a2ee 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -4,7 +4,7 @@ ## 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. +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. The sudoers rule also embeds the same UTC deadline with `NOTAFTER`, so sudo independently rejects it after expiry even if timer cleanup is delayed. 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. @@ -12,9 +12,9 @@ Each new expiry callback carries its timer identity. A delayed predecessor canno ## 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. +The packaging companion must put the publication/expiry command, `omarchy-security-functions` `omarchy-nopasswd-sudo.conf` and the pre-transaction revocation hook 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. +Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, 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. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. 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. diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index c70d69e5ebf..b3198405642 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -19,7 +19,7 @@ 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" +mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/hooks" export TEST_GRANT_ROOT="$test_tmp" cat >"$test_tmp/bin/stat" <<'STUB' #!/bin/bash @@ -60,6 +60,7 @@ library="$test_tmp/grant-functions.sh" -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|/usr/share/libalpm/hooks|$test_tmp/hooks|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" \ @@ -68,6 +69,8 @@ library="$test_tmp/grant-functions.sh" -e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \ -e 's|/usr/bin/chown|/usr/bin/true|g' >"$library" +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" + 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" @@ -197,3 +200,36 @@ if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash 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" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + record=$(read_state_record 1000) + expiry=${record#*$'\t'} + expiry=${expiry%%$'\t'*} + deadline=$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ) + [[ $(cat "$(rule_file 1000)") == "audituser ALL=(ALL) NOTAFTER=$deadline NOPASSWD: ALL" ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + classify_generated_rule "$(rule_file 1000)" + rm -f "$(state_file 1000)" + remove_known_legacy_rules + [[ ! -e $(rule_file 1000) ]] +) || fail "native sudo deadline or state-independent bounded rule cleanup is incorrect" +pass "sudo policy contains the same deadline and bounded orphan rules are recognized" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + if TEST_FAIL_RULE_DELETE=1 package_removing_locked; then exit 1; fi + [[ -f $REMOVAL_BLOCKER && -f $(rule_file 1000) ]] + ! enable_locked 1000 5 + package_removing_locked + [[ ! -e $(rule_file 1000) ]] + rm -f "$REMOVAL_BLOCKER" "$PACKAGE_HOOK" + ! enable_locked 1000 5 +) || fail "pre-transaction revocation error or missing hook does not prevent new grants" +pass "package hook fails closed and grants require its installed policy" From f57934c1acf87ed9f2114936c80c8c9205a4da7e Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 23:27:42 +0100 Subject: [PATCH 10/10] Preserve debug capture failures and probe namespace support --- .../omarchy/security/omarchy-debug-launcher.c | 4 ++ test/shell.d/debug-capture-test.sh | 69 +++++++++++++++++++ test/shell.d/debug-sudo-security-test.sh | 13 ++-- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100755 test/shell.d/debug-capture-test.sh diff --git a/default/omarchy/security/omarchy-debug-launcher.c b/default/omarchy/security/omarchy-debug-launcher.c index d9ddff89610..bf8d08eea49 100644 --- a/default/omarchy/security/omarchy-debug-launcher.c +++ b/default/omarchy/security/omarchy-debug-launcher.c @@ -275,6 +275,7 @@ static int capture_command(char *const argv[], int output_fd, size_t limit, bool merge_stderr, bool *overflowed) { unsigned char buffer[16384]; size_t total = 0; + bool capture_failed = false; int pipefd[2]; int status = 0; pid_t child; @@ -292,6 +293,7 @@ static int capture_command(char *const argv[], int output_fd, size_t limit, ssize_t received = read(pipefd[0], buffer, sizeof(buffer)); if (received < 0 && errno == EINTR) continue; if (received < 0) { + capture_failed = true; kill(child, SIGKILL); break; } @@ -302,6 +304,7 @@ static int capture_command(char *const argv[], int output_fd, size_t limit, break; } if (write_all(output_fd, buffer, (size_t)received)) { + capture_failed = true; kill(child, SIGKILL); break; } @@ -309,6 +312,7 @@ static int capture_command(char *const argv[], int output_fd, size_t limit, } close(pipefd[0]); if (wait_for_child(child, &status)) return 125; + if (capture_failed) return 125; if (*overflowed) return 124; return command_status(status); } diff --git a/test/shell.d/debug-capture-test.sh b/test/shell.d/debug-capture-test.sh new file mode 100755 index 00000000000..f061af3cfd1 --- /dev/null +++ b/test/shell.d/debug-capture-test.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command cc + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Exercise capture with an ordinary printf child, without sudo or namespaces. +cat >"$test_tmp/capture-test.c" <<'C' +#define _GNU_SOURCE +#include +#include + +static int read_error; +static ssize_t capture_read(int fd, void *buffer, size_t size) { + if (read_error) { + errno = read_error; + read_error = 0; + return -1; + } + return read(fd, buffer, size); +} + +#define read capture_read +#define main omarchy_debug_main +#define OMARCHY_SUDO_PATH "/usr/bin/printf" +#include "omarchy-debug-launcher.c" +#undef main +#undef read + +static int check_capture(int fd, size_t limit, int error, int expected) { + char *const argv[] = {"printf", "hello", NULL}; + bool overflowed = false; + int status; + + read_error = error; + status = capture_command(argv, fd, limit, false, &overflowed); + if (status != expected || overflowed != (expected == 124)) return 1; + errno = 0; + if (waitpid(-1, NULL, WNOHANG) != -1 || errno != ECHILD) return 1; + return 0; +} + +int main(void) { + char bytes[5]; + int memory = create_memfd("capture-test"); + int full = open("/dev/full", O_WRONLY | O_CLOEXEC); + if (memory < 0 || full < 0 || install_signal_handlers()) return 1; + + if (check_capture(memory, 5, 0, 0) || lseek(memory, 0, SEEK_SET) < 0 || + read(memory, bytes, sizeof(bytes)) != sizeof(bytes) || memcmp(bytes, "hello", 5)) return 1; + if (check_capture(full, 5, 0, 125)) return 1; + if (check_capture(memory, 5, EIO, 125)) return 1; + if (check_capture(memory, 5, EINTR, 0)) return 1; + if (check_capture(memory, 2, 0, 124)) return 1; + close(full); + close(memory); + return 0; +} +C + +cc -std=c11 -O2 -Wall -Wextra -Werror \ + -I "$ROOT/default/omarchy/security" "$test_tmp/capture-test.c" -o "$test_tmp/capture-test" +"$test_tmp/capture-test" || fail "debug capture preserves I/O errors, retries interruption and reaps its child" +pass "debug capture handles exact output, full destination, read failure, interrupted read and overflow" diff --git a/test/shell.d/debug-sudo-security-test.sh b/test/shell.d/debug-sudo-security-test.sh index 3c49d3dab84..95c77990f85 100644 --- a/test/shell.d/debug-sudo-security-test.sh +++ b/test/shell.d/debug-sudo-security-test.sh @@ -19,10 +19,15 @@ if [[ ${OMARCHY_DEBUG_SUDO_SECURITY_NS:-0} != 1 ]]; then pass "no subordinate uid/gid range; skipping debug sudo proof" exit 0 fi - exec unshare --user --mount \ - --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ - --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ - env OMARCHY_DEBUG_SUDO_SECURITY_NS=1 bash "$0" + namespace=(unshare --user --mount + --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536") + if "${namespace[@]}" true 2>/dev/null; then + exec "${namespace[@]}" env OMARCHY_DEBUG_SUDO_SECURITY_NS=1 bash "$0" + else + pass "requested user/mount namespace unavailable; skipping debug sudo proof" + exit 0 + fi fi [[ $(id -u) == 0 ]] || fail "debug proof did not enter its root namespace"