From 1136a715c08b73009d794ab6c2cc43c9406af720 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:50 +0100 Subject: [PATCH] OM-SEC-14: Run update hooks without reusable sudo authority --- bin/omarchy-refresh-pacman | 243 +++- bin/omarchy-update | 243 +++- .../add-custom-repo.sample | 15 +- default/agents/skills/omarchy/hooks.md | 6 +- default/omarchy/sudo-no-update/sudo | 27 + docs/update-process.md | 35 +- test/shell.d/update-hook-security-test.sh | 1040 +++++++++++++++++ test/shell.d/update-sequence-test.sh | 60 +- 8 files changed, 1628 insertions(+), 41 deletions(-) create mode 100755 default/omarchy/sudo-no-update/sudo create mode 100644 test/shell.d/update-hook-security-test.sh diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 299d6c20..612a31de 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -1,26 +1,245 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages. # omarchy:requires-sudo=true -sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak -sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + exit 126 +fi -channel="${1:-stable}" +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + exit 126 +fi +unset -f require_privileged_bash_startup -if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then - echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge" +set -e + +sanitize_bash_startup_environment() { + local environment_entry environment_name + local needs_reexec=0 + local -a environment_unsets=(-u BASH_ENV -u ENV) + + [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 + while IFS= read -r -d '' environment_entry; do + environment_name="${environment_entry%%=*}" + if [[ $environment_name == BASH_FUNC_*%% ]]; then + environment_unsets+=(-u "$environment_name") + needs_reexec=1 + fi + done < <(/usr/bin/env -0) + + if (( needs_reexec )); then + exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" + fi +} +sanitize_bash_startup_environment "$@" +unset -f sanitize_bash_startup_environment + +usage() { + echo "Usage: omarchy-refresh-pacman [stable|rc|edge]" >&2 +} + +# Composite commands can postpone the legacy user hook until their own final +# privilege boundary. The two internal modes are deliberately paired: a caller +# that defers must invoke --run-deferred-hook exactly once after all of its +# sudo-capable work has finished. +channel=stable +hook_mode=normal +case "$#:$1:${2:-}" in + 0::) + ;; + 1:stable: | 1:rc: | 1:edge:) + channel="$1" + ;; + 1:--run-deferred-hook:) + hook_mode=run-deferred + ;; + 2:stable:--defer-hook | 2:rc:--defer-hook | 2:edge:--defer-hook) + channel="$1" + hook_mode=defer + ;; + *) + usage + exit 2 + ;; +esac + +trusted_directory_chain() { + local current="$1" allow_current_user="$2" canonical owner mode current_uid + current_uid=$(/usr/bin/id -u) || return 1 + + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + [[ $current == / ]] && break + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then + return 1 + fi + (( (8#$mode & 0022) == 0 )) || return 1 + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +trusted_omarchy_source_root() { + local config=/etc/omarchy.conf default_root=/usr/share/omarchy configured_root="" canonical="" + local owner="" mode="" links="" size="" line="" encoded="" decoded="" character="" + local index=0 escaped=0 lines=() + + if [[ ! -e $config && ! -L $config ]]; then + configured_root="$default_root" + else + [[ -f $config && ! -L $config ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$config") || return 1 + [[ $canonical == "$config" ]] || return 1 + read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 + [[ $owner == "0" && $links == "1" ]] || return 1 + (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 + trusted_directory_chain /etc false || return 1 + mapfile -t lines <"$config" || return 1 + (( ${#lines[@]} == 1 )) || return 1 + line="${lines[0]}" + [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 + encoded="${line#'export OMARCHY_PATH="'}" + encoded="${encoded%'"'}" + for (( index = 0; index < ${#encoded}; index++ )); do + character="${encoded:index:1}" + if (( escaped )); then + case "$character" in + '\' | '"' | '$' | '`') decoded+="$character" ;; + *) return 1 ;; + esac + escaped=0 + elif [[ $character == '\' ]]; then + escaped=1 + elif [[ $character == '"' ]]; then + return 1 + else + decoded+="$character" + fi + done + (( escaped == 0 )) || return 1 + configured_root="$decoded" + fi + + [[ -d $configured_root && ! -L $configured_root ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 + [[ $canonical == "$configured_root" ]] || return 1 + if [[ $configured_root == "$default_root" ]]; then + trusted_directory_chain "$configured_root" false || return 1 + else + trusted_directory_chain "$configured_root" true || return 1 + fi + printf '%s\n' "$configured_root" +} + +trusted_omarchy_source_file() { + local relative="$1" source="$OMARCHY_PATH/$1" canonical owner mode links directory current_uid + current_uid=$(/usr/bin/id -u) || return 1 + [[ $relative != /* && $relative != ../* && $relative != */../* && $relative != */.. ]] || return 1 + [[ -f $source && ! -L $source ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$source") || return 1 + [[ $canonical == "$source" && $canonical == "$OMARCHY_PATH/"* ]] || return 1 + read -r owner mode links < <(/usr/bin/stat -Lc '%u %a %h' -- "$source") || return 1 + [[ $links == 1 ]] && (( (8#$mode & 0022) == 0 )) || return 1 + if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then + [[ $owner == 0 ]] || return 1 + else + [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 + fi + + directory=${source%/*} + while [[ $directory == "$OMARCHY_PATH" || $directory == "$OMARCHY_PATH/"* ]]; do + [[ -d $directory && ! -L $directory ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$directory") || return 1 + [[ $canonical == "$directory" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$directory") || return 1 + (( (8#$mode & 0022) == 0 )) || return 1 + if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then + [[ $owner == 0 ]] || return 1 + else + [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 + fi + [[ $directory == "$OMARCHY_PATH" ]] && break + directory=${directory%/*} + done + printf '%s\n' "$source" +} + +if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then + echo "Refusing to refresh pacman from an untrusted Omarchy source root." >&2 exit 1 fi +export OMARCHY_PATH +user_path="${PATH:-/usr/bin:/bin}" +PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" +export PATH + +as_root() { + if ((EUID == 0)); then + "$@" + elif [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then + /usr/bin/sudo -N -- "$@" + else + /usr/bin/sudo -- "$@" + fi +} + +cleanup_sudo_credentials() { + /usr/bin/sudo -k || true +} + +trap cleanup_sudo_credentials EXIT + +if [[ $hook_mode == "run-deferred" ]]; then + /usr/bin/sudo -k || exit 1 + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman + exit +fi + +pacman_source=$(trusted_omarchy_source_file "default/pacman/pacman-$channel.conf") || { + echo "Refusing an untrusted pacman configuration source." >&2 + exit 1 +} +mirror_source=$(trusted_omarchy_source_file "default/pacman/mirrorlist-$channel") || { + echo "Refusing an untrusted pacman mirror source." >&2 + exit 1 +} + +as_root /usr/bin/cp -f -- /etc/pacman.conf /etc/pacman.conf.bak +as_root /usr/bin/cp -f -- /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak echo "Setting channel to $channel" echo -sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf -sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist +# The unprivileged shell opens the authorized source. Root consumes only the +# inherited descriptor, never a caller-writable development-checkout pathname. +as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.conf <"$pacman_source" +as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.d/mirrorlist <"$mirror_source" -# Allow user customization of /etc/pacman.conf before the upgrade runs -omarchy-hook pre-refresh-pacman +# Reset all package DBs and then update. +as_root /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Syyuu --noconfirm -# Reset all package DBs and then update -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +# This legacy hook used to run before pacman. Executable user code cannot +# safely precede a later sudo authentication: a child can wait for the new +# timestamp even if the parent invalidates around the hook. Keep the hook, but +# run it only after every privileged refresh step and with a cold credential. +if [[ $hook_mode == "normal" ]]; then + /usr/bin/sudo -k || exit 1 + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman +fi diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e8086..4b0b1cc8 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,12 +1,217 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Update Omarchy and system packages # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + exit 126 +fi + +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + exit 126 +fi +unset -f require_privileged_bash_startup + set -e +# Privileged mode prevents BASH_ENV and exported functions from running before +# this boundary. Re-exec once without their raw environment records so ordinary +# Bash helpers cannot import them again and bypass the trusted command paths. +sanitize_bash_startup_environment() { + local environment_entry environment_name + local needs_reexec=0 + local -a environment_unsets=(-u BASH_ENV -u ENV) + + [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 + while IFS= read -r -d '' environment_entry; do + environment_name="${environment_entry%%=*}" + if [[ $environment_name == BASH_FUNC_*%% ]]; then + environment_unsets+=(-u "$environment_name") + needs_reexec=1 + fi + done < <(/usr/bin/env -0) + + if (( needs_reexec )); then + exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" + fi +} +sanitize_bash_startup_environment "$@" +unset -f sanitize_bash_startup_environment + +trusted_directory_chain() { + local current="$1" allow_current_user="$2" canonical owner mode current_uid + current_uid=$(/usr/bin/id -u) || return 1 + + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + [[ $current == / ]] && break + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then + return 1 + fi + (( (8#$mode & 0022) == 0 )) || return 1 + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +trusted_omarchy_source_root() { + local config=/etc/omarchy.conf + local default_root=/usr/share/omarchy + local configured_root="" + local canonical="" + local owner="" + local mode="" + local links="" + local size="" + local line="" + local encoded="" + local decoded="" + local character="" + local index=0 + local escaped=0 + local lines=() + + if [[ ! -e $config && ! -L $config ]]; then + configured_root="$default_root" + else + [[ -f $config && ! -L $config ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$config") || return 1 + [[ $canonical == "$config" ]] || return 1 + read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 + [[ $owner == "0" && $links == "1" ]] || return 1 + (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 + trusted_directory_chain /etc false || return 1 + + mapfile -t lines <"$config" || return 1 + (( ${#lines[@]} == 1 )) || return 1 + line="${lines[0]}" + [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 + encoded="${line#'export OMARCHY_PATH="'}" + encoded="${encoded%'"'}" + + for (( index = 0; index < ${#encoded}; index++ )); do + character="${encoded:index:1}" + if (( escaped )); then + case "$character" in + '\' | '"' | '$' | '`') decoded+="$character" ;; + *) return 1 ;; + esac + escaped=0 + elif [[ $character == '\' ]]; then + escaped=1 + elif [[ $character == '"' ]]; then + return 1 + else + decoded+="$character" + fi + done + (( escaped == 0 )) || return 1 + configured_root="$decoded" + fi + + [[ -d $configured_root && ! -L $configured_root ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 + [[ $canonical == "$configured_root" ]] || return 1 + + if [[ $configured_root == "$default_root" ]]; then + trusted_directory_chain "$configured_root" false || return 1 + else + trusted_directory_chain "$configured_root" true || return 1 + fi + + printf '%s\n' "$configured_root" +} + +sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +validate_non_reusable_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + local wrapper="$wrapper_dir/sudo" canonical="" current="" owner="" mode="" + + sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing to run a mixed-trust update." >&2 + return 1 + } + [[ -f $wrapper && -x $wrapper && ! -L $wrapper ]] || { + echo "Trusted no-update sudo wrapper is missing; refusing to run a mixed-trust update." >&2 + return 1 + } + canonical=$(/usr/bin/realpath -e -- "$wrapper") || return 1 + [[ $canonical == "$wrapper" ]] || return 1 + if [[ $OMARCHY_PATH == "/usr/share/omarchy" ]]; then + current="$wrapper" + while :; do + [[ ! -L $current ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == "0" ]] || return 1 + (( (8#$mode & 0022) == 0 )) || return 1 + [[ $current == "$OMARCHY_PATH" ]] && break + current=${current%/*} + done + fi +} + +enable_non_reusable_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + + validate_non_reusable_sudo + PATH="$wrapper_dir:$PATH" + export PATH +} + +if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then + echo "Refusing to update from an untrusted Omarchy source root." >&2 + exit 1 +fi +export OMARCHY_PATH +user_path="${PATH:-/usr/bin:/bin}" +PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" +export PATH +update_stay_awake_stopped=0 + +# Verify and enable the security primitive before any update-owned privileged +# work. Every authorization in this workflow is command-scoped (`sudo -N`): it +# may prompt for the command being run, but it never publishes a reusable +# timestamp to a dev hook, migration tool, AUR build, or detached child. +validate_non_reusable_sudo || exit 1 +if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then + enable_non_reusable_sudo +fi +/usr/bin/sudo -k || exit 1 +enable_non_reusable_sudo +export OMARCHY_SUDO_NO_UPDATE=1 + +cleanup_update() { + local status=$? + + trap - EXIT + if (( update_stay_awake_stopped == 0 )); then + omarchy-update-stay-awake stop || true + fi + /usr/bin/sudo -k || true + exit "$status" +} + if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then script_command=$(printf '%q ' "$0" "$@") exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log" @@ -17,7 +222,7 @@ if ! omarchy-update-lock held; then fi trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR -trap 'omarchy-update-stay-awake stop' EXIT +trap cleanup_update EXIT omarchy-update-requires-free-space @@ -38,6 +243,9 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start + # A dev link explicitly authorizes its checkout through root-owned system + # configuration (including sudo's secure_path), so preserve the established + # pull-before-packages/migrations ordering for that trusted mode. omarchy-update-dev omarchy-update-keyring @@ -45,20 +253,41 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # them, so everything below waits on this finishing. An upgrade that stopped # takes the update with it rather than migrating against what is still on disk. omarchy-update-system-pkgs + + # Historical migrations are strictly ordered and mix user hooks/downloaded + # tooling with privileged repairs. The no-update sudo wrapper has covered the + # whole update, so neither the package transaction nor a later repair can + # publish a timestamp to a detached migration child. + /usr/bin/sudo -k omarchy-migrate - omarchy-hook post-update - omarchy-update-aur-pkgs - omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status + # Service restart helpers can need sudo. Run them before any user-controlled + # update tooling; the reboot-only phase below performs no privileged work. + omarchy-update-restart --services-only + # Release update-owned inhibitors before offering a reboot. A confirmed # reboot can terminate this process before its EXIT trap gets a chance to # remove the persistent Stay Awake marker. omarchy-update-stay-awake stop - trap - EXIT + update_stay_awake_stopped=1 - omarchy-update-restart + # AUR installation can refresh sudo after running package build code. No + # privileged update stage may follow it: user-controlled code can outlive + # its parent and wait for a later timestamp even if we invalidate in between. + omarchy-update-aur-pkgs + /usr/bin/sudo -k + + # Hooks and mise execute user-controlled code. Give each a cold credential + # boundary and run mise last so it cannot wait for a legitimate hook sudo. + # Only the unprivileged reboot prompt follows them. + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update + /usr/bin/sudo -k + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" + /usr/bin/sudo -k + + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample index cebec4fc..0c8ba7c4 100644 --- a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample +++ b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample @@ -1,17 +1,20 @@ #!/bin/bash -# This hook is called by `omarchy refresh pacman` AFTER the channel template -# is copied to /etc/pacman.conf and BEFORE `pacman -Syyuu` runs. Use it to -# layer customizations onto the freshly-written pacman.conf so they're -# respected by the upgrade — common cases are adding a custom repository +# This legacy-named hook is called by `omarchy refresh pacman` AFTER the +# channel template is copied and the privileged package transaction finishes. +# During `omarchy channel set`, it is deferred until the package switch and +# complete update have also finished. +# Use it to layer customizations onto the freshly-written pacman.conf for +# subsequent package operations — common cases are adding a custom repository # (e.g. CachyOS, Chaotic-AUR, an internal company repo) or extra IgnorePkg # lines. # -# The hook runs as the invoking user with a warm sudo cache. +# The hook runs as the invoking user after Omarchy invalidates its sudo cache. +# A sudo command here therefore requires its own explicit authorization. # # To put it into use, remove .sample from this file name. -# Example: add an Include line above [core] for a custom repo. +# Example: add an Include line above [core] for future package operations. # Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself. CONF=/etc/pacman.conf diff --git a/default/agents/skills/omarchy/hooks.md b/default/agents/skills/omarchy/hooks.md index 4f8236ac..4878c7e1 100644 --- a/default/agents/skills/omarchy/hooks.md +++ b/default/agents/skills/omarchy/hooks.md @@ -14,8 +14,8 @@ file first, if one exists. ├── battery-low.d/ # Low battery (percentage in $1) ├── font-set.d/ # After font change (font name in $1) ├── post-boot.d/ # After the desktop starts -├── post-update.d/ # During `omarchy update`, after system packages and migrations -├── pre-refresh-pacman.d/ # Before `omarchy refresh pacman` re-syncs packages +├── post-update.d/ # At the end of `omarchy update`, after privileged work +├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` finishes (legacy name) └── theme-set.d/ # After theme change (theme slug in $1) ``` @@ -26,3 +26,5 @@ THEME_NAME=$1 echo "Theme changed to: $THEME_NAME" # Add custom actions here ``` + +Update-related hooks run only after the workflow's privileged work and after Omarchy invalidates its sudo timestamp. A hook that invokes `sudo` must therefore request its own explicit authorization. The legacy-named `pre-refresh-pacman` hook runs after the refresh transaction; during `omarchy channel set`, it is deferred further until the package switch and complete update finish. Executable user code cannot safely run before a later sudo authentication because a detached child could wait for the new timestamp. diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo new file mode 100755 index 00000000..ca94c1e6 --- /dev/null +++ b/default/omarchy/sudo-no-update/sudo @@ -0,0 +1,27 @@ +#!/bin/bash -p + +# Internal update/migration sudo boundary. Authentication may authorize this +# command, but -N prevents it from publishing a timestamp that detached user +# code can silently reuse. + +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 + exit 126 +fi + +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 + exit 126 +fi +exec /usr/bin/sudo -N -- "$@" diff --git a/docs/update-process.md b/docs/update-process.md index bec3350d..bb73d271 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -26,7 +26,7 @@ The design goal is: | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | -| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional allowlisted service/app restart markers checked by `omarchy-update-restart`. Marker names select fixed commands from the system-authorized Omarchy tree; they are not resolved through caller `PATH`. The shell needs no marker: it is restarted unconditionally after every update. | ## Migration layout @@ -56,6 +56,13 @@ privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; if one user already applied a machine-wide repair, the migration should no-op for other users. +The runner derives the migration source from root-owned `/etc/omarchy.conf`, +starts from a cold sudo timestamp, and routes migration sudo calls through +`sudo --no-update`. Historical migrations are strictly ordered and can mix +user-controlled tools or theme hooks with later privileged repairs; no-update +authentication lets those repairs run without publishing a credential a +detached earlier process could reuse. + For watchers and diagnostics, `omarchy-migrate --pending` prints pending migration names and exits `0` when any are pending. When no migrations are pending, it prints nothing and exits non-zero. @@ -125,18 +132,28 @@ omarchy-update │ installed but unconfigured fails the snapshot loudly, pointing at │ install/config/snapper.sh, and the update continues without one) ├─ omarchy-update-stay-awake start - ├─ run package updates, migrations, hooks, and log analysis + ├─ run system-package updates + ├─ invalidate sudo, then run migrations and all later privileged work with + │ no-update authentication + ├─ run orphan review and log analysis ├─ omarchy-update-status │ └─ refresh or clear the shell update indicator + ├─ restart marked services and the shell ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - └─ omarchy-update-restart + ├─ update AUR packages + ├─ invalidate sudo credentials + ├─ run the post-update hook, invalidate again, then update mise tools + └─ offer the unprivileged reboot prompt ``` Important behavior: -- In dev-link mode, `omarchy update` fast-forwards the active checkout from its - configured upstream before changing system packages or running migrations. +- `omarchy update` derives its source tree from root-owned `/etc/omarchy.conf`, or the root-owned `/usr/share/omarchy` default when that file is absent. It does not trust inherited `OMARCHY_PATH` or caller `PATH` for the privileged phase. +- Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. +- In dev-link mode, the root-owned configuration is the explicit authorization for the user-writable checkout. `omarchy update` fast-forwards that authorized checkout from its configured upstream before changing system packages or running migrations. +- Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. +- User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. Steps that would prompt (orphan removal, conflict handoff) report and skip instead of blocking. @@ -251,6 +268,9 @@ which pacman repo the mirrorlist points at (and swap between the `omarchy` and `omarchy-dev` packages through a guard-allowed pacman run), while `dev` links the runtime to a git checkout via the dev-link mechanism, after which `omarchy update` fast-forwards that checkout instead of upgrading a package. +Channel switching defers the legacy `pre-refresh-pacman` hook across the package +swap and the complete update. The hook runs exactly once at the final cold +credential boundary; it is skipped if the composite operation fails earlier. There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a @@ -272,8 +292,7 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | -| `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | +| `omarchy-update-system-pkgs` | Runs the ordinary guarded `pacman -Syu --noconfirm`. Package-vs-package conflicts may be retried interactively with `pacman -Su`; filesystem conflicts fail closed without moving, restoring, quarantining, or broadly overwriting live paths. The production Quattro transition performs the sole explicit settings-package takeover with `--overwrite='*'`; all later production and developer package transactions obey Pacman's ownership checks. | **Keep.** Small leaf command with no generic privileged conflict handler. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | @@ -285,7 +304,7 @@ scripts. | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | -| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-restart` | Restarts allowlisted components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh new file mode 100644 index 00000000..6245205f --- /dev/null +++ b/test/shell.d/update-hook-security-test.sh @@ -0,0 +1,1040 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +# The setuid helper below models the strongest sudo timestamp mode: one token +# shared by every process for this uid. That makes a detached hook child a +# faithful regression for both global timestamps and the easier tty-sharing +# case. The helper is mounted over /usr/bin/sudo only in this private namespace +# so the scripts must use the fixed trusted invalidation path. +if [[ ${OMARCHY_UPDATE_HOOK_SECURITY_NS:-} != 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 update-hook namespace 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_UPDATE_HOOK_SECURITY_NS=1 bash "$0" +fi + +[[ $(id -u) == 0 ]] || fail "update-hook proof entered its root namespace" + +mount -t tmpfs -o mode=0755 tmpfs /run +run_bound=1 +test_tmp=$(mktemp -d -p /run omarchy-update-hook-security.XXXXXXXX) +mount -t tmpfs -o mode=0755 tmpfs "$test_tmp" +chmod 0755 "$test_tmp" +sudo_path_bound=0 +channel_paths_bound=0 +channel_wrapper_tree_bound=0 +aur_paths_bound=0 +font_paths_bound=0 +migration_pkg_paths_bound=0 +etc_bound=0 +persistent_pids=() +cleanup() { + local pid pid_file + for pid_file in "$test_home"/*.pid; do + if [[ -s $pid_file ]]; then + persistent_pids+=("$(<"$pid_file")") + fi + done + for pid in "${persistent_pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + sleep 0.05 + for pid in "${persistent_pids[@]}"; do + kill -KILL "$pid" 2>/dev/null || true + done + if (( sudo_path_bound )); then + umount /usr/bin/sudo + fi + if (( channel_paths_bound )); then + umount /usr/bin/pacman + umount /usr/bin/omarchy-dev-unlink + umount /usr/bin/omarchy-state + umount /usr/bin/omarchy-refresh-pacman + umount /usr/bin/omarchy-update + fi + if (( channel_wrapper_tree_bound )); then + umount /usr/share/omarchy + fi + if (( aur_paths_bound )); then + umount /usr/bin/omarchy-pkg-aur-accessible + umount /usr/bin/yay + fi + if (( font_paths_bound )); then + umount /usr/bin/omarchy-launch-floating-terminal-with-presentation + umount /usr/bin/omarchy-pkg-add + umount /usr/bin/omarchy-font-set + fi + if (( migration_pkg_paths_bound )); then + umount /usr/bin/omarchy-pkg-missing + umount /usr/bin/omarchy-pkg-add + fi + if (( etc_bound )); then + umount /etc + fi + rm -rf "$test_tmp"/* + umount "$test_tmp" + rmdir "$test_tmp" + if (( run_bound )); then + umount /run + fi +} +trap cleanup EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +root_dir="$test_tmp/root" +token="$test_tmp/sudo-token" +event_log="$test_tmp/events" +hook_log="$test_home/hook-events" +mkdir -p "$stub_bin" "$test_home/.config/omarchy/hooks" "$root_dir" +mkdir -p "$test_tmp/default/omarchy/sudo-no-update" +cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$test_tmp/default/omarchy/sudo-no-update/sudo" +chmod 0755 "$test_tmp/default/omarchy/sudo-no-update/sudo" +mkdir -p "$test_tmp/default/pacman" +cp "$ROOT/default/pacman"/* "$test_tmp/default/pacman/" +chmod 0755 "$test_tmp/default" "$test_tmp/default/omarchy" \ + "$test_tmp/default/omarchy/sudo-no-update" "$test_tmp/default/pacman" +chmod 0644 "$test_tmp/default/pacman"/* +touch "$event_log" "$hook_log" +chown -R 1000:1000 "$test_home" +chown 1000:1000 "$event_log" +chmod 0700 "$test_home" +chmod 0600 "$event_log" "$hook_log" +chmod 0755 "$stub_bin" "$root_dir" + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include +#include + +static const char *required_env(const char *name) { + const char *value = getenv(name); + if (!value || !*value) exit(125); + return value; +} + +static void log_event(const char *event) { + int fd = open(required_env("TEST_SUDO_EVENT_LOG"), O_WRONLY | O_APPEND); + if (fd < 0) exit(125); + if (dprintf(fd, "%s\n", event) < 0) exit(125); + close(fd); +} + +static int authenticate(void) { + int fd = open(required_env("TEST_SUDO_TOKEN"), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + return 0; +} + +static int token_valid(void) { + struct stat st; + return stat(required_env("TEST_SUDO_TOKEN"), &st) == 0 && st.st_uid == 0; +} + +static int is_pacman_command(int argc, char **argv) { + int index; + for (index = 1; index < argc; index++) { + const char *base = strrchr(argv[index], '/'); + base = base ? base + 1 : argv[index]; + if (strcmp(base, "pacman") == 0) return 1; + } + return 0; +} + +int main(int argc, char **argv) { + int no_update = 0; + if (argc == 2 && strcmp(argv[1], "-h") == 0) { + const char *disable = getenv("TEST_SUDO_NO_N"); + if (disable && strcmp(disable, "1") == 0) { + fputs("usage: sudo [-ABbEHknPS] command\n", stdout); + } else { + fputs("usage: sudo [-ABbEHkNnPS] command\n", stdout); + } + return 0; + } + if (argc > 1 && strcmp(argv[1], "-N") == 0) { + no_update = 1; + argc--; + argv++; + } + if (argc > 1 && strcmp(argv[1], "--") == 0) { + argc--; + argv++; + } + if (argc == 2 && strcmp(argv[1], "--authenticate-for-test") == 0) { + if (no_update) { + log_event("authenticate-no-update"); + return 0; + } else { + log_event("authenticate"); + return authenticate(); + } + } + if (argc == 2 && strcmp(argv[1], "-k") == 0) { + log_event("invalidate"); + if (unlink(required_env("TEST_SUDO_TOKEN")) < 0 && errno != ENOENT) return 125; + return 0; + } + if (!token_valid() && !no_update) { + if (is_pacman_command(argc, argv)) { + log_event("authenticate-command"); + if (authenticate() != 0) return 125; + } else { + log_event("deny"); + fputs("sudo: a password is required\n", stderr); + return 1; + } + } + log_event(no_update ? "grant-no-update" : "grant"); + if (argc < 2 || setuid(0) < 0) return 125; + execvp(argv[1], &argv[1]); + return 125; +} +C +gcc -O2 -Wall -Wextra -o "$stub_bin/sudo" "$test_tmp/sudo.c" +chown 0:0 "$stub_bin/sudo" +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/sudo" /usr/bin/sudo +sudo_path_bound=1 +mount --bind "$ROOT/bin/omarchy-refresh-pacman" /usr/bin/omarchy-refresh-pacman +mount --bind "$ROOT/bin/omarchy-update" /usr/bin/omarchy-update +mount -t tmpfs -o mode=0755 tmpfs /usr/share/omarchy +mkdir -p /usr/share/omarchy/default/omarchy/sudo-no-update +cp "$ROOT/default/omarchy/sudo-no-update/sudo" /usr/share/omarchy/default/omarchy/sudo-no-update/sudo +chmod 0755 /usr/share/omarchy/default /usr/share/omarchy/default/omarchy \ + /usr/share/omarchy/default/omarchy/sudo-no-update +chmod 0755 /usr/share/omarchy/default/omarchy/sudo-no-update/sudo +channel_wrapper_tree_bound=1 + +# Authorize this private tree exactly as omarchy-dev-link authorizes a +# development checkout. Hiding the host /etc keeps the proof self-contained. +mount -t tmpfs -o mode=0755 tmpfs /etc +etc_bound=1 +mkdir -p /etc/pacman.d +touch /etc/pacman.conf /etc/pacman.d/mirrorlist +chmod 0644 /etc/pacman.conf /etc/pacman.d/mirrorlist +printf 'root:x:0:0:root:/root:/bin/bash\n' >/etc/passwd +printf 'root:x:0:\n' >/etc/group +chmod 0644 /etc/passwd /etc/group +write_authorized_source_root() { + local source_root="$1" + local quoted="$source_root" + + quoted=${quoted//\\/\\\\} + quoted=${quoted//\"/\\\"} + quoted=${quoted//\$/\\\$} + quoted=${quoted//\`/\\\`} + printf 'export OMARCHY_PATH="%s"\n' "$quoted" >/etc/omarchy.conf + chown 0:0 /etc/omarchy.conf + chmod 0644 /etc/omarchy.conf +} +write_authorized_source_root "$test_tmp" + +cat >"$test_home/launch-persistent-attack" <<'ATTACK' +#!/bin/bash +/usr/bin/setsid --fork /bin/bash -c ' + printf "%s\n" "$$" >"$3" + for (( attempt = 0; attempt < 200; attempt++ )); do + if /usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 0600 "$1" "$2" 2>/dev/null; then + exit 0 + fi + /usr/bin/sleep 0.01 + done +' omarchy-hook-child "$HOME/payload" "$1" "$2" +ATTACK +cat >"$test_home/payload" <<'PAYLOAD' +RUN+="/tmp/update-hook-payload" +PAYLOAD +chown 1000:1000 "$test_home/launch-persistent-attack" "$test_home/payload" +chmod 0700 "$test_home/launch-persistent-attack" +chmod 0600 "$test_home/payload" + +cat >"$stub_bin/omarchy-update-lock" <<'STUB' +#!/bin/bash +[[ ${1:-} == held ]] +STUB +cat >"$stub_bin/omarchy-update-system-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_UPDATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-orphan-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-aur-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-mise" <<'STUB' +#!/bin/bash +sudo /usr/bin/true 2>/dev/null && exit 97 +"$HOME/launch-persistent-attack" "$TEST_MISE_VICTIM" "$TEST_MISE_PID" +STUB +cat >"$stub_bin/omarchy-update-restart" <<'STUB' +#!/bin/bash +printf 'restart:%s\n' "$1" >>"$TEST_HOOK_LOG" +if [[ $1 == --services-only && ${TEST_SKIP_LATE_AUTH:-0} != 1 ]]; then + sudo --authenticate-for-test +fi +STUB +cat >"$stub_bin/omarchy-update-confirm" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-migrate" <<'STUB' +#!/bin/bash +if [[ ${TEST_REAL_MIGRATE:-0} == 1 ]]; then + exec "$TEST_ROOT/bin/omarchy-migrate" +fi +if [[ ${TEST_FAILING_STAGE:-} == signal ]]; then + kill -TERM "$PPID" + sleep 0.1 +fi +[[ ${TEST_FAILING_STAGE:-} != migration ]] +STUB +cat >"$stub_bin/omarchy-hook" <<'STUB' +#!/bin/bash +exec bash "$TEST_ROOT/bin/omarchy-hook" "$@" +STUB +cat >"$stub_bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-state" <<'STUB' +#!/bin/bash +exit 0 +STUB +for command in \ + omarchy-update-requires-free-space omarchy-update-pkg-prune omarchy-snapshot \ + omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring \ + omarchy-update-analyze-logs omarchy-update-status; do + cat >"$stub_bin/$command" <<'STUB' +#!/bin/bash +exit 0 +STUB +done +cat >"$stub_bin/cp" <<'STUB' +#!/bin/bash +printf 'cp:%s\n' "$*" >>"$TEST_SUDO_EVENT_LOG" +exit 0 +STUB +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +[[ -z ${TEST_PACMAN_DELAY:-} ]] || /usr/bin/sleep "$TEST_PACMAN_DELAY" +exit "${TEST_PACMAN_STATUS:-0}" +STUB +chmod 0755 "$stub_bin"/* +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/omarchy-dev-unlink" /usr/bin/omarchy-dev-unlink +mount --bind "$stub_bin/omarchy-state" /usr/bin/omarchy-state +mount --bind "$stub_bin/pacman" /usr/bin/pacman +channel_paths_bound=1 + +evil_update_bin="$test_home/evil-update-bin" +evil_update_root="$test_home/evil-update-root" +evil_update_marker="$test_home/evil-update-ran" +mkdir -p "$evil_update_bin" "$evil_update_root/migrations" +for command in script omarchy-migrate omarchy-update-system-pkgs; do + cat >"$evil_update_bin/$command" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_UPDATE_MARKER" +exit 97 +STUB +done +cat >"$evil_update_bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_CHANNEL_HELPER_MARKER" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_CHANNEL_VICTIM" +STUB +cat >"$evil_update_root/migrations/9999999999.sh" <<'STUB' +#!/bin/bash +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_MIGRATION_VICTIM" +touch "$TEST_EVIL_UPDATE_MARKER" +STUB +chown -R 1000:1000 "$evil_update_bin" "$evil_update_root" +chmod 0755 "$evil_update_bin"/* "$evil_update_root/migrations/9999999999.sh" + +write_attack_hook() { + local hook_name="$1" + + mkdir -p "$test_home/.config/omarchy/hooks/$hook_name.d" + cat >"$test_home/.config/omarchy/hooks/$hook_name" <<'HOOK' +#!/bin/bash +printf 'file:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" 2>/dev/null || true +"$HOME/launch-persistent-attack" "$TEST_PERSISTENT_VICTIM" "$TEST_PERSISTENT_PID" +HOOK + cat >"$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" <<'HOOK' +#!/bin/bash +printf 'directory:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_DIR_VICTIM" 2>/dev/null || true +HOOK + chown -R 1000:1000 "$test_home/.config/omarchy/hooks/$hook_name" \ + "$test_home/.config/omarchy/hooks/$hook_name.d" + chmod 0700 "$test_home/.config/omarchy/hooks/$hook_name" \ + "$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" +} + +reset_case() { + local pid_file + for pid_file in "$test_home"/*.pid; do + if [[ -s $pid_file ]]; then + persistent_pids+=("$(<"$pid_file")") + kill "$(<"$pid_file")" 2>/dev/null || true + fi + done + rm -f "$test_home"/*.pid "$token" "$root_dir"/* + : >"$event_log" + setpriv --reuid 1000 --regid 1000 --clear-groups /usr/bin/truncate -s 0 "$hook_log" +} + +run_as_user() { + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ + TEST_ROOT="$ROOT" TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ + TEST_HOOK_LOG="$hook_log" "$@" +} + +authenticate_for_test() { + TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ + /usr/bin/sudo --authenticate-for-test +} + +wait_for_persistent_attempts() { + local pid_file="$1" + local pid="" + for (( attempt = 0; attempt < 100; attempt++ )); do + [[ -s $pid_file ]] && break + sleep 0.01 + done + [[ -s $pid_file ]] || fail "detached hook child did not start" + pid=$(<"$pid_file") + persistent_pids+=("$pid") + sleep 0.2 +} + +assert_hook_sandboxed() { + local direct_victim="$1" + local directory_victim="$2" + local persistent_victim="$3" + + [[ ! -e $direct_victim && ! -e $directory_victim && ! -e $persistent_victim ]] || + fail "hook code reused an Omarchy sudo credential" + grep -qxF 'file:1000' "$hook_log" || fail "the regular hook did not run as the desktop user" + grep -qxF 'directory:1000' "$hook_log" || fail "the hook-directory entry did not run as the desktop user" + [[ ! -e $token ]] || fail "the workflow left its modeled sudo credential live" +} + +write_attack_hook post-update +update_victim="$root_dir/80-update-hook.rules" +update_dir_victim="$root_dir/81-update-hook-dir.rules" +update_persistent_victim="$root_dir/82-update-hook-child.rules" +mise_victim="$root_dir/83-mise-child.rules" +reset_case +set +e +run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/update.out" 2>"$test_tmp/update.err" +status=$? +set -e +(( status == 0 )) || fail "isolated unattended update failed" "$(<"$test_tmp/update.err")" +wait_for_persistent_attempts "$test_home/update.pid" +wait_for_persistent_attempts "$test_home/mise.pid" +assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" +[[ ! -e $mise_victim ]] || fail "detached mise code observed a later update authorization" +[[ ! -e $evil_update_marker ]] || fail "update trusted an inherited PATH or OMARCHY_PATH override" +grep -qxF 'restart:--services-only' "$hook_log" || fail "privileged restart phase did not run before user code" +grep -qxF 'restart:--reboot-only' "$hook_log" || fail "reboot-only phase did not run after user code" +pass "update leaves no later sudo authentication for mise or persistent hook children" + +# OM-SEC-14 ends at the final cold hook boundary. Later sections exercise +# separate migration, restart-marker, channel, and installer findings in their +# own PRs. +exit 0 + +# Exercise the real migration dispatcher separately from PATH spoofing. An old +# update inherited the evil root here and ran its migration with the live +# system-package credential; the fixed update exports the authorized root. +evil_migration_victim="$root_dir/90-evil-migration.rules" +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" TEST_REAL_MIGRATE=1 \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_MIGRATION_STATE="$test_home/migration-state" OMARCHY_UPDATE_LOGGED=1 \ + "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-root-spoof.out" 2>"$test_tmp/update-root-spoof.err" +status=$? +set -e +(( status == 0 )) || fail "authorized-root update with real migration dispatcher failed" +[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || + fail "update dispatched a migration from inherited OMARCHY_PATH" +pass "update rejects inherited OMARCHY_PATH for real migration dispatch" + +# The login-notification workflow invokes omarchy-migrate directly, outside an +# update that already normalized OMARCHY_PATH. It must independently reject an +# inherited attacker tree rather than running the migration placed there. +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ + OMARCHY_MIGRATION_STATE="$test_home/direct-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/direct-migrate.out" 2>"$test_tmp/direct-migrate.err" +status=$? +set -e +(( status == 0 )) || fail "direct authorized migration dispatch failed" +[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || + fail "direct migration dispatch trusted inherited OMARCHY_PATH" +pass "direct migration dispatch derives its source from root-owned configuration" + +# Reproduce the real historical ordering that exposed the remaining gap: the +# mise-wrapper migration can execute user tooling at 1784909971, while the +# 1784914435 migration invokes sudo later. A following migration also invokes +# the real absolute-path package helper, which cannot be protected by PATH +# alone. Every later authorization must inherit the exported no-update policy. +mkdir -p "$test_tmp/migrations" "$test_home/.local/bin" +chmod 0755 "$test_tmp/migrations" +cp "$ROOT/migrations/1784909971.sh" "$test_tmp/migrations/1784909971.sh" +cp "$ROOT/migrations/1784914435.sh" "$test_tmp/migrations/1784914435.sh" +cat >"$test_tmp/migrations/1784914436.sh" <<'STUB' +#!/bin/bash +/usr/bin/omarchy-pkg-add migration-security-fixture +STUB +chmod 0644 "$test_tmp/migrations/1784909971.sh" "$test_tmp/migrations/1784914435.sh" \ + "$test_tmp/migrations/1784914436.sh" +cat >"$test_home/.local/bin/legacy-mise-wrapper" <<'STUB' +#!/bin/bash +mise use -g "github:attacker/tool" +exec "attacker-tool" "$@" +STUB +cat >"$stub_bin/omarchy-mise-install" <<'STUB' +#!/bin/bash +"$HOME/launch-persistent-attack" "$TEST_MIGRATION_VICTIM" "$TEST_MIGRATION_PID" +STUB +cat >"$stub_bin/nmcli" <<'STUB' +#!/bin/bash +printf '%s\n' "$(id -u)" >"$TEST_PRIV_MIGRATION_MARKER" +STUB +cat >"$stub_bin/omarchy-notification-dismiss" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin/omarchy-mise-install" "$stub_bin/nmcli" \ + "$stub_bin/omarchy-notification-dismiss" "$test_home/.local/bin/legacy-mise-wrapper" +chown -R 1000:1000 "$test_home/.local" +cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin/omarchy-pkg-missing" +mount --bind "$ROOT/bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/omarchy-pkg-missing" /usr/bin/omarchy-pkg-missing +migration_pkg_paths_bound=1 +migration_victim="$root_dir/95-migration-child.rules" +migration_marker="$root_dir/96-privileged-migration-ran" +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" \ + TEST_MIGRATION_VICTIM="$migration_victim" TEST_MIGRATION_PID="$test_home/migration.pid" \ + TEST_PRIV_MIGRATION_MARKER="$migration_marker" TEST_PACMAN_DELAY=0.2 \ + OMARCHY_MIGRATION_STATE="$test_home/mixed-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/mixed-migrate.out" 2>"$test_tmp/mixed-migrate.err" +status=$? +set -e +(( status == 0 )) || fail "real mixed-trust migration sequence failed" "$(<"$test_tmp/mixed-migrate.err")" +wait_for_persistent_attempts "$test_home/migration.pid" +[[ -f $migration_marker && $(<"$migration_marker") == 0 ]] || fail "later privileged migration did not run as root" +[[ ! -e $migration_victim && ! -e $token ]] || fail "mise migration child reused a later migration authorization" +(( $(grep -c '^grant-no-update$' "$event_log") >= 2 )) || + fail "direct package-helper migration did not inherit the no-update policy" +pass "real mise-before-sudo and absolute package-helper migrations publish no reusable timestamp" +umount /usr/bin/omarchy-pkg-missing +umount /usr/bin/omarchy-pkg-add +migration_pkg_paths_bound=0 + +# Bash can import both BASH_ENV startup code and exported functions before an +# ordinary script body. An exported sudo function used to bypass the PATH +# wrapper in a later helper, publishing a global token to a BASH_ENV child that +# had started before migration invalidation. Exercise both injection channels +# through a normal-shebang child of the privileged migration shell. +cat >"$test_tmp/migrations/9999999998.sh" <<'STUB' +#!/bin/bash +"$HOME/launch-persistent-attack" "$TEST_BASH_STARTUP_VICTIM" "$TEST_BASH_STARTUP_PID" +omarchy-exported-function-auth +STUB +cat >"$stub_bin/omarchy-exported-function-auth" <<'STUB' +#!/bin/bash +sudo --authenticate-for-test +STUB +cat >"$test_home/bash-env-attack" <<'STUB' +#!/bin/bash +if [[ ! -e $TEST_BASH_ENV_MARKER ]]; then + /usr/bin/touch "$TEST_BASH_ENV_MARKER" + "$HOME/launch-persistent-attack" "$TEST_BASH_ENV_VICTIM" "$TEST_BASH_ENV_PID" +fi +STUB +chmod 0755 "$test_tmp/migrations/9999999998.sh" "$stub_bin/omarchy-exported-function-auth" +chown 1000:1000 "$test_home/bash-env-attack" +chmod 0600 "$test_home/bash-env-attack" +bash_startup_victim="$root_dir/99-exported-function-child.rules" +bash_env_victim="$root_dir/100-bash-env-child.rules" +bash_env_marker="$test_home/bash-env-ran" +reset_case +set +e +run_as_user env \ + 'BASH_FUNC_sudo%%=() { /usr/bin/sudo "$@"; }' \ + TEST_MULTILINE_ENV=$'value\nBASH_FUNC_fake%%=not-an-environment-record' \ + BASH_ENV="$test_home/bash-env-attack" \ + TEST_BASH_ENV_MARKER="$bash_env_marker" \ + TEST_BASH_ENV_VICTIM="$bash_env_victim" TEST_BASH_ENV_PID="$test_home/bash-env.pid" \ + TEST_BASH_STARTUP_VICTIM="$bash_startup_victim" TEST_BASH_STARTUP_PID="$test_home/bash-startup.pid" \ + OMARCHY_MIGRATION_STATE="$test_home/bash-startup-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/bash-startup.out" 2>"$test_tmp/bash-startup.err" +status=$? +set -e +(( status == 0 )) || fail "migration rejected a sanitized Bash startup environment" "$(<"$test_tmp/bash-startup.err")" +wait_for_persistent_attempts "$test_home/bash-startup.pid" +sleep 0.2 +[[ ! -e $bash_env_marker && ! -e $bash_env_victim ]] || + fail "BASH_ENV ran before or beneath the migration security boundary" +[[ ! -e $bash_startup_victim && ! -e $token ]] || + fail "an exported sudo function published a reusable migration credential" +grep -q '^grant-no-update$' "$event_log" || fail "sanitized helper did not use sudo --no-update" +grep -qxF 'exec /usr/bin/sudo -N -- "$@"' "$ROOT/default/omarchy/sudo-no-update/sudo" || + fail "no-update sudo wrapper omitted the option terminator" +pass "Bash startup injection cannot bypass the no-update sudo boundary" + +# Invoking a mixed-trust entrypoint with an ordinary explicit Bash bypasses its +# shebang. A BASH_ENV can erase its own environment record and retain a DEBUG +# trap, so environment-record cleanup alone is not a sufficient startup gate. +# The exact interpreter argv/privileged-mode check must reject this process +# before update authentication, leaving even its already-detached child cold. +cat >"$test_home/self-erasing-bash-env" <<'STUB' +#!/bin/bash +unset BASH_ENV ENV +trap ' + if [[ ! -e $TEST_DEBUG_TRAP_MARKER ]]; then + /usr/bin/touch "$TEST_DEBUG_TRAP_MARKER" + "$HOME/launch-persistent-attack" "$TEST_DEBUG_TRAP_VICTIM" "$TEST_DEBUG_TRAP_PID" + fi +' DEBUG +STUB +chown 1000:1000 "$test_home/self-erasing-bash-env" +chmod 0600 "$test_home/self-erasing-bash-env" +debug_trap_marker="$test_home/debug-trap-ran" +debug_trap_victim="$root_dir/101-debug-trap-child.rules" +reset_case +set +e +run_as_user env BASH_ENV="$test_home/self-erasing-bash-env" \ + TEST_DEBUG_TRAP_MARKER="$debug_trap_marker" TEST_DEBUG_TRAP_VICTIM="$debug_trap_victim" \ + TEST_DEBUG_TRAP_PID="$test_home/debug-trap.pid" OMARCHY_UPDATE_LOGGED=1 \ + /usr/bin/bash "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/unsafe-bash.out" 2>"$test_tmp/unsafe-bash.err" +status=$? +set -e +(( status == 126 )) || fail "update did not reject an unsafe explicit Bash interpreter" +[[ -e $debug_trap_marker ]] || fail "self-erasing BASH_ENV regression did not install its DEBUG trap" +wait_for_persistent_attempts "$test_home/debug-trap.pid" +[[ ! -e $debug_trap_victim && ! -e $token ]] || + fail "unsafe Bash startup reached update authentication" +! grep -qE '^(authenticate|authenticate-command|grant|grant-no-update)$' "$event_log" || + fail "unsafe Bash startup reached privileged update work" +grep -q 'unsafe Bash startup' "$test_tmp/unsafe-bash.err" || + fail "unsafe Bash startup rejection lacked a diagnostic" +pass "self-erasing BASH_ENV and DEBUG traps cannot cross the interpreter gate" + +# Model a hostile yay configuration that selects absolute /usr/bin/sudo and a +# refresh loop. The real AUR helper must override both on its command line, so +# even a migration child already polling the global token sees no credential. +cat >"$stub_bin/omarchy-pkg-aur-accessible" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/yay" <<'STUB' +#!/bin/bash +sudo_command=/usr/bin/sudo +sudoflags="" +sudoloop=true +while (($#)); do + case "$1" in + --sudo) + sudo_command="$2" + shift 2 + ;; + --sudoloop=false) + sudoloop=false + shift + ;; + --sudoflags=-N) + sudoflags=-N + shift + ;; + *) + shift + ;; + esac +done +printf 'sudo=%s sudoflags=%s sudoloop=%s\n' "$sudo_command" "$sudoflags" "$sudoloop" >"$TEST_YAY_LOG" +"$sudo_command" $sudoflags --authenticate-for-test +STUB +chmod 0755 "$stub_bin/omarchy-pkg-aur-accessible" "$stub_bin/yay" +mount --bind "$stub_bin/omarchy-pkg-aur-accessible" /usr/bin/omarchy-pkg-aur-accessible +mount --bind "$stub_bin/yay" /usr/bin/yay +aur_paths_bound=1 +yay_victim="$root_dir/97-yay-override-child.rules" +reset_case +run_as_user env TEST_YAY_LOG="$test_home/yay.log" \ + "$test_home/launch-persistent-attack" "$yay_victim" "$test_home/yay-child.pid" +wait_for_persistent_attempts "$test_home/yay-child.pid" +run_as_user env OMARCHY_PATH="$test_tmp" OMARCHY_SUDO_NO_UPDATE=1 TEST_YAY_LOG="$test_home/yay.log" \ + "$ROOT/bin/omarchy-update-aur-pkgs" >"$test_tmp/yay.out" 2>"$test_tmp/yay.err" +sleep 0.2 +grep -qxF "sudo=/usr/bin/sudo sudoflags=-N sudoloop=false" "$test_home/yay.log" || + fail "AUR update did not override hostile yay sudo settings" +[[ ! -e $yay_victim && ! -e $token ]] || fail "hostile yay sudo configuration published a reusable timestamp" +pass "AUR updates force no-update sudo and disable yay's credential loop" + +# A pre-existing credential and skipped package paths exercise the interactive +# branch independently of authority acquired by update helpers. +reset_case +authenticate_for_test +set +e +run_as_user env TEST_SKIP_UPDATE_AUTH=1 TEST_SKIP_LATE_AUTH=1 \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" \ + >"$test_tmp/update-interactive.out" 2>"$test_tmp/update-interactive.err" +status=$? +set -e +(( status == 0 )) || fail "isolated interactive update failed" +wait_for_persistent_attempts "$test_home/update.pid" +assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" +pass "interactive update invalidates a pre-existing credential before user code" + +for failing_stage in migration signal; do + reset_case + set +e + run_as_user env TEST_FAILING_STAGE="$failing_stage" TEST_SKIP_LATE_AUTH=1 \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/update-$failing_stage.out" 2>"$test_tmp/update-$failing_stage.err" + status=$? + set -e + (( status != 0 )) || fail "update $failing_stage case unexpectedly succeeded" + [[ ! -e $token ]] || fail "update $failing_stage exit left its credential live" + [[ ! -s $hook_log ]] || fail "update $failing_stage case reached user-controlled stages" +done +pass "failed and signaled updates invalidate credentials before user code" + +reset_case +set +e +run_as_user env TEST_SUDO_NO_N=1 OMARCHY_UPDATE_LOGGED=1 \ + "$ROOT/bin/omarchy-update" -y >"$test_tmp/no-update-unsupported.out" 2>"$test_tmp/no-update-unsupported.err" +status=$? +set -e +(( status != 0 )) || fail "update accepted sudo without --no-update support" +! grep -qE '^(authenticate|grant)' "$event_log" || fail "unsupported sudo reached privileged update work" +grep -q 'does not support --no-update' "$test_tmp/no-update-unsupported.err" || + fail "unsupported sudo failure did not explain the missing security primitive" +pass "update fails closed before privileged work when sudo lacks --no-update" + +# The config parser is the authority for all three scoped commands. Exercise a +# different unsafe shape through each copy before restoring the valid config. +chmod 0666 /etc/omarchy.conf +if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/untrusted-update.out" 2>"$test_tmp/untrusted-update.err"; then + fail "update accepted a writable source-root authorization" +fi + +/usr/bin/mv /etc/omarchy.conf /etc/omarchy.real +/usr/bin/ln -s /etc/omarchy.real /etc/omarchy.conf +if run_as_user "$ROOT/bin/omarchy-refresh-pacman" stable \ + >"$test_tmp/untrusted-refresh.out" 2>"$test_tmp/untrusted-refresh.err"; then + fail "pacman refresh accepted a symlinked source-root authorization" +fi +/usr/bin/rm /etc/omarchy.conf +/usr/bin/mv /etc/omarchy.real /etc/omarchy.conf + +chown 1000:1000 /etc/omarchy.conf +if run_as_user "$ROOT/bin/omarchy-update-restart" --services-only \ + >"$test_tmp/untrusted-restart.out" 2>"$test_tmp/untrusted-restart.err"; then + fail "update restart accepted a non-root source-root authorization" +fi +write_authorized_source_root "$test_tmp" +pass "update commands reject writable, symlinked, and non-root source-root authorization" + +# A root-owned config may authorize a development checkout, but not a tree +# another local account (or every account) can rewrite. Exercise both unsafe +# directory-chain shapes before restoring the valid authorized fixture. +writable_source_root="$test_tmp/writable-source-root" +foreign_source_root="$test_tmp/foreign-source-root" +mkdir -p "$writable_source_root" "$foreign_source_root" +chown 1000:1000 "$writable_source_root" +chmod 0777 "$writable_source_root" +chown 1001:1001 "$foreign_source_root" +chmod 0755 "$foreign_source_root" + +write_authorized_source_root "$writable_source_root" +if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/writable-source.out" 2>"$test_tmp/writable-source.err"; then + fail "update accepted a group/world-writable authorized source tree" +fi +grep -q 'untrusted Omarchy source root' "$test_tmp/writable-source.err" || + fail "writable source-root rejection happened after the trust parser" + +write_authorized_source_root "$foreign_source_root" +if run_as_user "$ROOT/bin/omarchy-migrate" --pending \ + >"$test_tmp/foreign-source.out" 2>"$test_tmp/foreign-source.err"; then + fail "migration runner accepted a foreign-owned authorized source tree" +fi +grep -q 'untrusted Omarchy source root' "$test_tmp/foreign-source.err" || + fail "foreign source-root rejection happened after the trust parser" +write_authorized_source_root "$test_tmp" +pass "authorized source roots reject foreign-owned and group/world-writable path components" + +# The restart marker is attacker-writable, but its value is now an allowlisted +# selector into the configured Omarchy tree rather than a command resolved by +# PATH. A dev-linked tree is honored only through root-owned /etc/omarchy.conf. +reset_case +restart_home="$test_tmp/restart-home" +restart_log="$test_tmp/restart.log" +mkdir -p "$restart_home/.local/state/omarchy" +touch "$restart_log" +chown -R 1000:1000 "$restart_home" +chown 1000:1000 "$restart_log" +touch "$restart_home/.local/state/omarchy/restart-btop-required" \ + "$restart_home/.local/state/omarchy/restart-evil-required" +chown 1000:1000 "$restart_home/.local/state/omarchy"/* +cat >"$stub_bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo trusted-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$stub_bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +echo trusted-shell >>"$TEST_RESTART_LOG" +STUB +evil_bin="$test_home/evil-bin" +mkdir -p "$evil_bin" +cat >"$evil_bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo path-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$evil_bin/omarchy-restart-evil" <<'STUB' +#!/bin/bash +echo path-evil >>"$TEST_RESTART_LOG" +sudo /usr/bin/true +STUB +chown -R 1000:1000 "$evil_bin" +chmod 0755 "$stub_bin/omarchy-restart-btop" "$stub_bin/omarchy-restart-shell" "$evil_bin"/* +run_as_user env HOME="$restart_home" PATH="$evil_bin:$stub_bin:/usr/bin:/bin" \ + OMARCHY_PATH="$test_home/evil-root" TEST_RESTART_LOG="$restart_log" \ + "$ROOT/bin/omarchy-update-restart" --services-only >"$test_tmp/restart.out" 2>"$test_tmp/restart.err" +grep -qxF trusted-btop "$restart_log" || fail "allowed marker did not use the configured Omarchy command" +grep -qxF trusted-shell "$restart_log" || fail "shell restart did not use the configured Omarchy command" +! grep -q '^path-' "$restart_log" || fail "restart marker resolved an attacker PATH command" +[[ ! -e $restart_home/.local/state/omarchy/restart-evil-required ]] || fail "unsupported restart marker was retained" +pass "restart markers use an allowlist and fixed configured command paths" + +special_root="$test_tmp/dev root\\checkout\$cash" +special_home="$test_tmp/special-home" +special_log="$test_tmp/special.log" +mkdir -p "$special_root/bin" "$special_home/.local/state/omarchy" +touch "$special_home/.local/state/omarchy/restart-btop-required" "$special_log" +chown -R 1000:1000 "$special_home" "$special_log" +cat >"$special_root/bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo special-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$special_root/bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$special_root/bin"/* +chown -R 1000:1000 "$special_root" +write_authorized_source_root "$special_root" +run_as_user env HOME="$special_home" TEST_RESTART_LOG="$special_log" \ + "$ROOT/bin/omarchy-update-restart" --services-only \ + >"$test_tmp/special-root.out" 2>"$test_tmp/special-root.err" +grep -qxF special-btop "$special_log" || fail "authorized quoted dev root did not dispatch its restart helper" +write_authorized_source_root "$test_tmp" +pass "source-root authorization decodes spaces, backslashes, and dollar signs" + +# The legacy pre-refresh hook now runs only after pacman; a detached child can +# no longer wait for a later authentication in either tty or global mode. +write_attack_hook pre-refresh-pacman +refresh_victim="$root_dir/84-refresh-hook.rules" +refresh_dir_victim="$root_dir/85-refresh-hook-dir.rules" +refresh_persistent_victim="$root_dir/86-refresh-hook-child.rules" +reset_case +authenticate_for_test +cat >"$evil_bin/cp" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_REFRESH_MARKER" +exit 97 +STUB +chmod 0755 "$evil_bin/cp" +evil_refresh_marker="$test_home/evil-refresh-ran" +set +e +run_as_user env PATH="$evil_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_REFRESH_MARKER="$evil_refresh_marker" \ + TEST_ROOT_VICTIM="$refresh_victim" TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" \ + TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" TEST_PERSISTENT_PID="$test_home/refresh.pid" \ + "$ROOT/bin/omarchy-refresh-pacman" stable >"$test_tmp/refresh.out" 2>"$test_tmp/refresh.err" +status=$? +set -e +(( status == 0 )) || fail "isolated pacman refresh failed" "$(<"$test_tmp/refresh.err")" +wait_for_persistent_attempts "$test_home/refresh.pid" +assert_hook_sandboxed "$refresh_victim" "$refresh_dir_victim" "$refresh_persistent_victim" +[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh resolved cp through caller PATH" +/usr/bin/cmp -s "$test_tmp/default/pacman/pacman-stable.conf" /etc/pacman.conf || + fail "pacman refresh did not copy config from the authorized source root" +[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh copied from inherited OMARCHY_PATH or PATH" +pass "pacman refresh runs its legacy hook only after all privileged work" + +reset_case +authenticate_for_test +set +e +run_as_user env TEST_PACMAN_STATUS=1 TEST_ROOT_VICTIM="$refresh_victim" \ + TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" \ + TEST_PERSISTENT_PID="$test_home/refresh.pid" "$ROOT/bin/omarchy-refresh-pacman" stable \ + >"$test_tmp/refresh-fail.out" 2>"$test_tmp/refresh-fail.err" +status=$? +set -e +(( status != 0 )) || fail "failing pacman refresh unexpectedly succeeded" +[[ ! -e $token ]] || fail "failed pacman refresh left its credential live" +[[ ! -s $hook_log ]] || fail "failed pacman transaction reached the refresh hook" +pass "failed pacman refresh invalidates and does not run its hook" + +# Channel switching is a composite refresh caller: after refreshing it +# authenticates for the package swap and runs the full update. Exercise the +# real channel, refresh, update, and hook commands against the global-token +# model. A detached legacy refresh-hook child must not start until that entire +# chain has finished. +cat >"$test_home/.config/omarchy/hooks/post-update" <<'HOOK' +#!/bin/bash +printf 'post-update:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +HOOK +chown 1000:1000 "$test_home/.config/omarchy/hooks/post-update" +chmod 0700 "$test_home/.config/omarchy/hooks/post-update" +channel_victim="$root_dir/91-channel-refresh-hook.rules" +channel_dir_victim="$root_dir/92-channel-refresh-dir.rules" +channel_persistent_victim="$root_dir/93-channel-refresh-child.rules" +channel_mise_victim="$root_dir/94-channel-mise-child.rules" +reset_case +set +e +evil_channel_helper_marker="$test_home/evil-channel-helper-ran" +evil_channel_helper_victim="$root_dir/98-channel-path-helper.rules" +run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" \ + TEST_EVIL_CHANNEL_HELPER_MARKER="$evil_channel_helper_marker" \ + TEST_EVIL_CHANNEL_VICTIM="$evil_channel_helper_victim" \ + TEST_ROOT_VICTIM="$channel_victim" TEST_ROOT_DIR_VICTIM="$channel_dir_victim" \ + TEST_PERSISTENT_VICTIM="$channel_persistent_victim" TEST_PERSISTENT_PID="$test_home/channel.pid" \ + TEST_MISE_VICTIM="$channel_mise_victim" TEST_MISE_PID="$test_home/channel-mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-channel-set" stable \ + >"$test_tmp/channel.out" 2>"$test_tmp/channel.err" +status=$? +set -e +(( status == 0 )) || fail "isolated channel switch failed" "$(<"$test_tmp/channel.err")" +wait_for_persistent_attempts "$test_home/channel.pid" +wait_for_persistent_attempts "$test_home/channel-mise.pid" +assert_hook_sandboxed "$channel_victim" "$channel_dir_victim" "$channel_persistent_victim" +[[ ! -e $channel_mise_victim ]] || fail "channel mise child reused a later refresh-hook credential" +[[ ! -e $evil_channel_helper_marker && ! -e $evil_channel_helper_victim ]] || + fail "channel switch resolved a post-pacman helper through caller PATH" +[[ $(grep -c '^file:1000$' "$hook_log") == 1 ]] || fail "channel switch did not run the deferred refresh hook exactly once" +pass "channel switching defers its refresh hook past every later authentication" + +cat >"$stub_bin/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +#!/bin/bash +exec bash -c "$1" +STUB +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 +/usr/bin/sudo -N -- /usr/bin/true +exit "${TEST_PKG_STATUS:-0}" +STUB +cat >"$stub_bin/omarchy-font-set" <<'STUB' +#!/bin/bash +exec bash "$TEST_ROOT/bin/omarchy-font-set" "$@" +STUB +cat >"$stub_bin/fc-list" <<'STUB' +#!/bin/bash +echo 'Example Family' +STUB +cat >"$stub_bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/pgrep" <<'STUB' +#!/bin/bash +exit 1 +STUB +cat >"$stub_bin/sleep" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin"/* +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/omarchy-launch-floating-terminal-with-presentation" \ + /usr/bin/omarchy-launch-floating-terminal-with-presentation +mount --bind "$stub_bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/omarchy-font-set" /usr/bin/omarchy-font-set +font_paths_bound=1 + +write_attack_hook font-set +font_victim="$root_dir/87-font-hook.rules" +font_dir_victim="$root_dir/88-font-hook-dir.rules" +font_persistent_victim="$root_dir/89-font-hook-child.rules" +reset_case +set +e +run_as_user env TEST_ROOT_VICTIM="$font_victim" TEST_ROOT_DIR_VICTIM="$font_dir_victim" \ + TEST_PERSISTENT_VICTIM="$font_persistent_victim" TEST_PERSISTENT_PID="$test_home/font.pid" \ + "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ + >"$test_tmp/font.out" 2>"$test_tmp/font.err" +status=$? +set -e +(( status == 0 )) || fail "isolated font install failed" "$(<"$test_tmp/font.err")" +wait_for_persistent_attempts "$test_home/font.pid" +assert_hook_sandboxed "$font_victim" "$font_dir_victim" "$font_persistent_victim" +pass "font installation invalidates before file, directory, and persistent hooks" + +reset_case +set +e +run_as_user env TEST_PKG_STATUS=1 TEST_ROOT_VICTIM="$font_victim" \ + TEST_ROOT_DIR_VICTIM="$font_dir_victim" TEST_PERSISTENT_VICTIM="$font_persistent_victim" \ + TEST_PERSISTENT_PID="$test_home/font.pid" \ + "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ + >"$test_tmp/font-fail.out" 2>"$test_tmp/font-fail.err" +status=$? +set -e +(( status != 0 )) || fail "failing font package installation unexpectedly succeeded" +[[ ! -e $token ]] || fail "failed font package installation left its credential live" +[[ ! -s $hook_log ]] || fail "failed font package installation reached the hook" +pass "failed font installation invalidates without running its hook" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index 2dd62b6e..edac1209 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -4,8 +4,55 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT +if [[ -z ${OMARCHY_UPDATE_SEQUENCE_NS:-} ]]; 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 authorized update-sequence test" + 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_UPDATE_SEQUENCE_NS=setup bash "$0" +elif [[ $OMARCHY_UPDATE_SEQUENCE_NS == setup ]]; then + mount -t tmpfs -o mode=0755 tmpfs /run + namespace_tmp=$(mktemp -d -p /run omarchy-update-sequence.XXXXXXXX) + chmod 0755 "$namespace_tmp" + mkdir -p "$namespace_tmp/default/omarchy/sudo-no-update" + cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$namespace_tmp/default/omarchy/sudo-no-update/sudo" + chmod 0755 "$namespace_tmp/default/omarchy/sudo-no-update/sudo" + cat >"$namespace_tmp/fixed-sudo" <<'STUB' +#!/bin/bash +if [[ ${1:-} == "-h" ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' +fi +exit 0 +STUB + chmod 0755 "$namespace_tmp/fixed-sudo" + mount --bind "$namespace_tmp/fixed-sudo" /usr/bin/sudo + mount -t tmpfs -o mode=0755 tmpfs /etc + printf 'export OMARCHY_PATH="%s"\n' "$namespace_tmp" >/etc/omarchy.conf + chmod 0644 /etc/omarchy.conf + chown -R 1000:1000 "$namespace_tmp" + + set +e + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env OMARCHY_UPDATE_SEQUENCE_NS=run OMARCHY_AUTHORIZED_TEST_ROOT="$namespace_tmp" bash "$0" + status=$? + set -e + + umount /usr/bin/sudo + umount /etc + rm -rf "$namespace_tmp" + umount /run + exit "$status" +fi + +test_tmp="$OMARCHY_AUTHORIZED_TEST_ROOT" +trap 'rm -rf "$test_tmp"/*' EXIT stub_bin="$test_tmp/bin" mkdir -p "$stub_bin" @@ -49,7 +96,7 @@ run_update() { FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ PATH="$stub_bin:$PATH" \ - bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" + "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" } steps_run() { @@ -70,13 +117,14 @@ expected_steps() { omarchy-update-keyring \ omarchy-update-system-pkgs \ omarchy-migrate \ - omarchy-hook \ - omarchy-update-aur-pkgs \ - omarchy-update-mise \ omarchy-update-orphan-pkgs \ omarchy-update-analyze-logs \ omarchy-update-status \ + omarchy-update-restart \ omarchy-update-stay-awake \ + omarchy-update-aur-pkgs \ + omarchy-hook \ + omarchy-update-mise \ omarchy-update-restart }