diff --git a/bin/omarchy-dns b/bin/omarchy-dns index a22e028c..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -6,6 +6,18 @@ set -euo pipefail +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system +# tool, never an omarchy-* command, so pin PATH to trusted system directories +# and keep root from resolving one out of that checkout. The unprivileged +# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf provider_from_arg() { diff --git a/test/shell.d/dns-sudoers-test.sh b/test/shell.d/dns-sudoers-test.sh index e8424e4a..e5390f46 100755 --- a/test/shell.d/dns-sudoers-test.sh +++ b/test/shell.d/dns-sudoers-test.sh @@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null || pass "dns sudoers rule is scoped to the stock providers" +# The privileged half runs as root under sudo's secure_path, and a dev link +# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it. +# Every helper the script calls by bare name -- dirname, install, tee, nmcli, +# systemctl, awk -- is a system tool, so once it holds root the script pins PATH +# to trusted system directories and never resolves one of them out of the +# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why +# the pin is gated on EUID rather than set unconditionally. +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" || + fail "omarchy-dns pins PATH to trusted system directories when it holds root" +# require_root carries its own `(( EUID == 0 ))`, so matching that text alone +# would pass with the pin deleted. Anchor on the unindented guard and require the +# pin to be the line it opens. +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-dns gates the trusted-PATH pin on holding root" + +# The no-argument path only reads DNS config, so exercise the privileged phase +# directly when the suite is root and as namespaced root otherwise. This reaches +# tr while EUID is 0 without giving an ordinary test run any host privileges. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi + +# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and +# the non-graphical suites have to stay green on any machine -- a skip is a +# passing test. Only the runtime probe needs the namespace; the static checks +# above and the elevation checks below run either way. +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + poison_dir=$(mktemp -d) + poison_ran="$poison_dir/ran" + for helper in tr awk dirname install tee; do + cat >"$poison_dir/$helper" <"$poison_ran" +exec "/usr/bin/$helper" "\$@" +SH + chmod +x "$poison_dir/$helper" + done + + if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" /dev/null 2>&1; then + rm -rf "$poison_dir" + fail "root omarchy-dns failed its read-only trusted-PATH probe" + fi + if [[ -e $poison_ran ]]; then + rm -rf "$poison_dir" + fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path" + fi + rm -rf "$poison_dir" + pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH" +else + pass "no unprivileged user namespace; skipping the root trusted-PATH probe" +fi + # require_root returns immediately for root, so the stubs below would not stand # between the script and the host's real NetworkManager and resolved config. if (( EUID == 0 )); then