From 1b7301916bc0ce96f134cdbfdaed0715f57256f4 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Fri, 4 Sep 2026 20:18:48 +0200 Subject: [PATCH 1/3] Secure OCaml removal sudo boundary --- bin/omarchy-remove-dev-env | 36 ++- .../remove-dev-env-sudo-security-test.sh | 251 ++++++++++++++++++ 2 files changed, 286 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/remove-dev-env-sudo-security-test.sh diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e..de27eec5 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -9,6 +9,35 @@ if [[ -z $1 ]]; then exit 1 fi +cleanup_ocaml_sudo_credentials() { + local status=$? + trap - EXIT HUP INT TERM + + if ! /usr/bin/sudo -k >/dev/null 2>&1; then + echo "Failed to invalidate sudo credentials after OCaml removal." >&2 + (( status != 0 )) || status=1 + fi + + exit "$status" +} + +begin_ocaml_sudo_boundary() { + trap cleanup_ocaml_sudo_credentials EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + /usr/bin/sudo -k >/dev/null || { + echo "Unable to start OCaml removal with a cold sudo credential state." >&2 + exit 1 + } + + if ! /usr/bin/sudo -N -V >/dev/null 2>&1; then + echo "This sudo does not support --no-update; refusing to remove OCaml." >&2 + exit 1 + fi +} + remove_php() { omarchy-pkg-drop php composer php-sqlite xdebug } @@ -87,10 +116,15 @@ dotnet) mise rm -g dotnet ;; ocaml) + begin_ocaml_sudo_boundary + echo -e "Removing OCaml...\n" opam switch remove default -y 2>/dev/null || true rm -rf ~/.opam 2>/dev/null || true - sudo rm -f /usr/local/bin/opam 2>/dev/null || true + if ! /usr/bin/sudo -N -- /usr/bin/rm -f /usr/local/bin/opam; then + echo "Failed to remove the system opam launcher." >&2 + exit 1 + fi ;; clojure) echo -e "Removing Clojure...\n" diff --git a/test/shell.d/remove-dev-env-sudo-security-test.sh b/test/shell.d/remove-dev-env-sudo-security-test.sh new file mode 100755 index 00000000..b5e7959d --- /dev/null +++ b/test/shell.d/remove-dev-env-sudo-security-test.sh @@ -0,0 +1,251 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command gcc +require_command setpriv +require_command unshare + +if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != 1 ]]; then + outer_uid=$(id -u) + outer_gid=$(id -g) + subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) + subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) + + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping OCaml sudo 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_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" +fi + +[[ $(id -u) == 0 ]] || fail "OCaml sudo proof did not enter its root namespace" + +test_tmp=$(mktemp -d) +test_home=$test_tmp/home +stub_bin=$test_home/bin +event_log=$test_tmp/events +token=$test_tmp/sudo-token +protected_target=$test_tmp/root-reused +active_session="" + +cleanup() { + local status=$? + trap - EXIT + + if [[ $active_session =~ ^[1-9][0-9]*$ ]]; then + kill -TERM -- "-$active_session" 2>/dev/null || kill -TERM "$active_session" 2>/dev/null || true + kill -KILL -- "-$active_session" 2>/dev/null || kill -KILL "$active_session" 2>/dev/null || true + fi + + if [[ -s $test_home/attack.pid ]]; then + attack_pid=$(<"$test_home/attack.pid") + kill "$attack_pid" 2>/dev/null || true + fi + + umount -l /usr/bin/sudo 2>/dev/null || true + umount -l "$test_tmp" 2>/dev/null || true + rm -rf "$test_tmp" 2>/dev/null || true + exit "$status" +} +trap cleanup EXIT + +mount -t tmpfs -o mode=0755,suid tmpfs "$test_tmp" +mkdir -p "$stub_bin" +touch "$event_log" + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include + +static const char *required(const char *name) { + const char *value = getenv(name); + if (!value || !*value) exit(125); + return value; +} + +static void event(const char *message) { + int fd = open(required("TEST_EVENT_LOG"), O_WRONLY | O_APPEND); + if (fd < 0) exit(125); + dprintf(fd, "%s\n", message); + close(fd); +} + +int main(int argc, char **argv) { + const char *token = required("TEST_SUDO_TOKEN"); + + if (geteuid() != 0) return 125; + + if (argc == 2 && strcmp(argv[1], "-k") == 0) { + event("invalidate"); + if (unlink(token) && errno != ENOENT) return 125; + return 0; + } + + if (argc == 3 && strcmp(argv[1], "-N") == 0 && strcmp(argv[2], "-V") == 0) { + event("probe-no-update"); + return getenv("TEST_SUDO_NO_N") ? 2 : 0; + } + + if (argc == 6 && strcmp(argv[1], "-N") == 0 && strcmp(argv[2], "--") == 0 && + strcmp(argv[3], "/usr/bin/rm") == 0 && strcmp(argv[4], "-f") == 0 && + strcmp(argv[5], "/usr/local/bin/opam") == 0) { + event("remove-opam-no-update"); + return getenv("TEST_RM_FAIL") ? 83 : 0; + } + + if (argc >= 3 && strcmp(argv[1], "-n") == 0) { + if (access(token, F_OK) == 0) { + int fd = open(required("TEST_PROTECTED_TARGET"), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + event("attack-reused-root"); + return 0; + } + event("attack-denied"); + return 1; + } + + event("unexpected-sudo"); + return 124; +} +C + +gcc -O2 -Wall -Wextra -o "$test_tmp/sudo" "$test_tmp/sudo.c" +chown 0:0 "$test_tmp/sudo" +chmod 4755 "$test_tmp/sudo" +mount --bind "$test_tmp/sudo" /usr/bin/sudo + +cat >"$stub_bin/attack" <<'STUB' +#!/bin/bash + +printf '%s\n' "$$" >"$HOME/attack.pid" +for _ in {1..200}; do + if /usr/bin/sudo -n -- /usr/bin/true 2>/dev/null; then + exit 0 + fi + /usr/bin/sleep 0.005 +done +STUB + +cat >"$stub_bin/opam" <<'STUB' +#!/bin/bash + +printf 'opam-ran\n' >>"$TEST_EVENT_LOG" +/usr/bin/setsid --fork "$HOME/bin/attack" >/dev/null 2>&1 + +if [[ ${TEST_OPAM_BLOCK:-0} == 1 ]]; then + printf 'ready\n' >"$HOME/ready" + trap 'exit 143' TERM + while :; do /usr/bin/sleep 0.05; done +fi +STUB + +cat >"$stub_bin/mise" <<'STUB' +#!/bin/bash +printf 'mise:%s\n' "$*" >>"$TEST_EVENT_LOG" +STUB + +chmod 0755 "$stub_bin"/* +chown -R 1000:1000 "$test_home" +chown 1000:1000 "$event_log" + +base_env=( + HOME="$test_home" + USER=test + LOGNAME=test + PATH="$stub_bin:/usr/bin:/bin" + TEST_EVENT_LOG="$event_log" + TEST_SUDO_TOKEN="$token" + TEST_PROTECTED_TARGET="$protected_target" +) + +run_user() { + setpriv --reuid=1000 --regid=1000 --clear-groups env -i "${base_env[@]}" "$@" +} + +reset_case() { + if [[ -s $test_home/attack.pid ]]; then + attack_pid=$(<"$test_home/attack.pid") + kill "$attack_pid" 2>/dev/null || true + fi + : >"$event_log" + chown 1000:1000 "$event_log" + rm -f "$token" "$protected_target" "$test_home/attack.pid" "$test_home/ready" +} + +wait_for_attack() { + for _ in {1..240}; do + grep -q '^attack-' "$event_log" 2>/dev/null && return 0 + sleep 0.005 + done + return 1 +} + +reset_case +touch "$token" +chown 0:0 "$token" +run_user bash "$ROOT/bin/omarchy-remove-dev-env" ocaml +wait_for_attack || fail "hostile opam did not exercise the detached sudo poller" +[[ ! -e $protected_target ]] || fail "hostile opam reused root authorization" +[[ ! -e $token ]] || fail "OCaml removal left a reusable sudo credential" +grep -Fxq 'remove-opam-no-update' "$event_log" || fail "OCaml removal did not use sudo --no-update" +! grep -Fxq 'attack-reused-root' "$event_log" || fail "OCaml removal published reusable authorization" +pass "OCaml removal keeps user-controlled opam outside reusable sudo authorization" + +reset_case +if run_user env TEST_SUDO_NO_N=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then + fail "OCaml removal accepted sudo without --no-update support" +fi +! grep -Fxq 'opam-ran' "$event_log" || fail "unsupported sudo reached user-controlled opam" +pass "OCaml removal validates --no-update support before running opam" + +reset_case +if run_user env TEST_RM_FAIL=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then + fail "OCaml removal ignored root cleanup failure" +fi +[[ ! -e $token ]] || fail "failed OCaml cleanup left a reusable sudo credential" +pass "OCaml cleanup failures propagate and invalidate credentials" + +reset_case +touch "$token" +chown 0:0 "$token" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i "${base_env[@]}" TEST_OPAM_BLOCK=1 \ + /usr/bin/setsid bash "$ROOT/bin/omarchy-remove-dev-env" ocaml & +session=$! +active_session=$session + +for _ in {1..200}; do + [[ -e $test_home/ready ]] && break + sleep 0.005 +done +[[ -e $test_home/ready ]] || fail "OCaml signal fixture did not become ready" +kill -TERM -- "-$session" 2>/dev/null || kill -TERM "$session" +set +e +wait "$session" +signal_status=$? +set -e +active_session="" +(( signal_status != 0 )) || fail "terminated OCaml removal unexpectedly succeeded" +[[ ! -e $token ]] || fail "terminated OCaml removal left a reusable sudo credential" +[[ ! -e $protected_target ]] || fail "detached opam child reused root after termination" +pass "OCaml removal invalidates credentials on signals" + +reset_case +run_user bash "$ROOT/bin/omarchy-remove-dev-env" node +! grep -Eq '^(invalidate|probe-no-update|unexpected-sudo)$' "$event_log" || + fail "non-OCaml removal crossed the sudo boundary" +grep -Fxq 'mise:uninstall node --all' "$event_log" || fail "Node removal did not run" +grep -Fxq 'mise:rm -g node' "$event_log" || fail "Node removal did not clear the global version" +pass "non-OCaml removals do not inspect or invalidate sudo credentials" From 73a09695f98c7d4c95d88040b5876c858cc5e02e Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Fri, 4 Sep 2026 20:27:53 +0200 Subject: [PATCH 2/3] Strengthen OCaml sudo security test --- .../remove-dev-env-sudo-security-test.sh | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/test/shell.d/remove-dev-env-sudo-security-test.sh b/test/shell.d/remove-dev-env-sudo-security-test.sh index b5e7959d..27bb3f8b 100755 --- a/test/shell.d/remove-dev-env-sudo-security-test.sh +++ b/test/shell.d/remove-dev-env-sudo-security-test.sh @@ -8,24 +8,27 @@ require_command gcc require_command setpriv require_command unshare -if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != 1 ]]; then +if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != "1" ]]; then outer_uid=$(id -u) outer_gid=$(id -g) - subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) - subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) + outer_user=$(id -un) + subuid_entry=$(awk -F: -v user="$outer_user" -v uid="$outer_uid" '($1 == user || $1 == uid) && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ && $3 >= 1000 { print $2 ":" $3; exit }' /etc/subuid) + subgid_entry=$(awk -F: -v user="$outer_user" -v uid="$outer_uid" '($1 == user || $1 == uid) && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ && $3 >= 1000 { print $2 ":" $3; exit }' /etc/subgid) - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping OCaml sudo namespace proof" + if [[ -z $subuid_entry || -z $subgid_entry ]]; then + pass "no subordinate uid/gid range covering test user 1000; skipping OCaml sudo namespace proof" exit 0 fi + IFS=: read -r subuid subuid_count <<<"$subuid_entry" + IFS=: read -r subgid subgid_count <<<"$subgid_entry" 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" \ + --map-users "0:$outer_uid:1" --map-users "1:$subuid:$subuid_count" \ + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:$subgid_count" \ env OMARCHY_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" fi -[[ $(id -u) == 0 ]] || fail "OCaml sudo proof did not enter its root namespace" +(( EUID == 0 )) || fail "OCaml sudo proof did not enter its root namespace" test_tmp=$(mktemp -d) test_home=$test_tmp/home @@ -144,7 +147,7 @@ cat >"$stub_bin/opam" <<'STUB' printf 'opam-ran\n' >>"$TEST_EVENT_LOG" /usr/bin/setsid --fork "$HOME/bin/attack" >/dev/null 2>&1 -if [[ ${TEST_OPAM_BLOCK:-0} == 1 ]]; then +if [[ ${TEST_OPAM_BLOCK:-0} == "1" ]]; then printf 'ready\n' >"$HOME/ready" trap 'exit 143' TERM while :; do /usr/bin/sleep 0.05; done @@ -192,6 +195,12 @@ wait_for_attack() { return 1 } +assert_ocaml_invalidations() { + local context=$1 invalidations + invalidations=$(grep -Fxc 'invalidate' "$event_log" || true) + (( invalidations == 2 )) || fail "$context did not invalidate sudo credentials before and after OCaml removal (saw $invalidations invalidations)" +} + reset_case touch "$token" chown 0:0 "$token" @@ -199,6 +208,7 @@ run_user bash "$ROOT/bin/omarchy-remove-dev-env" ocaml wait_for_attack || fail "hostile opam did not exercise the detached sudo poller" [[ ! -e $protected_target ]] || fail "hostile opam reused root authorization" [[ ! -e $token ]] || fail "OCaml removal left a reusable sudo credential" +assert_ocaml_invalidations "successful OCaml removal" grep -Fxq 'remove-opam-no-update' "$event_log" || fail "OCaml removal did not use sudo --no-update" ! grep -Fxq 'attack-reused-root' "$event_log" || fail "OCaml removal published reusable authorization" pass "OCaml removal keeps user-controlled opam outside reusable sudo authorization" @@ -208,6 +218,7 @@ if run_user env TEST_SUDO_NO_N=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; fail "OCaml removal accepted sudo without --no-update support" fi ! grep -Fxq 'opam-ran' "$event_log" || fail "unsupported sudo reached user-controlled opam" +assert_ocaml_invalidations "unsupported sudo exit" pass "OCaml removal validates --no-update support before running opam" reset_case @@ -215,6 +226,7 @@ if run_user env TEST_RM_FAIL=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; th fail "OCaml removal ignored root cleanup failure" fi [[ ! -e $token ]] || fail "failed OCaml cleanup left a reusable sudo credential" +assert_ocaml_invalidations "failed OCaml cleanup" pass "OCaml cleanup failures propagate and invalidate credentials" reset_case @@ -240,6 +252,7 @@ active_session="" (( signal_status != 0 )) || fail "terminated OCaml removal unexpectedly succeeded" [[ ! -e $token ]] || fail "terminated OCaml removal left a reusable sudo credential" [[ ! -e $protected_target ]] || fail "detached opam child reused root after termination" +assert_ocaml_invalidations "terminated OCaml removal" pass "OCaml removal invalidates credentials on signals" reset_case From a805ef990e93b8dfa71d1d8c71541fb1c63e956b Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sun, 6 Sep 2026 15:46:59 +0200 Subject: [PATCH 3/3] Fix OCaml sudo test portability --- .../remove-dev-env-sudo-security-test.sh | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/test/shell.d/remove-dev-env-sudo-security-test.sh b/test/shell.d/remove-dev-env-sudo-security-test.sh index 27bb3f8b..0de3976f 100755 --- a/test/shell.d/remove-dev-env-sudo-security-test.sh +++ b/test/shell.d/remove-dev-env-sudo-security-test.sh @@ -22,15 +22,26 @@ if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != "1" ]]; then IFS=: read -r subuid subuid_count <<<"$subuid_entry" IFS=: read -r subgid subgid_count <<<"$subgid_entry" - exec unshare --user --mount \ - --map-users "0:$outer_uid:1" --map-users "1:$subuid:$subuid_count" \ - --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:$subgid_count" \ - env OMARCHY_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" + namespace_args=( + --user --mount + --map-users "0:$outer_uid:1" --map-users "1:$subuid:$subuid_count" + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:$subgid_count" + ) + + # Probe only the prerequisites; failures from the actual test must propagate. + if unshare "${namespace_args[@]}" /usr/bin/true; then + exec unshare "${namespace_args[@]}" env OMARCHY_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" + else + pass "user/mount namespace setup unavailable; skipping OCaml sudo namespace proof" + exit 0 + fi fi (( EUID == 0 )) || fail "OCaml sudo proof did not enter its root namespace" -test_tmp=$(mktemp -d) +# Keep the synthetic user's paths traversable even when TMPDIR is private. +test_tmp=$(mktemp -d /tmp/omarchy-remove-dev-env-security.XXXXXX) +test_helper=$test_tmp/omarchy-remove-dev-env test_home=$test_tmp/home stub_bin=$test_home/bin event_log=$test_tmp/events @@ -60,6 +71,7 @@ cleanup() { trap cleanup EXIT mount -t tmpfs -o mode=0755,suid tmpfs "$test_tmp" +install -m 0755 "$ROOT/bin/omarchy-remove-dev-env" "$test_helper" mkdir -p "$stub_bin" touch "$event_log" @@ -204,7 +216,7 @@ assert_ocaml_invalidations() { reset_case touch "$token" chown 0:0 "$token" -run_user bash "$ROOT/bin/omarchy-remove-dev-env" ocaml +run_user bash "$test_helper" ocaml wait_for_attack || fail "hostile opam did not exercise the detached sudo poller" [[ ! -e $protected_target ]] || fail "hostile opam reused root authorization" [[ ! -e $token ]] || fail "OCaml removal left a reusable sudo credential" @@ -214,7 +226,7 @@ grep -Fxq 'remove-opam-no-update' "$event_log" || fail "OCaml removal did not us pass "OCaml removal keeps user-controlled opam outside reusable sudo authorization" reset_case -if run_user env TEST_SUDO_NO_N=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then +if run_user env TEST_SUDO_NO_N=1 bash "$test_helper" ocaml; then fail "OCaml removal accepted sudo without --no-update support" fi ! grep -Fxq 'opam-ran' "$event_log" || fail "unsupported sudo reached user-controlled opam" @@ -222,7 +234,7 @@ assert_ocaml_invalidations "unsupported sudo exit" pass "OCaml removal validates --no-update support before running opam" reset_case -if run_user env TEST_RM_FAIL=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then +if run_user env TEST_RM_FAIL=1 bash "$test_helper" ocaml; then fail "OCaml removal ignored root cleanup failure" fi [[ ! -e $token ]] || fail "failed OCaml cleanup left a reusable sudo credential" @@ -234,7 +246,7 @@ touch "$token" chown 0:0 "$token" setpriv --reuid=1000 --regid=1000 --clear-groups \ env -i "${base_env[@]}" TEST_OPAM_BLOCK=1 \ - /usr/bin/setsid bash "$ROOT/bin/omarchy-remove-dev-env" ocaml & + /usr/bin/setsid bash "$test_helper" ocaml & session=$! active_session=$session @@ -256,7 +268,7 @@ assert_ocaml_invalidations "terminated OCaml removal" pass "OCaml removal invalidates credentials on signals" reset_case -run_user bash "$ROOT/bin/omarchy-remove-dev-env" node +run_user bash "$test_helper" node ! grep -Eq '^(invalidate|probe-no-update|unexpected-sudo)$' "$event_log" || fail "non-OCaml removal crossed the sudo boundary" grep -Fxq 'mise:uninstall node --all' "$event_log" || fail "Node removal did not run"