1041 lines
42 KiB
Bash
1041 lines
42 KiB
Bash
#!/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 <errno.h>
|
|
#include <fcntl.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
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"
|