diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 9b97c0db..5bb261cb 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -6,6 +6,12 @@ set -e +# Install and upgrade callers can start this helper as root. Ignore their PATH +# so optional commands never fall through to a user-writable directory. +if (( EUID == 0 )); then + export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +fi + target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}} if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) @@ -34,7 +40,8 @@ auth required pam_faillock.so authsucc account include system-local-login EOF -if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then +if [[ -x /usr/bin/fprintd-list ]] && + /usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then echo "Configuring lock screen fingerprint authentication..." as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2e9bd759..a4caa7fe 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -175,7 +175,10 @@ target_home=$(getent passwd "$target_user" | cut -d: -f6) [[ -n $target_home && -d $target_home ]] || fail "Home directory for '$target_user' was not found." target_uid=$(id -u "$target_user") target_runtime_dir="/run/user/$target_uid" -package_path="/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin:$target_home/.local/bin" +# User-local commands are needed only after dropping to the target user. Never +# expose their search path to commands run through as_root. +root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +package_path="$root_path:$target_home/.local/bin" as_root() { if (( EUID == 0 )); then @@ -661,7 +664,7 @@ configure_lock_authentication() { as_root env \ OMARCHY_INSTALL_USER="$target_user" \ OMARCHY_PATH=/usr/share/omarchy \ - PATH="$package_path" \ + PATH="$root_path" \ "$apply_lock" } @@ -1287,7 +1290,7 @@ apply_firewall_defaults() { fi log "Applying Omarchy firewall defaults" - as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$package_path" \ + as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \ bash -euo pipefail "$firewall_script" || warn "Could not apply firewall defaults; run 'sudo bash $firewall_script' after reboot." } diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh new file mode 100644 index 00000000..5e7170cf --- /dev/null +++ b/test/shell.d/apply-lock-test.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +apply_lock="$ROOT/bin/omarchy-apply-lock" + +root_path_guard=$(awk ' + /^if \(\( EUID == 0 \)\); then$/ { inside = 1 } + inside { print } + inside && /^fi$/ { exit } +' "$apply_lock") +grep -Fx ' export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' <<<"$root_path_guard" >/dev/null || + fail "the root lock helper replaces its inherited command path" +if grep -E '(\.local/bin|target_user|target_home)' <<<"$root_path_guard" >/dev/null; then + fail "the root lock helper does not retain a user-controlled command directory" +fi +pass "the root lock helper uses only trusted command directories" + +grep -F '[[ -x /usr/bin/fprintd-list ]]' "$apply_lock" >/dev/null || + fail "the lock helper checks the trusted fprintd-list executable" +grep -F '/usr/bin/fprintd-list "$target_user"' "$apply_lock" >/dev/null || + fail "the lock helper invokes fprintd-list by its trusted absolute path" +if grep -F 'omarchy-cmd-present fprintd-list' "$apply_lock" >/dev/null || + grep -E '(^|[[:space:];&|])fprintd-list([[:space:]]|$)' "$apply_lock" >/dev/null || + grep -E 'command[[:space:]]+-v[[:space:]]+fprintd-list' "$apply_lock" >/dev/null; then + fail "the lock helper does not resolve fprintd-list through PATH" +fi +pass "the lock helper pins fprintd-list to its packaged system path" + +# Exercise the helper as real root when the suite already has it, or as root in +# an unprivileged user namespace otherwise. A hardened kernel can disable user +# namespaces, so preserve the static coverage above and skip only this probe. +root_runner=() +root_runtime_available=1 +if (( EUID != 0 )); then + if command -v unshare >/dev/null && unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + else + root_runtime_available=0 + fi +fi + +if (( ! root_runtime_available )); then + pass "no unprivileged user namespace; skipping the root lock-helper lookup matrix" + exit 0 +fi + +# Retarget the two PAM files, the trusted fprintd-list binary, and the final +# shell status query in copies under this scratch directory. The production +# files and service stay untouched even when this suite itself runs as root. +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +poison_bin="$test_tmp/poison-bin" +trusted_root_bin="$test_tmp/trusted-root-bin" +trusted_fprintd="$test_tmp/trusted-fprintd-list" +password_pam="$test_tmp/omarchy-lock-password" +fingerprint_pam="$test_tmp/omarchy-lock-fingerprint" +attack_marker="$test_tmp/user-fprintd-list-ran" +trusted_uid="$test_tmp/trusted-fprintd-list.uid" +trusted_args="$test_tmp/trusted-fprintd-list.args" +attack_args="$test_tmp/user-fprintd-list.args" +patched_helper="$test_tmp/omarchy-apply-lock-patched" +absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" +root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" +unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +target_user=omarchy-regression-user +mkdir -p "$poison_bin" "$trusted_root_bin" + +# The runtime copy pins to this isolated root path. It contains every bare +# command the exercised helper needs, but deliberately no fprintd-list. +for helper in grep rm tee; do + ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" +done + +export TEST_ATTACK_ARGS="$attack_args" +export TEST_ATTACK_MARKER="$attack_marker" +export TEST_TRUSTED_ARGS="$trusted_args" +export TEST_TRUSTED_UID="$trusted_uid" + +cat >"$trusted_fprintd" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" +printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" +echo "Fingerprints are enrolled" +EOF + +cat >"$poison_bin/fprintd-list" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" +printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" +echo "Fingerprints are enrolled" +EOF + +chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" + +prepare_helper() { + local destination="$1" keep_root_path="$2" use_absolute_fprintd="$3" + + awk \ + -v password_pam="$password_pam" \ + -v fingerprint_pam="$fingerprint_pam" \ + -v trusted_root_bin="$trusted_root_bin" \ + -v trusted_fprintd="$trusted_fprintd" \ + -v keep_root_path="$keep_root_path" \ + -v use_absolute_fprintd="$use_absolute_fprintd" ' + { + line = $0 + gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) + gsub("/etc/pam\\.d/omarchy-lock-fingerprint", "\"" fingerprint_pam "\"", line) + + if (line == "if (( EUID == 0 )); then" && keep_root_path == 0) { + print "if (( 0 )); then" + next + } + if (line == " export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin") { + print " export PATH=\"" trusted_root_bin "\"" + next + } + if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (use_absolute_fprintd == 1) { + print "if [[ -x \"" trusted_fprintd "\" ]] &&" + } else { + print "if command -v fprintd-list >/dev/null 2>&1 &&" + } + next + } + if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (use_absolute_fprintd == 1) { + print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + } else { + print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + } + next + } + if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { + print "if false; then" + next + } + + print line + } + ' "$apply_lock" >"$destination" + chmod +x "$destination" +} + +prepare_helper "$patched_helper" 1 1 +prepare_helper "$absolute_only_helper" 0 1 +prepare_helper "$root_path_only_helper" 1 0 +prepare_helper "$unprotected_helper" 0 0 + +for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do + if grep -F '/etc/pam.d/' "$helper" >/dev/null || + grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then + fail "the isolated root fixture redirects every live-system lock-helper target" + fi +done + +reset_runtime_files() { + rm -f "$password_pam" "$fingerprint_pam" "$trusted_uid" "$trusted_args" "$attack_marker" "$attack_args" +} + +run_as_root() { + local helper="$1" description="$2" output + + if ! output=$(PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$helper" 2>&1); then + fail "$description" "$output" + fi +} + +reset_runtime_files +run_as_root "$patched_helper" "the fully hardened lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the hardened root lock helper executes the user-planted fprintd-list" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe runs with EUID 0" +grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" +[[ -s $password_pam && -s $fingerprint_pam ]] || + fail "the isolated root lock-helper run writes both scratch PAM fixtures" +pass "the hardened root lock helper uses the trusted fingerprint probe" + +reset_runtime_files +run_as_root "$absolute_only_helper" "the absolute-path-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "an absolute fprintd-list path permits the user-planted command" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the absolute-path defense runs the trusted probe as root" +pass "the absolute fprintd-list path independently blocks the user-planted command" + +reset_runtime_files +run_as_root "$root_path_only_helper" "the root-PATH-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the trusted root path permits the user-planted fprintd-list" +pass "the trusted root path independently blocks the user-planted command" + +# Mutation control: removing both protections must execute the planted command +# as UID 0, proving the matrix detects the original privilege-boundary failure. +reset_runtime_files +run_as_root "$unprotected_helper" "the unprotected mutation runs in an isolated root context" +grep -Fx '0' "$attack_marker" >/dev/null || + fail "the root lock-helper fixture detects a PATH-resolved fprintd-list regression" +grep -Fx "$target_user" "$attack_args" >/dev/null || + fail "the planted fprintd-list receives the target user" +[[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" +pass "the root lock-helper matrix rejects the vulnerable PATH lookup" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index 0648409d..d357f513 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -6,6 +6,10 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" upgrade_to_quattro="$ROOT/bin/omarchy-upgrade-to-quattro" +function_body() { + awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" +} + snapshot_line=$(grep -n '^create_pre_upgrade_snapshot$' "$upgrade_to_quattro" | cut -d: -f1) pacman_line=$(grep -n '^configure_pacman_channel$' "$upgrade_to_quattro" | cut -d: -f1) [[ -n $snapshot_line && -n $pacman_line ]] || fail "upgrade snapshot and first mutation calls exist" @@ -73,6 +77,44 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade configures lock screen authentication for the target user" +root_path_count=$(awk '/^root_path=/{ count++ } END { print count + 0 }' "$upgrade_to_quattro") +(( root_path_count == 1 )) || fail "Omarchy 4 upgrade defines exactly one root command path" +grep -Fx 'root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade limits root command lookup to trusted system directories" +if grep -E '^root_path=.*(target_home|\.local/bin)' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not put the target user's bin directory on the root command path" +fi +grep -Fx 'package_path="$root_path:$target_home/.local/bin"' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade retains the target user's bin directory for user commands" + +lock_authentication_body=$(function_body configure_lock_authentication) +lock_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$lock_authentication_body") +(( lock_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged lock helper exactly one command path" +grep -Fx ' PATH="$root_path" \' <<<"$lock_authentication_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged lock helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$lock_authentication_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged lock helper the target user's path" +fi + +firewall_body=$(function_body apply_firewall_defaults) +firewall_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$firewall_body") +(( firewall_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged firewall helper exactly one command path" +grep -Fx ' as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \' <<<"$firewall_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged firewall helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$firewall_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged firewall helper the target user's path" +fi + +user_omarchy_body=$(function_body run_as_user_omarchy) +grep -F 'PATH="$package_path"' <<<"$user_omarchy_body" >/dev/null || + fail "Omarchy 4 upgrade retains the package and user path for target-user commands" +if grep -F 'PATH="$root_path"' <<<"$user_omarchy_body" >/dev/null; then + fail "Omarchy 4 upgrade does not narrow target-user commands to the root-only path" +fi +pass "Omarchy 4 upgrade separates privileged and target-user command paths" + grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade uses the shared browser-policy helper" grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || @@ -100,10 +142,6 @@ pass "Omarchy 4 upgrade retires systemd-networkd for NetworkManager" # Booting with both managers enabled leaves them fighting over the Wi-Fi # adapter, so enabling NetworkManager and disabling iwd cannot be separated by # any step that might abort in between. -function_body() { - awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" -} - migrations_body=$(function_body run_post_upgrade_migrations) grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null || fail "Omarchy 4 upgrade fails when a migration cannot complete"