Skip to content

Commit ea7e44d

Browse files
fix(security): route shell utilities through trusted-binary resolver
Hardens scripts/lib/security.sh so that the security-critical helpers which fetch and execute remote installers can never be hijacked by a PATH-shadowed shell utility. Several common utilities (mktemp, cat, date, rm, realpath, bash) were previously invoked by bare name from within trusted code paths; if PATH placed a malicious binary first, an attacker could subvert checksum verification or execute arbitrary code. Specific changes: - Introduce acfs_security_required_binary_path(name): wraps acfs_security_system_binary_path and emits a trusted error when the binary cannot be located, returning 127 so callers can fail closed. - Add acfs_security_mktemp / acfs_security_cat_file / acfs_security_date thin wrappers that route through acfs_security_required_binary_path, giving call sites a single seam to enforce trusted resolution. - Replace bare mktemp invocations in fetch_checksum, verify_checksum (initial + refreshed-installer recovery path), and fetch_and_run_with_recovery with acfs_security_mktemp. - Replace the bare cat in verify_checksum's verified-bytes emission with acfs_security_cat_file (also gains a readability check). - Replace bare bash in fetch_and_run and fetch_and_run_with_recovery with a resolved bash_bin from acfs_security_required_binary_path so the verified payload is piped to the trusted bash, not whatever bash happens to come first on PATH. - Replace bare rm in _acfs_remove_temp_files with a resolved rm_bin; bail out silently if no trusted rm is found rather than risk calling a shadowed one. - Replace the realpath probe used to canonicalize DEFAULT_CHECKSUMS_FILE with a trusted-binary lookup, falling back to a pure-bash cd/pwd -P branch using parameter expansion (no basename/dirname dependency) when realpath is unavailable. - Make SECURITY_SCRIPT_DIR resolution self-contained: derive the source directory via parameter expansion on BASH_SOURCE rather than calling dirname, then cd ... && pwd -P, and clean up the temporary _acfs_security_source* variables after use. This removes the last bare external dependency from the file's prelude, which runs before any helpers are defined. No behavior changes for the happy path on systems where the trusted binaries resolve normally; the diff strictly narrows the trust surface. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 651093b commit ea7e44d

1 file changed

Lines changed: 128 additions & 39 deletions

File tree

scripts/lib/security.sh

Lines changed: 128 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
# When executed directly, strict mode is enabled in the entrypoint below.
1010
# ============================================================
1111

12-
SECURITY_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12+
_acfs_security_source="${BASH_SOURCE[0]}"
13+
_acfs_security_source_dir="."
14+
case "$_acfs_security_source" in
15+
*/*) _acfs_security_source_dir="${_acfs_security_source%/*}" ;;
16+
esac
17+
SECURITY_SCRIPT_DIR="$(cd "$_acfs_security_source_dir" && pwd -P)"
18+
unset _acfs_security_source _acfs_security_source_dir
1319

1420
# Ensure we have logging functions available
1521
if [[ -z "${ACFS_BLUE:-}" ]]; then
@@ -82,6 +88,19 @@ acfs_security_curl_binary_path() {
8288
acfs_security_system_binary_path curl
8389
}
8490

91+
acfs_security_required_binary_path() {
92+
local name="${1:-}"
93+
local path=""
94+
95+
path="$(acfs_security_system_binary_path "$name" 2>/dev/null || true)"
96+
if [[ -z "$path" ]]; then
97+
log_error "No trusted $name binary available"
98+
return 127
99+
fi
100+
101+
printf '%s\n' "$path"
102+
}
103+
85104
acfs_security_hash_tool() {
86105
local sha256sum_bin=""
87106
local shasum_bin=""
@@ -101,6 +120,53 @@ acfs_security_hash_tool() {
101120
return 1
102121
}
103122

123+
acfs_security_mktemp() {
124+
local mktemp_bin=""
125+
126+
mktemp_bin="$(acfs_security_required_binary_path mktemp)" || return $?
127+
if [[ "$#" -gt 0 ]]; then
128+
"$mktemp_bin" "$@"
129+
else
130+
"$mktemp_bin"
131+
fi
132+
}
133+
134+
acfs_security_cat_file() {
135+
local cat_bin=""
136+
local file="${1:-}"
137+
138+
[[ -r "$file" ]] || {
139+
log_error "Cannot read file: $file"
140+
return 1
141+
}
142+
143+
cat_bin="$(acfs_security_required_binary_path cat)" || return $?
144+
"$cat_bin" "$file"
145+
}
146+
147+
acfs_security_mkdir_p() {
148+
local mkdir_bin=""
149+
local dir="${1:-}"
150+
151+
[[ -n "$dir" ]] || return 1
152+
mkdir_bin="$(acfs_security_required_binary_path mkdir)" || return $?
153+
"$mkdir_bin" -p "$dir"
154+
}
155+
156+
acfs_security_sort_lines() {
157+
local sort_bin=""
158+
159+
sort_bin="$(acfs_security_required_binary_path sort)" || return $?
160+
"$sort_bin"
161+
}
162+
163+
acfs_security_date() {
164+
local date_bin=""
165+
166+
date_bin="$(acfs_security_required_binary_path date)" || return $?
167+
"$date_bin" "$@"
168+
}
169+
104170
# Check if running in interactive mode
105171
# Returns 0 if interactive, 1 if non-interactive
106172
_acfs_is_interactive() {
@@ -167,9 +233,14 @@ acfs_download_to_file() {
167233
local url="$1"
168234
local output_path="$2"
169235
local name="${3:-$url}"
236+
local output_dir="${output_path%/*}"
237+
238+
if [[ "$output_dir" == "$output_path" ]]; then
239+
output_dir="."
240+
fi
170241

171242
# Ensure parent dir exists
172-
mkdir -p "$(dirname "$output_path")"
243+
acfs_security_mkdir_p "$output_dir" || return $?
173244

174245
# Use GitHub-specific backoff for GitHub URLs (rate limit handling)
175246
if [[ "$url" == *"github.com"* || "$url" == *"githubusercontent.com"* ]]; then
@@ -231,12 +302,16 @@ DEFAULT_CHECKSUMS_FILE="$SECURITY_SCRIPT_DIR/../../checksums.yaml"
231302

232303
# Resolve to absolute path to prevent working directory manipulation
233304
if [[ -r "$DEFAULT_CHECKSUMS_FILE" ]]; then
234-
# Use realpath if available, otherwise use cd/pwd to get absolute path
235-
if command -v realpath &>/dev/null; then
236-
DEFAULT_CHECKSUMS_FILE="$(realpath "$DEFAULT_CHECKSUMS_FILE")"
305+
# Use trusted realpath if available, otherwise use cd/pwd to get absolute path.
306+
_acfs_security_realpath_bin="$(acfs_security_system_binary_path realpath 2>/dev/null || true)"
307+
if [[ -n "$_acfs_security_realpath_bin" ]]; then
308+
DEFAULT_CHECKSUMS_FILE="$("$_acfs_security_realpath_bin" "$DEFAULT_CHECKSUMS_FILE")"
237309
else
238-
DEFAULT_CHECKSUMS_FILE="$(cd "$(dirname "$DEFAULT_CHECKSUMS_FILE")" && pwd)/$(basename "$DEFAULT_CHECKSUMS_FILE")"
310+
_acfs_security_checksums_dir="${DEFAULT_CHECKSUMS_FILE%/*}"
311+
_acfs_security_checksums_base="${DEFAULT_CHECKSUMS_FILE##*/}"
312+
DEFAULT_CHECKSUMS_FILE="$(cd "$_acfs_security_checksums_dir" && pwd -P)/$_acfs_security_checksums_base"
239313
fi
314+
unset _acfs_security_realpath_bin _acfs_security_checksums_dir _acfs_security_checksums_base
240315
CHECKSUMS_FILE="${CHECKSUMS_FILE:-$DEFAULT_CHECKSUMS_FILE}"
241316
else
242317
# If default not found and CHECKSUMS_FILE not set, use absolute path to repo root
@@ -413,8 +488,13 @@ calculate_sha256() {
413488

414489
_acfs_remove_temp_files() {
415490
local path
491+
local rm_bin=""
492+
493+
rm_bin="$(acfs_security_system_binary_path rm 2>/dev/null || true)"
494+
[[ -n "$rm_bin" ]] || return 0
495+
416496
for path in "$@"; do
417-
[[ -n "$path" ]] && rm -f -- "$path" 2>/dev/null || true
497+
[[ -n "$path" ]] && "$rm_bin" -f -- "$path" 2>/dev/null || true
418498
done
419499
}
420500

@@ -428,7 +508,7 @@ fetch_checksum() {
428508

429509
# Create safe temp file
430510
local tmp_file
431-
tmp_file="$(mktemp "${TMPDIR:-/tmp}/acfs-fetch.XXXXXX")" || {
511+
tmp_file="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-fetch.XXXXXX")" || {
432512
log_error "Failed to create temp file"
433513
return 1
434514
}
@@ -472,7 +552,7 @@ verify_checksum() {
472552

473553
# Create safe temp file
474554
local tmp_file
475-
tmp_file="$(mktemp "${TMPDIR:-/tmp}/acfs-verify.XXXXXX")" || {
555+
tmp_file="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-verify.XXXXXX")" || {
476556
log_error "Failed to create temp file for $name"
477557
return 1
478558
}
@@ -507,7 +587,7 @@ verify_checksum() {
507587
fi
508588

509589
if [[ -z "$verified_file" ]]; then
510-
fresh_tmp_file="$(mktemp "${TMPDIR:-/tmp}/acfs-verify.XXXXXX" 2>/dev/null)" || fresh_tmp_file=""
590+
fresh_tmp_file="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-verify.XXXXXX" 2>/dev/null)" || fresh_tmp_file=""
511591
if [[ -n "$fresh_tmp_file" ]] && acfs_download_to_file "$refreshed_url" "$fresh_tmp_file" "$name"; then
512592
refreshed_actual_sha256="$(calculate_file_sha256 "$fresh_tmp_file")" || refreshed_actual_sha256=""
513593
if [[ -n "$refreshed_actual_sha256" && "$refreshed_actual_sha256" == "$refreshed_expected_sha256" ]]; then
@@ -546,7 +626,7 @@ verify_checksum() {
546626

547627
if [[ "$status" -eq 0 ]]; then
548628
# Return the verified content (verbatim bytes) on stdout.
549-
cat "$verified_file"
629+
acfs_security_cat_file "$verified_file"
550630
status=$?
551631
fi
552632

@@ -559,6 +639,7 @@ fetch_and_run() {
559639
local url="$1"
560640
local expected_sha256="${2:-}"
561641
local name="${3:-installer}"
642+
local bash_bin=""
562643
shift 3 || true
563644
local args=("$@")
564645

@@ -577,9 +658,11 @@ fetch_and_run() {
577658
return 1
578659
fi
579660

661+
bash_bin="$(acfs_security_required_binary_path bash)" || return $?
662+
580663
(
581664
set -o pipefail
582-
verify_checksum "$url" "$expected_sha256" "$name" | bash -s -- "${args[@]}"
665+
verify_checksum "$url" "$expected_sha256" "$name" | "$bash_bin" -s -- "${args[@]}"
583666
)
584667
}
585668

@@ -614,6 +697,7 @@ fetch_and_run_with_recovery() {
614697
local url="$1"
615698
local expected_sha256="${2:-}"
616699
local name="${3:-installer}"
700+
local bash_bin=""
617701
shift 3 || true
618702
local args=("$@")
619703

@@ -632,9 +716,11 @@ fetch_and_run_with_recovery() {
632716
return 1
633717
fi
634718

719+
bash_bin="$(acfs_security_required_binary_path bash)" || return $?
720+
635721
# Create safe temp file
636722
local tmp_file
637-
tmp_file="$(mktemp "${TMPDIR:-/tmp}/acfs-recovery.XXXXXX")" || {
723+
tmp_file="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-recovery.XXXXXX")" || {
638724
log_error "Failed to create temp file for $name"
639725
return 1
640726
}
@@ -678,7 +764,7 @@ fetch_and_run_with_recovery() {
678764
elif [[ "$status" -eq 0 ]]; then
679765
log_success "Verified: $name"
680766
# Run the installer
681-
bash "$tmp_file" "${args[@]}"
767+
"$bash_bin" "$tmp_file" "${args[@]}"
682768
status=$?
683769
fi
684770

@@ -702,7 +788,7 @@ print_upstream_urls() {
702788
for name in "${!KNOWN_INSTALLERS[@]}"; do
703789
local url="${KNOWN_INSTALLERS[$name]}"
704790
printf " %-20s %s\n" "$name:" "$url"
705-
done | sort
791+
done | acfs_security_sort_lines
706792

707793
echo ""
708794
printf "${DIM}All URLs use HTTPS for secure transport.${NC}\n"
@@ -714,9 +800,7 @@ print_current_checksums() {
714800
local had_failure=false
715801
local tmp_output=""
716802

717-
if command -v mktemp &>/dev/null; then
718-
tmp_output=$(mktemp "${TMPDIR:-/tmp}/acfs-checksums-out.XXXXXX" 2>/dev/null) || tmp_output=""
719-
fi
803+
tmp_output="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-checksums-out.XXXXXX" 2>/dev/null)" || tmp_output=""
720804

721805
if [[ -z "$tmp_output" ]]; then
722806
echo "ERROR: unable to create temp file for checksums output" >&2
@@ -730,7 +814,7 @@ print_current_checksums() {
730814

731815
{
732816
# YAML output to stdout
733-
echo "# checksums.yaml - Auto-generated $(date -Iseconds)"
817+
echo "# checksums.yaml - Auto-generated $(acfs_security_date -Iseconds)"
734818
echo "# Run: ./scripts/lib/security.sh --update-checksums"
735819
echo ""
736820
echo "installers:"
@@ -742,7 +826,7 @@ print_current_checksums() {
742826
installer_names+=("$name")
743827
done
744828
if [[ ${#installer_names[@]} -gt 0 ]]; then
745-
mapfile -t installer_names < <(printf '%s\n' "${installer_names[@]}" | sort)
829+
mapfile -t installer_names < <(printf '%s\n' "${installer_names[@]}" | acfs_security_sort_lines)
746830
fi
747831

748832
for name in "${installer_names[@]}"; do
@@ -772,13 +856,13 @@ print_current_checksums() {
772856
done
773857

774858
if [[ "$had_failure" == "true" ]]; then
775-
rm -f "$tmp_output" 2>/dev/null || true
859+
_acfs_remove_temp_files "$tmp_output"
776860
echo "ERROR: one or more installer checksums failed to fetch; refusing to emit incomplete checksums.yaml" >&2
777861
return 1
778862
fi
779863

780-
cat "$tmp_output"
781-
rm -f "$tmp_output" 2>/dev/null || true
864+
acfs_security_cat_file "$tmp_output"
865+
_acfs_remove_temp_files "$tmp_output"
782866
}
783867

784868
# ============================================================
@@ -901,14 +985,23 @@ declare -g ACFS_CHECKSUMS_REMOTE_REFRESHED=false
901985

902986
acfs_checksums_file_looks_valid() {
903987
local file="$1"
988+
local line=""
989+
904990
[[ -r "$file" ]] || return 1
905-
grep -Eq '^[[:space:]]*installers:[[:space:]]*$' "$file"
991+
992+
while IFS= read -r line || [[ -n "$line" ]]; do
993+
[[ "$line" =~ ^[[:space:]]*installers:[[:space:]]*$ ]] && return 0
994+
done < "$file"
995+
996+
return 1
906997
}
907998

908999
acfs_fetch_fresh_checksums_to_file() {
9091000
local dest="$1"
1001+
local cache_buster=""
9101002
local api_url="https://api.github.com/repos/${ACFS_REPO_OWNER}/${ACFS_REPO_NAME}/contents/checksums.yaml?ref=${ACFS_CHECKSUMS_REF}"
911-
local raw_url="https://raw.githubusercontent.com/${ACFS_REPO_OWNER}/${ACFS_REPO_NAME}/${ACFS_CHECKSUMS_REF}/checksums.yaml?cb=$(date +%s)"
1003+
cache_buster="$(acfs_security_date +%s 2>/dev/null || printf '0')"
1004+
local raw_url="https://raw.githubusercontent.com/${ACFS_REPO_OWNER}/${ACFS_REPO_NAME}/${ACFS_CHECKSUMS_REF}/checksums.yaml?cb=${cache_buster}"
9121005

9131006
: > "$dest" 2>/dev/null || {
9141007
log_detail "Unable to initialize temporary checksums file: $dest"
@@ -946,24 +1039,24 @@ acfs_refresh_loaded_checksums_from_remote() {
9461039
fi
9471040

9481041
local refreshed_file=""
949-
refreshed_file="$(mktemp "${TMPDIR:-/tmp}/acfs-checksums-refresh.XXXXXX" 2>/dev/null)" || refreshed_file=""
1042+
refreshed_file="$(acfs_security_mktemp "${TMPDIR:-/tmp}/acfs-checksums-refresh.XXXXXX" 2>/dev/null)" || refreshed_file=""
9501043
if [[ -z "$refreshed_file" ]]; then
9511044
log_detail "Unable to create temp file for refreshed checksums"
9521045
return 1
9531046
fi
9541047

9551048
if ! acfs_fetch_fresh_checksums_to_file "$refreshed_file"; then
956-
rm -f "$refreshed_file"
1049+
_acfs_remove_temp_files "$refreshed_file"
9571050
return 1
9581051
fi
9591052

9601053
if ! load_checksums "$refreshed_file"; then
961-
rm -f "$refreshed_file"
1054+
_acfs_remove_temp_files "$refreshed_file"
9621055
return 1
9631056
fi
9641057

9651058
ACFS_CHECKSUMS_REMOTE_REFRESHED=true
966-
rm -f "$refreshed_file"
1059+
_acfs_remove_temp_files "$refreshed_file"
9671060
return 0
9681061
}
9691062

@@ -1440,7 +1533,7 @@ verify_all_installers() {
14401533
# Output: JSON object with matches, mismatches, and errors arrays
14411534
verify_all_installers_json() {
14421535
local timestamp
1443-
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1536+
timestamp="$(acfs_security_date -u +"%Y-%m-%dT%H:%M:%SZ")"
14441537

14451538
# Arrays to collect results
14461539
local matches=()
@@ -1477,13 +1570,9 @@ verify_all_installers_json() {
14771570
local fetch_error=""
14781571
local tmp_err=""
14791572

1480-
# Create temp file for stderr capture
1481-
# Fallback to empty if mktemp fails (no capture), do NOT use predictable /tmp paths.
1482-
if command -v mktemp &>/dev/null; then
1483-
tmp_err=$(mktemp 2>/dev/null) || tmp_err=""
1484-
else
1485-
tmp_err=""
1486-
fi
1573+
# Create temp file for stderr capture. If this fails, do not use a
1574+
# predictable fallback path; lose stderr detail instead.
1575+
tmp_err="$(acfs_security_mktemp 2>/dev/null)" || tmp_err=""
14871576

14881577
# Capture stdout to variable, stderr to file (if tmp_err exists)
14891578
if [[ -n "$tmp_err" ]]; then
@@ -1492,10 +1581,10 @@ verify_all_installers_json() {
14921581
:
14931582
else
14941583
# Failure
1495-
fetch_error=$(cat "$tmp_err")
1584+
fetch_error="$(acfs_security_cat_file "$tmp_err")"
14961585
[[ -z "$fetch_error" ]] && fetch_error="Unknown error fetching checksum"
14971586
fi
1498-
rm -f "$tmp_err"
1587+
_acfs_remove_temp_files "$tmp_err"
14991588
else
15001589
# Fallback: capture combined output or lose stderr if we can't separate them safely
15011590
# without a temp file. Here we prioritize safety over error details.

0 commit comments

Comments
 (0)