The failure advice printed a ready-to-paste annotation with the scan's verdict already filled in, so the shortest way past the check was to copy back what it had just concluded. That is worst exactly where the scan is weakest: a path it cannot follow through a variable reads as an ordinary value, and the annotation it offers for that case is paths=none. Print the annotation with the path list left blank and say why the author has to fill it in. The scan's own reading stays in the report above it, so nothing diagnostic is lost.
777 lines
28 KiB
Bash
Executable File
777 lines
28 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
# Omarchy does not write a root-owned file through a heredoc whose delimiter is
|
|
# unquoted, and this check enforces that.
|
|
#
|
|
# With an unquoted delimiter (<<EOF rather than <<'EOF') the *installing user's*
|
|
# shell expands the body before root ever sees the text, so any value that user
|
|
# controls is baked into the result as a literal. Send such a body to /etc and
|
|
# the file root later reads or executes carries a value an unprivileged user
|
|
# chose.
|
|
#
|
|
# Worked example -- an installer emitting a udev rule:
|
|
#
|
|
# sudo tee /etc/udev/rules.d/99-power-profile.rules <<EOF
|
|
# ACTION=="change", SUBSYSTEM=="power_supply", RUN+="$HOME/.local/share/omarchy/bin/omarchy-power-profile"
|
|
# EOF
|
|
#
|
|
# The delimiter is unquoted, so the rule that lands names
|
|
# /home/<user>/.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} or $(cmd).
|
|
# Kept in a variable because an unquoted `(` inside a bracket expression is a
|
|
# syntax error in [[ =~ ]].
|
|
EXPANSION_RE='\$[A-Za-z_{(]'
|
|
|
|
# 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_]*(\[[^]]*\])?)(.*)$'
|
|
|
|
# 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 name guard=0
|
|
local -a 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]}
|
|
|
|
if [[ $body == \(* ]]; then
|
|
name=$COMMAND_SUBSTITUTION
|
|
elif [[ $body == \{* ]]; then
|
|
body=${body:1:${#body}-2}
|
|
# ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start
|
|
# with the name once the decorations are stripped.
|
|
body=${body#[\#!]}
|
|
if [[ $body =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then
|
|
name=${BASH_REMATCH[1]}
|
|
else
|
|
name=$COMMAND_SUBSTITUTION
|
|
fi
|
|
else
|
|
name=${body%%\[*}
|
|
fi
|
|
|
|
names+=("$name")
|
|
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_shaped=1 token_has_slash=1
|
|
|
|
head=$(literal_head "$masked")
|
|
head=${head%%$'\001'*}
|
|
literal=$(literal_value "$name")
|
|
|
|
[[ $masked == */* ]] && token_has_slash=0
|
|
|
|
((token_has_slash == 0)) && path_shaped=0
|
|
in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shaped=0
|
|
[[ -v VARS_TAINTED[$name] ]] && path_shaped=0
|
|
[[ -n $literal && $literal == */* ]] && path_shaped=0
|
|
|
|
((path_shaped == 0)) || 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 ((token_has_slash != 0)) && ! in_list "$name" "${USER_WRITABLE_VARS[@]}" &&
|
|
! [[ -v VARS_TAINTED[$name] ]] && [[ -n $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//\'/ }
|
|
# Detach redirects from their targets so "> /etc/x" and ">/etc/x" agree.
|
|
line=${line//>/ > }
|
|
|
|
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 == ">" ]]; 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" || $token == "dd" ]]; 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
|
|
else
|
|
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 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 expression into a root-owned path.
|
|
if [[ $dest == *'$'* ]]; then
|
|
follow=$start_index
|
|
while ((follow < ${#scan_lines[@]})); do
|
|
hop=${scan_lines[follow]}
|
|
follow=$((follow + 1))
|
|
[[ $hop == *"$dest"* ]] || continue
|
|
[[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || 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
|
|
fi
|
|
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
|
|
}
|
|
|
|
# Count the \001 placeholders in a masked token.
|
|
count_placeholders() {
|
|
local text="$1" count=0
|
|
|
|
while [[ $text == *$'\001'* ]]; do
|
|
count=$((count + 1))
|
|
text=${text#*$'\001'}
|
|
done
|
|
|
|
printf '%s' "$count"
|
|
}
|
|
|
|
scan_file() {
|
|
local file="$1" display="${2:-$1}"
|
|
local -a lines=()
|
|
local index lineno line scan rest raw guard slot delim candidate
|
|
local body_text unescaped destination body_line masked_line token name
|
|
local declared_paths annotation look shown_paths shown_plain count next slots
|
|
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
|
|
|
|
# Herestrings are not heredocs. Blanking them keeps <<<"$x" from reading as
|
|
# a heredoc while preserving every other offset on the line.
|
|
scan=${line//<<</ }
|
|
[[ $scan == *"<<"* ]] || continue
|
|
|
|
# Collect this line's heredoc delimiters in order. Quoted ones are safe by
|
|
# construction but still have to be tracked, or their bodies would be
|
|
# parsed as code.
|
|
local -a delims=() quoted=()
|
|
rest=$scan
|
|
guard=0
|
|
while ((guard++ < 8)) && [[ $rest =~ $hd_re ]]; do
|
|
raw=${BASH_REMATCH[1]}
|
|
rest=${rest#*"${BASH_REMATCH[0]}"}
|
|
if [[ $raw == \"*\" || $raw == \'*\' ]]; then
|
|
delims+=("${raw:1:${#raw}-2}")
|
|
quoted+=(0)
|
|
else
|
|
delims+=("$raw")
|
|
quoted+=(1)
|
|
fi
|
|
done
|
|
|
|
((${#delims[@]} > 0)) || continue
|
|
|
|
for slot in "${!delims[@]}"; do
|
|
delim=${delims[slot]}
|
|
local -a body=()
|
|
|
|
while ((index < ${#lines[@]})); do
|
|
candidate=${lines[index]}
|
|
index=$((index + 1))
|
|
[[ ${candidate#"${candidate%%[![:space:]]*}"} == "$delim" ]] && break
|
|
body+=("$candidate")
|
|
done
|
|
|
|
# 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=$(privileged_destination "$line" "$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=<expansions used as paths, or none> -- <why this is safe>
|
|
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 [[ $declared_paths != "$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
|
|
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"
|
|
|
|
# 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-dash-delimiter.sh "flags an indented <<- heredoc"
|
|
|
|
# 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-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"
|