diff --git a/bin/omarchy-dns b/bin/omarchy-dns index 19c6549e..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -149,9 +149,6 @@ write_networkmanager_dns() { local servers="$1" install -d -m 0755 "$(dirname "$NM_DNS_CONF")" - # omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line - # DNS server list written as data, not a path or command; nothing user-writable - # is resolved or executed from the root-owned drop-in. cat >"$NM_DNS_CONF" </dev/null <"/etc/systemd/system/$unit" <<'UNIT' + cat >"/etc/systemd/system/$unit" </dev/null </dev/null </dev/null || true) fi [[ -n ${autologin_user:-} ]] || autologin_user="$target_user" - # omarchy:heredoc-expands paths=none -- $autologin_user is a username, read - # back from the root-owned drop-in or falling back to $target_user. Same - # mechanism as the old getty override: a name expands, no path does. cat </dev/null [Autologin] User=$autologin_user @@ -1423,8 +1417,6 @@ EOF fi as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm - # omarchy:heredoc-expands paths=none -- $target_user is a username, not a - # path; SDDM's state file records who logged in last. cat </dev/null [Last] Session=omarchy.desktop diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f672ed7e..f1582e2d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -691,10 +691,6 @@ write_compose_atomically() ( esc_password=${esc_password//\$/\$\$} tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 - # omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are - # root-protected anchors derived from the authenticated caller uid and bound - # to source inodes that were opened and validated before this compose is - # written. The remaining expansions are revalidated scalar settings. cat >"$tmp" </dev/null - sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh deleted file mode 100644 index 5f4d50a2..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh +++ /dev/null @@ -1,5 +0,0 @@ -# omarchy:heredoc-expands paths=none -- the positional argument is a scalar -sudo tee /etc/omarchy/example.conf </etc/omarchy/agent.conf </dev/null -SUBSYSTEM=="power_supply", RUN+="$helper" -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh deleted file mode 100644 index 916887c8..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# One hop between the expansion and the home path it carries. The token in the -# heredoc has no slash and the value never resolves to a literal path, so a scan -# that rescues unresolved values would exempt a unit baking the user's home into -# /etc/systemd/system. -helper="$HOME/.local/share/omarchy/bin/omarchy-agent" - -# omarchy:heredoc-expands paths=none -- helper is just the agent command name -cat </dev/null -[Service] -ExecStart=$helper -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh deleted file mode 100644 index 7cb74bc0..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# omarchy:heredoc-expands paths=none -- review regression fixture -sudo tee /etc/omarchy/review.conf >/dev/null </etc/omarchy/agent.conf <>/etc/omarchy/agent.conf </dev/null - helper=$HOME/.local/share/omarchy/bin/omarchy-agent - EOF -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh deleted file mode 100644 index fbaab243..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh +++ /dev/null @@ -1,6 +0,0 @@ -tmp=/tmp/omarchy-generated -copy=$tmp -cat >"$tmp" <"$tmp" </tmp/omarchy-review-unit -[Service] -ExecStart=$HOME/.local/bin/payload -EOF -sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh deleted file mode 100644 index f3894ff4..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh +++ /dev/null @@ -1,8 +0,0 @@ -tmp=$(mktemp) - -cat >"$tmp" <|` is a plain redirect with noclobber overridden, not a redirect into a pipe. -cat >|/etc/omarchy/agent.conf </etc/omarchy/agent.conf </dev/null -[Service] -ExecStart=$OMARCHY_PATH/bin/omarchy-agent -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh b/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh deleted file mode 100644 index b003ca58..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh +++ /dev/null @@ -1,8 +0,0 @@ -storage="$HOME/storage" -shared="$HOME/shared" - -# omarchy:heredoc-expands paths=shared,storage -- both sources are validated before use -cat >/etc/omarchy/mounts.conf </dev/null -servers=$servers -EOF - -# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use -cat </dev/null -source=$storage:/storage -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh deleted file mode 100644 index 41e6355e..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh +++ /dev/null @@ -1,7 +0,0 @@ -resolved="RemoteCommand none" - -grep -qvi '^remotecommand none$' <<<"$resolved" || true - -sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF' -ok=1 -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh deleted file mode 100644 index d79fa9e4..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh +++ /dev/null @@ -1,3 +0,0 @@ -cat </dev/null -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set" -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh deleted file mode 100644 index a62c627a..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh +++ /dev/null @@ -1,7 +0,0 @@ -cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null -SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME" -EOF - -cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null - -XML diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh deleted file mode 100644 index 92c6c076..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh +++ /dev/null @@ -1,11 +0,0 @@ -unit=omarchy-agent.service - -# "/etc/systemd/system/$unit" is a path, but one anchored where root already -# owns everything, so paths=none is the truthful declaration -- the check must -# not demand paths=unit here. -# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths -cat >"/etc/systemd/system/$unit" </dev/null -[Service] -Environment=TERM=\$TERM -ExecStart=-/usr/bin/agetty --noclear %I \$TERM -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh deleted file mode 100644 index 78a47bf7..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh +++ /dev/null @@ -1,9 +0,0 @@ -mkdir -p ~/.config/omarchy - -cat >~/.config/omarchy/agent.conf <"$HOME/.local/bin/omarchy-shim" </dev/null -[Service] -ExecStart=$target -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh deleted file mode 100644 index 54b21414..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# Install Plymouth package -echo "Installing Plymouth..." -yay -S --noconfirm --needed plymouth - -# Skip if plymouth already exists for some reason -if ! grep -q "plymouth" /etc/mkinitcpio.conf; then - # Backup original mkinitcpio.conf just in case - backup_timestamp=$(date +"%Y%m%d%H%M%S") - sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}" - - # Add plymouth to HOOKS array. Should be added: - # - After 'base' and 'udev' (or 'systemd' if using systemd hook) - # - Before 'encrypt' or 'sd-encrypt' if present - - # Use sed to add plymouth in-place - if grep -q "systemd" /etc/mkinitcpio.conf; then - # Add after systemd - sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf - elif grep -q "udev" /etc/mkinitcpio.conf; then - # Add after udev - sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf - else - # Fallback: add after base - sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf - fi -fi - -# Regenerate initramfs -sudo mkinitcpio -P - -# Add kernel parameters for Plymouth (systemd-boot only) -if [ -d "/boot/loader/entries" ]; then - echo "Detected systemd-boot" - - for entry in /boot/loader/entries/*.conf; do - if [ -f "$entry" ]; then - # Skip fallback entries - if [[ "$(basename "$entry")" == *"fallback"* ]]; then - echo "Skipped: $(basename "$entry") (fallback entry)" - continue - fi - - # Skip if splash it already present for some reason - if ! grep -q "splash" "$entry"; then - sudo sed -i '/^options/ s/$/ splash quiet/' "$entry" - else - echo "Skipped: $(basename "$entry") (splash already present)" - fi - fi - done -else - echo "" - echo "systemd-boot not detected. Please manually add these kernel parameters:" - echo " - splash (to see the graphical splash screen)" - echo " - quiet (for silent boot)" - echo "" -fi - -# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot -touch "$HOME/.config/omarchy/.plymouth-sync-needed" - -# Create the systemd service -sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null </dev/null - sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh deleted file mode 100644 index 08b8d49b..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh +++ /dev/null @@ -1,9 +0,0 @@ -if omarchy-battery-present; then - cat </.local/share/omarchy/bin/..., and ~/.local/share/omarchy is a -# symlink that same user owns. Replacing the symlink and provoking a -# power_supply event gets their code run by udev as root. Quote the delimiter -# and the rule names a literal $HOME instead, which udev never expands, so -# there is nothing to aim at. -# -# The distinction that matters throughout: install-time expansion bakes a -# literal, user-controlled value into a file root later reads or executes -- -# that is the bug. Runtime expansion (an escaped \$VAR left literal in the file -# for a root daemon that does not have the variable set) is a different -# mechanism and is not flagged. install/3-config.sh once used both in one -# heredoc on purpose: $USER expanded at install time because it is a username, -# while \$TERM stayed escaped for systemd to expand later. - -# A file under one of these is owned by root, so its content is a root-level -# input no unprivileged user should be able to influence. -PRIVILEGED_PREFIXES=(/etc /usr /opt /srv /boot /var/lib) - -# Path roots the installing user can replace outright -- by editing the -# directory, or by swapping a symlink like ~/.local/share/omarchy. An expansion -# anchored in one of these is the shape this check exists to catch. -USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL - XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME XDG_RUNTIME_DIR) - -# Commands that carry a heredoc's output to its destination, and the ones that -# do it as root. `tee` counts unelevated too: several bin/ commands re-exec -# themselves as root and then tee straight into /etc. -WRITE_COMMANDS=(tee dd install cp mv) -ELEVATORS=(sudo as_root pkexec doas run0) - -# A dollar the installing user's shell would act on: $name, ${name}, $1, or $(cmd). -# Kept in a variable because an unquoted `(` inside a bracket expression is a -# syntax error in [[ =~ ]]. -EXPANSION_RE='\$[A-Za-z_{(0-9@*#?$!-]' - -# One pattern for every expansion form, shared by masking and name extraction -# so the two stay in lockstep. -EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?|[0-9@*#?$!-])(.*)$' - -# Stand-in name for a command substitution, which has no variable to report. -COMMAND_SUBSTITUTION="command-substitution" - -# Sites that legitimately need install-time expansion declare it in a comment -# immediately above the heredoc: -# -# # omarchy:heredoc-expands paths=none -- $servers is a validated IP list -# # omarchy:heredoc-expands paths=storage,shared -- checked by valid_path -# -# `paths=` is the machine-checked half, and is what keeps this from being a -# rubber stamp: it must name exactly the expansions that are path-shaped, so -# adding a "$HOME/..." to an already-annotated heredoc makes the declaration -# false and trips the check again instead of inheriting the old exemption. The -# reason after `--` is for the reviewer. -ANNOTATION_RE='^[[:space:]]*#[[:space:]]*omarchy:heredoc-expands[[:space:]]+paths=([A-Za-z_][A-Za-z0-9_-]*(,[A-Za-z_][A-Za-z0-9_-]*)*|none)[[:space:]]+--[[:space:]]+([^[:space:]].*)$' - -FINDINGS=() - -starts_with_privileged_prefix() { - local candidate="$1" prefix - - for prefix in "${PRIVILEGED_PREFIXES[@]}"; do - [[ $candidate == "$prefix"/* ]] && return 0 - done - - return 1 -} - -in_list() { - local needle="$1" item - shift - - for item in "$@"; do - [[ $needle == "$item" ]] && return 0 - done - - return 1 -} - -# Drop escaped dollars, backticks and backslashes so what is left is only what -# the installing user's shell would actually expand. Escaped backslashes go -# first, otherwise "\\$TERM" would read as an escaped dollar. -strip_escapes() { - local text="$1" - - text=${text//\\\\/} - text=${text//\\$/} - text=${text//\\\`/} - - printf '%s' "$text" -} - -# Replace every expansion in TEXT with \001 and list the variable names in -# order: the masked text first, then one name per line. -# -# Masking whole lines rather than whitespace-split words is what makes -# "DNS=${dns_servers//,/ }" readable. Those slashes belong to the substitution -# operator, not to a path, and the space inside the braces would otherwise -# split the expansion across two words and leave a bare "//,/" looking like a -# path. Because both halves come from one pass over one pattern, the Nth \001 -# is the Nth name, so a token can be judged against the right variable. -mask_and_names() { - local text="$1" masked="" body inner tail name guard=0 nested_masked - local -a names=() nested_scan=() nested_names=() - - # Normalize backtick substitution into $( ) so one pattern covers both. - while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do - body=${BASH_REMATCH[2]//[()]/} - text="${BASH_REMATCH[1]}\$($body)${BASH_REMATCH[3]}" - done - - guard=0 - while ((guard++ < 128)) && [[ $text =~ $EXPANSION_SCAN_RE ]]; do - masked+="${BASH_REMATCH[1]}"$'\001' - body=${BASH_REMATCH[2]} - text=${BASH_REMATCH[4]} - nested_masked="" - nested_names=() - - if [[ $body == \(* ]]; then - name=$COMMAND_SUBSTITUTION - elif [[ $body == \{* ]]; then - inner=${body:1:${#body}-2} - # ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start - # with the name once the decorations are stripped. - inner=${inner#[\#!]} - if [[ $inner =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then - name=${BASH_REMATCH[1]} - tail=${inner#"$name"} - elif [[ $inner =~ ^[0-9@*#?$!-] ]]; then - name="shell-parameter" - tail=${inner:1} - else - name=$COMMAND_SUBSTITUTION - tail=$inner - fi - - # The shell expands the operator payload too. Keep it as a synthetic - # adjacent token so its placeholders stay aligned with their names while - # the outer expansion remains independently classifiable. Without this, - # ${target:-$HOME/path} is consumed as only `target` and hides HOME. - if [[ $tail =~ $EXPANSION_RE || $tail == *'`'* ]]; then - mapfile -t nested_scan < <(mask_and_names "$tail") - nested_masked=${nested_scan[0]} - nested_names=("${nested_scan[@]:1}") - fi - else - name=${body%%\[*} - [[ $name =~ ^[A-Za-z_] ]] || name="shell-parameter" - fi - - names+=("$name") - if ((${#nested_names[@]} > 0)); then - masked+=" $nested_masked" - names+=("${nested_names[@]}") - fi - done - - printf '%s\n' "$masked$text" - if ((${#names[@]} > 0)); then - printf '%s\n' "${names[@]}" - fi -} - -declare -A VARS=() -declare -A VARS_TAINTED=() - -# Literal assignments in the file under scan, so a destination written as -# "$DROP_IN" or "$COMPOSE_FILE" can be judged as the path it actually is. -# First assignment wins: these scripts set a constant once, and an append like -# boot_params+=(...) is not an assignment this reads at all. -# -# VARS_TAINTED records, separately, any name that is assigned a value naming a -# root the user can replace -- at any point in the file, not just the assignment -# that won. That is what stops a name being introduced with a harmless packaged -# value and then reassigned under $HOME, which judging one assignment in -# isolation would miss in whichever direction it picked. -collect_vars() { - local -n source_lines="$1" - local line name value append - - VARS=() - VARS_TAINTED=() - for line in "${source_lines[@]}"; do - [[ $line =~ ^[[:space:]]*# ]] && continue - [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$ ]] || continue - - name=${BASH_REMATCH[2]} - append=${BASH_REMATCH[3]} - value=${BASH_REMATCH[4]} - value=${value%%[[:space:]]#*} - value=${value%[[:space:]]} - if [[ $value == \"*\" || $value == \'*\' ]]; then - value=${value:1:${#value}-2} - fi - - mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1 - - # An append never wins the value -- an array grown across a file resolves to - # nothing useful -- but it does carry the taint, or a name could reach a user - # root through += and never be judged on it. - [[ -n $append ]] && continue - [[ -v VARS[$name] ]] || VARS["$name"]=$value - done -} - -# Expand what can be expanded from the file's own assignments. ${NAME:-default} -# falls back to the default, which is how RUNTIME_DIR reaches -# /var/lib/omarchy/windows; mktemp is unwrapped to the template it is handed, so -# a scratch file inside a privileged directory still reads as privileged. -resolve_value() { - local value="$1" outer=0 inner before name default replacement - - while ((outer++ < 8)); do - before=$value - - inner=0 - while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}]*)\} ]]; do - name=${BASH_REMATCH[1]} - default=${BASH_REMATCH[2]} - if [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]]; then - replacement=${VARS[$name]} - else - replacement=$default - fi - value=${value/"${BASH_REMATCH[0]}"/$replacement} - done - - inner=0 - while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*) ]]; do - name=${BASH_REMATCH[1]} - [[ -n $name ]] || name=${BASH_REMATCH[2]} - [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]] || break - value=${value/"${BASH_REMATCH[0]}"/${VARS[$name]}} - done - - if [[ $value =~ \$\(mktemp[^\)]*[[:space:]]\"?([^\"\)]+)\"?\) ]]; then - value=${BASH_REMATCH[1]} - fi - - [[ $value == "$before" ]] && break - done - - printf '%s' "$value" -} - -# Strip a leading KEY= and any opening quote so a token's literal head can be -# compared against the privileged prefixes. -literal_head() { - local text="$1" - - [[ $text =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && text=${text#*=} - text=${text#[\"\']} - - printf '%s' "$text" -} - -# The literal value a name is assigned, when that value is knowable. A value -# read out of a command substitution is not: resolving it would treat the -# slashes in the command itself as a path, which made $autologin_user (an awk -# over /etc/sddm.conf.d) look like a path when it holds a username. -literal_value() { - local name="$1" - - [[ -v VARS[$name] ]] || return 0 - [[ ${VARS[$name]} != *'$('* && ${VARS[$name]} != *'`'* ]] || return 0 - - resolve_value "${VARS[$name]}" -} - -# Does TEXT still reference a root the installing user can replace? Used on a -# value the scan could not fully resolve, where the remaining "$HOME" or -# "${XDG_DATA_HOME}" is the whole reason the value cannot be trusted as a -# root-owned path. A chain through a name this scan never saw assigned resolves -# to neither, and is judged on the rest of the evidence. -mentions_user_writable_root() { - local text="$1" name pattern - - for name in "${USER_WRITABLE_VARS[@]}"; do - # Whole name only. A prefix match would read ${OMARCHY_INSTALL_USER:-}, which - # holds a username, as OMARCHY_INSTALL, which holds a path. - pattern='\$\{?'"$name"'([^A-Za-z0-9_]|$)' - [[ $text =~ $pattern ]] && return 0 - done - - return 1 -} - -# Is this expansion used as a path, and if so, is that path anchored somewhere -# root already owns? MASKED is the containing whitespace token with expansions -# replaced by \001. -# -# Path-shaped means the literal text left around the expansion contains a slash -# ("$HOME/.local/...", "$storage:/storage"), or the variable names a -# user-writable root, or it is assigned a literal value containing a slash. -# Anchored means the literal text *before* the expansion is itself a privileged -# path, as in "/etc/systemd/system/$unit" -- still a path, but one root owns end -# to end. Scoping the anchor test to the containing token is what keeps a udev -# RUN+= line honest: it can name /usr/bin/systemd-run earlier on the same line -# while the $HOME token stands alone. -classify_expansion() { - local masked="$1" name="$2" head literal piece - local path_shape="" - - head=$(literal_head "$masked") - head=${head%%$'\001'*} - literal=$(literal_value "$name") - - [[ $masked == */* ]] && path_shape+="token " - in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shape+="user-root " - [[ -v VARS_TAINTED[$name] ]] && path_shape+="tainted " - [[ -n $literal && $literal == */* ]] && path_shape+="literal " - - [[ -n $path_shape ]] || return 1 - - # A path expansion anchored under a root-owned prefix cannot introduce a - # user-writable location, so it does not need declaring. - starts_with_privileged_prefix "$head" && return 1 - - # When the token itself is not a path and the shape came only from the - # assigned value, a variable holding root-owned absolute paths is not baking - # anything user-writable in: that is how $fprintd_gate carries - # /usr/bin/omarchy-hw-laptop-closed. This rescue deliberately does not apply - # when the token is a path, so "$storage:/storage" stays flagged. - # - # It also does not apply to a value this scan could not finish resolving whose - # unresolved part reaches a root the user can replace. One hop is all it takes - # to hide the shape: helper="$HOME/.local/share/omarchy/bin/agent" followed by - # ExecStart=$helper puts no slash in the token and no literal path in the value, - # so rescuing it would exempt exactly the write this check exists to catch. A - # value that merely fails to resolve -- a kernel parameter list, an escaped - # password -- is left to the rescue, since nothing in it names a user root. - # A name assigned a user root anywhere in the file is never rescued: the - # assignment that won may be the packaged path it was later reassigned away - # from, and the rescue would then clear it on evidence it no longer holds. - if [[ $path_shape == "literal " ]]; then - for piece in $literal; do - piece=$(literal_head "$piece") - if mentions_user_writable_root "$piece"; then - return 0 - fi - if [[ $piece == /* ]] && ! starts_with_privileged_prefix "$piece"; then - return 0 - fi - done - return 1 - fi - - return 0 -} - -# Destination paths a command line hands a heredoc's output, as written. An -# "\002elevated" marker is emitted when the line runs through sudo and friends. -command_destinations() { - local line="$1" token target elevated=1 copy_like=1 last="" index scan - local -a tokens=() - - # Quotes only get in the way of splitting; the paths inside them do not - # contain spaces anywhere this check runs. - line=${line//\"/ } - line=${line//\'/ } - # `>|` overrides noclobber; the bar belongs to the operator, not to a pipe. - # Left alone it becomes the redirect's target and hides the privileged path - # behind it, so a `cat <| /etc/...` heredoc reports no destination. - line=${line//">|"/">"} - # Preserve append redirects before detaching redirect operators from their - # targets, so ">> /etc/x" does not become two ">" tokens whose first target - # is the second operator. - line=${line//>>/$'\003'} - line=${line//>/ > } - line=${line//$'\003'/" >> "} - - read -r -a tokens <<<"$line" - - index=0 - while ((index < ${#tokens[@]})); do - token=${tokens[index]} - index=$((index + 1)) - - in_list "$token" "${ELEVATORS[@]}" && elevated=0 - - if [[ $token == ">" || $token == ">>" ]]; then - target=${tokens[index]:-} - index=$((index + 1)) - [[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target" - continue - fi - - if [[ $token == of=* ]]; then - printf '%s\n' "${token#of=}" - continue - fi - - if in_list "$token" "${WRITE_COMMANDS[@]}"; then - if [[ $token == "tee" ]]; then - # Every non-flag argument to tee is a destination. - scan=$index - while ((scan < ${#tokens[@]})); do - target=${tokens[scan]} - scan=$((scan + 1)) - [[ $target == "|" || $target == "&&" || $target == ";" ]] && break - [[ $target == -* || $target == "<"* || $target == ">" || $target == of=* ]] && continue - [[ $target == /dev/* ]] && continue - printf '%s\n' "$target" - done - elif [[ $token != "dd" ]]; then - # dd destinations are expressed only by of= operands, handled above. - copy_like=0 - fi - continue - fi - - [[ $token != -* && $token != "|" && $token != "<"* && $token != ">" ]] && last=$token - done - - # install/cp/mv put the destination last. - if ((copy_like == 0)) && [[ -n $last ]]; then - printf '%s\n' "$last" - fi - - if ((elevated == 0)); then - printf '%s\n' $'\002elevated' - fi -} - -# Does LINE carry the same resolved value as DEST? Compare resolved tokens rather -# than source spelling so $tmp, ${tmp}, and an alias assigned from either form -# all identify the same scratch file. -line_carries_destination() { - local line="$1" dest="$2" resolved token candidate - local -a tokens=() - - resolved=$(resolve_value "$dest") - line=${line//\"/ } - line=${line//\'/ } - read -r -a tokens <<<"$line" - - for token in "${tokens[@]}"; do - token=${token#[<>]} - token=${token%;} - candidate=$(resolve_value "$token") - [[ $candidate == "$resolved" ]] && return 0 - done - - return 1 -} - -# Does the heredoc on this line reach a root-owned file? Either directly, or in -# one hop: written to a scratch file that a later install/cp/mv carries into a -# privileged directory. -privileged_destination() { - local line="$1" start_index="$2" - local -n scan_lines="$3" - local dest resolved elevated=1 follow hop hop_dest - local -a unresolved=() - - while IFS= read -r dest; do - if [[ $dest == $'\002elevated' ]]; then - elevated=0 - continue - fi - - resolved=$(resolve_value "$dest") - resolved=${resolved#\~} - if starts_with_privileged_prefix "$resolved"; then - printf '%s' "$resolved" - return 0 - fi - - if [[ $resolved == *'$'* ]]; then - unresolved+=("$dest") - fi - - # One hop: a later copy of this same destination into a root-owned path. - # Literal scratch files need tracing just as much as variable destinations. - follow=$start_index - while ((follow < ${#scan_lines[@]})); do - hop=${scan_lines[follow]} - follow=$((follow + 1)) - [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue - line_carries_destination "$hop" "$dest" || continue - while IFS= read -r hop_dest; do - [[ $hop_dest == $'\002elevated' ]] && continue - [[ $hop_dest == "$dest" ]] && continue - hop_dest=$(resolve_value "$hop_dest") - if starts_with_privileged_prefix "$hop_dest"; then - printf '%s' "$hop_dest" - return 0 - fi - done < <(command_destinations "$hop") - done - done < <(command_destinations "$line") - - # An elevated write whose destination cannot be resolved counts as privileged: - # sudo tee is not aimed at a user's own dotfile. - if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then - printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)" - return 0 - fi - - return 1 -} - -# A pipeline may put the command consuming a heredoc after its terminator: -# -# cat < closes)) -} - -scan_file() { - local file="$1" display="${2:-$1}" - local -a lines=() - local index lineno line command scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start - local body_text unescaped destination destination_command body_line masked_line token name - local declared_paths annotation look shown_paths shown_plain count next slots terminated - local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' - - mapfile -t lines <"$file" - collect_vars lines - - index=0 - while ((index < ${#lines[@]})); do - line=${lines[index]} - lineno=$((index + 1)) - index=$((index + 1)) - - [[ $line =~ ^[[:space:]]*# ]] && continue - - # A backslash-escaped newline is removed before Bash parses the command, so - # a pipeline consumer can appear on the next physical line before heredoc - # body collection begins: `cat < 0)) || continue - - for slot in "${!delims[@]}"; do - delim=${delims[slot]} - local -a body=() - body_start=$index - terminated=1 - - while ((index < ${#lines[@]})); do - candidate=${lines[index]} - index=$((index + 1)) - candidate_delim=$candidate - if ((strip_tabs[slot] == 1)); then - while [[ $candidate_delim == $'\t'* ]]; do - candidate_delim=${candidate_delim#$'\t'} - done - fi - if [[ $candidate_delim == "$delim" ]]; then - terminated=0 - break - fi - body+=("$candidate") - done - - # A valid shell source cannot contain an unterminated heredoc. If this - # candidate has no terminator it was syntax such as a multi-line arithmetic - # shift that the lightweight matcher could not classify; resume scanning - # below it instead of swallowing the rest of the file. - if ((terminated != 0)); then - index=$body_start - continue - fi - - # A quoted delimiter cannot expand anything. - ((quoted[slot] == 1)) || continue - - printf -v body_text '%s\n' "${body[@]:-}" - unescaped=$(strip_escapes "$body_text") - [[ $unescaped =~ $EXPANSION_RE || $unescaped == *'`'* ]] || continue - - destination_command=$(continued_heredoc_command "$command" "$index" lines) - destination=$(privileged_destination "$destination_command" "$index" lines) || continue - - # Sort the expansions into the ones that bake a path into the file and - # the ones that only interpolate a scalar. - local -a path_expansions=() plain_expansions=() scanned=() names=() - while IFS= read -r body_line; do - mapfile -t scanned < <(mask_and_names "$body_line") - masked_line=${scanned[0]} - names=("${scanned[@]:1}") - next=0 - - for token in $masked_line; do - count=$(count_placeholders "$token") - ((count > 0)) || continue - - for ((slots = 0; slots < count; slots++)); do - name=${names[next]:-} - next=$((next + 1)) - [[ -n $name ]] || continue - - if classify_expansion "$token" "$name"; then - in_list "$name" "${path_expansions[@]:-}" || path_expansions+=("$name") - else - in_list "$name" "${plain_expansions[@]:-}" || plain_expansions+=("$name") - fi - done - done - done <<<"$unescaped" - - declared_paths="" - annotation="" - look=$((lineno - 2)) - while ((look >= 0)) && [[ ${lines[look]} =~ ^[[:space:]]*# ]]; do - if [[ ${lines[look]} =~ $ANNOTATION_RE ]]; then - declared_paths=${BASH_REMATCH[1]} - annotation=${BASH_REMATCH[3]} - fi - look=$((look - 1)) - done - - shown_paths="none" - if ((${#path_expansions[@]} > 0)); then - shown_paths=$( - IFS=, - printf '%s' "${path_expansions[*]}" - ) - fi - shown_plain="none" - if ((${#plain_expansions[@]} > 0)); then - shown_plain=$( - IFS=, - printf '%s' "${plain_expansions[*]}" - ) - fi - - if [[ -z $annotation ]]; then - FINDINGS+=("$display:$lineno: unquoted heredoc <<$delim expands values at install time and its output reaches $destination - path-shaped expansions: $shown_paths - other expansions: $shown_plain - Whatever expands here is baked into a file root owns. If it is a path the - installing user can replace, root later reads or executes attacker-controlled - content -- that is a local privilege escalation. - Fix, in order of preference: - 1. quote the delimiter (<<'$delim') so nothing expands at install time; - 2. hardcode an absolute root-owned path instead of expanding one; - 3. if the expansion is genuinely required, declare it above the heredoc: - # omarchy:heredoc-expands paths= -- - Decide that list yourself. The scan's own reading of it is above, and - where the scan is most likely wrong is exactly here -- a path it could - not follow reads as an ordinary value -- so pasting its verdict back - signs off on the case worth checking by hand.") - continue - fi - - if [[ $(normalize_path_set "$declared_paths") != $(normalize_path_set "$shown_paths") ]]; then - FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths - Writing to: $destination - Every expansion used as a path outside a root-owned prefix has to be named, - so adding one to an already-annotated heredoc trips this check again instead - of inheriting the old exemption. - Fix: drop the path expansion (hardcode an absolute root-owned path), or name - every path-shaped expansion in the declaration and say why root using it is - safe.") - fi - done - done -} - -# bin/, install/ and migrations/ are where privileged writes live: bin/ holds -# the setup and upgrade commands, install/ runs during install, migrations/ -# during update. default/ is scanned too even though the pattern is not -# reachable there today -- it ships bash functions and completions whose only -# "<<" uses are herestrings, with no privileged writes at all -- because the -# scan is cheap and default/ is sourced into every login shell, so a privileged -# write arriving there later should not arrive unchecked. -shell_sources() { - local file first - - while IFS= read -r -d '' file; do - # Binary files and sources with no heredoc operator have nothing this check - # can classify. Filter them before collect_vars and the line-by-line scan. - grep -Iq '<<' "$file" 2>/dev/null || continue - - case $file in - *.sh | *.hook) - printf '%s\0' "$file" - continue - ;; - esac - - IFS= read -r first <"$file" || true - if [[ $first =~ ^#!.*[[:space:]/](bash|sh)$ ]]; then - printf '%s\0' "$file" - fi - done < <(find "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" "$ROOT/default" \ - -type f -print0 2>/dev/null | sort -z) -} - -require_command find -require_command grep - -sources=() -while IFS= read -r -d '' file; do - sources+=("$file") -done < <(shell_sources) - -((${#sources[@]} > 50)) || fail "the scan reaches the privileged-write scripts" \ - "only ${#sources[@]} shell sources found under bin/, install/, migrations/ and default/" -pass "the scan reaches the privileged-write scripts (${#sources[@]} files)" - -for file in "${sources[@]}"; do - scan_file "$file" "${file#"$ROOT"/}" -done - -if ((${#FINDINGS[@]} > 0)); then - fail "no privileged write embeds an install-time expansion through an unquoted heredoc" \ - "$(printf '%s\n\n' "${FINDINGS[@]}")" -fi -pass "no privileged write embeds an install-time expansion through an unquoted heredoc" - -# --- Non-vacuity ------------------------------------------------------------ -# -# A check that cannot catch the bug it was written for is worthless, so the same -# scanner runs against fixtures: installer shapes taken verbatim from this -# repository's history, the routes other than a pipe into sudo tee, and the -# shapes that must stay quiet. - -FIXTURES="$SHELL_TEST_DIR/fixtures/privileged-heredoc" - -fixture_flags() { - local fixture="$1" description="$2" expected="${3:-}" - - FINDINGS=() - scan_file "$FIXTURES/$fixture" "$fixture" - - ((${#FINDINGS[@]} > 0)) || fail "$description" "$fixture produced no finding" - if [[ -n $expected ]]; then - printf '%s\n' "${FINDINGS[@]}" | grep -qF -- "$expected" || - fail "$description" "expected \"$expected\" in:$(printf '\n%s' "${FINDINGS[@]}")" - fi - pass "$description" -} - -fixture_passes() { - local fixture="$1" description="$2" - - FINDINGS=() - scan_file "$FIXTURES/$fixture" "$fixture" - - ((${#FINDINGS[@]} == 0)) || fail "$description" "$(printf '%s\n' "${FINDINGS[@]}")" - pass "$description" -} - -# Verbatim installer shapes: two udev rules whose RUN+= resolves through a -# user's home, and a systemd unit whose ExecStop did the same. Kept as written -# rather than tidied, so the fixtures stay faithful to the real shape instead of -# a cleaned-up sketch of it. -fixture_flags udev-rule-home-path.sh \ - "flags a power-profile udev rule whose RUN+= resolves under \$HOME" \ - "path-shaped expansions: HOME" -fixture_flags wifi-rule-home-path.sh \ - "flags a wifi-powersave udev rule whose RUN+= resolves under \$HOME" \ - "path-shaped expansions: HOME" -fixture_flags shutdown-unit-home-execstop.sh \ - "flags a shutdown unit with ExecStop=\$HOME/..." \ - "path-shaped expansions: HOME" - -# The exemption must not be a rubber stamp: the same file carrying a -# plausible-looking annotation still fails, because $HOME is path-shaped and -# the declaration does not say so. -fixture_flags annotated-paths-none-still-fails.sh \ - "an annotation claiming paths=none cannot silence a baked \$HOME path" \ - "declares paths=none but the path-shaped expansions are HOME" -fixture_flags annotated-special-parameter-before-home.sh \ - "a shell special parameter cannot hide a later baked \$HOME path" \ - "declares paths=none but the path-shaped expansions are HOME" - -# A path can hide one or more hops away from the heredoc. In each of these the -# token in the body has no slash and the value never resolves to a literal path, -# so an annotation of paths=none looks plausible while the write still bakes the -# user's home into a root-owned file. The declaration has to name the expansion. -fixture_flags hop-variable-home-path.sh \ - "an annotation cannot exempt a home path carried one variable hop away" \ - "declares paths=none but the path-shaped expansions are helper" -fixture_flags hop-twice-home-path.sh \ - "an annotation cannot exempt a home path carried two variable hops away" \ - "declares paths=none but the path-shaped expansions are helper" -fixture_flags shadowed-assignment-home-path.sh \ - "a later assignment under \$HOME is judged, not the packaged value it shadowed" \ - "declares paths=none but the path-shaped expansions are target" - -# Routes other than a direct pipe into sudo tee. -fixture_flags route-redirect.sh "flags a plain redirect into /etc" -fixture_flags route-sudo-dd.sh "flags sudo dd of= into a privileged path" -fixture_flags route-variable-path.sh \ - "flags an elevated write whose destination is a variable resolving under /etc" -fixture_flags route-install-hop.sh \ - "flags a scratch file that install(1) later copies into /usr" -fixture_flags route-install-hop-literal.sh \ - "flags a literal scratch file that install(1) later copies into /etc" -fixture_flags route-install-hop-braced.sh \ - "flags a scratch-file hop whose variable uses braces at the privileged copy" -fixture_flags route-install-hop-alias.sh \ - "flags a scratch-file hop carried through an alias variable" -fixture_flags route-continued-pipeline.sh \ - "flags a privileged pipeline command continued after the heredoc terminator" -fixture_flags route-prebody-escaped-pipeline.sh \ - "flags an escaped-line pipeline consumer before the heredoc body" -fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" -fixture_flags route-append-redirect.sh "flags an append redirect into /etc" -fixture_flags route-noclobber-redirect.sh \ - "flags a noclobber-override redirect into /etc" -fixture_flags arithmetic-left-shift-before-heredoc.sh \ - "an arithmetic left shift does not swallow a later privileged heredoc" \ - "path-shaped expansions: HOME" -fixture_flags plain-heredoc-indented-pseudo-delimiter.sh \ - "an indented delimiter does not terminate a plain heredoc" \ - "path-shaped expansions: HOME" -fixture_flags nested-parameter-default.sh \ - "a nested parameter default cannot hide a baked home path" \ - "path-shaped expansions are HOME" - -mapfile -t dd_destinations < <(command_destinations \ - 'sudo dd if=/tmp/input bs=4M status=none of=/etc/omarchy/image') -[[ ${dd_destinations[0]:-} == "/etc/omarchy/image" && ${dd_destinations[1]:-} == $'\002elevated' && ${#dd_destinations[@]} == 2 ]] || - fail "dd emits only its of= destination" "$(printf '%q\n' "${dd_destinations[@]:-}")" -pass "dd emits only its of= destination" - -# Negatives. -fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes" -fixture_passes safe-user-destination.sh \ - "an unquoted heredoc expanding into the user's own ~/.config passes" -fixture_passes safe-no-expansion.sh \ - "a privileged write with no expansion in the body passes" -fixture_passes safe-runtime-expansion.sh \ - "an escaped \\\$VAR left for a root daemon to expand passes" -fixture_passes safe-annotated.sh "a declared, reasoned exemption passes" -fixture_passes safe-annotated-reordered-paths.sh \ - "path declarations compare as sets rather than traversal order" -fixture_passes safe-root-anchored.sh \ - "a path expansion anchored under /etc is truthfully declared paths=none" -fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc"