From 92c60c3ecd2448e7f139dc875bcdf9b2cc2e5838 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 27 Aug 2026 09:22:17 -0400 Subject: [PATCH] Fix: Close runtime provenance residuals --- setup/scripts/install-packages | 47 +++++-- tests/setup/package-provenance-contract | 159 +++++++++++++++++++++++- 2 files changed, 190 insertions(+), 16 deletions(-) diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 79d1eb7..aecfa99 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -139,25 +139,48 @@ _archive_member_is_safe() { || "$member" == "$expected_top/"* ]] } +_bun_zip_entry_types_match() { + local archive="$1" archive_top="$2" details entry_types + details="$(unzip -Z -s "$archive")" || return 1 + entry_types="$(awk -v directory="$archive_top/" -v binary="$archive_top/bun" ' + $NF == directory || $NF == binary { print substr($1, 1, 1), $NF } + ' <<<"$details")" || return 1 + [[ "$entry_types" == "d $archive_top/"$'\n'"- $archive_top/bun" ]] +} + _tree_links_stay_inside() { - local root="$1" link resolved - while IFS= read -r -d '' link; do - resolved="$(realpath -m -- "$link")" || return 1 - [[ "$resolved" == "$root" || "$resolved" == "$root/"* ]] || return 1 - done < <(find "$root" -type l -print0) + local root="$1" link resolved scan_fd scan_pid scan_status=0 invalid=0 + # Retain and wait for find's PID: a loop fed directly by process substitution + # cannot otherwise distinguish an empty tree from a failed traversal. + exec {scan_fd}< <(find "$root" -type l -print0) + scan_pid=$! + while IFS= read -r -d '' link <&"$scan_fd"; do + resolved="$(realpath -m -- "$link")" || { invalid=1; continue; } + [[ "$resolved" == "$root" || "$resolved" == "$root/"* ]] || invalid=1 + done + exec {scan_fd}<&- + wait "$scan_pid" || scan_status=$? + (( scan_status == 0 && invalid == 0 )) } _tree_hardlinks_stay_inside() { - local root="$1" device inode link_count key + local root="$1" device inode link_count key scan_fd scan_pid scan_status=0 + local invalid=0 local -A names_in_tree=() inode_links=() - while read -r device inode link_count; do + exec {scan_fd}< <(find "$root" -type f -printf '%D %i %n\n') + scan_pid=$! + while read -r device inode link_count <&"$scan_fd"; do key="$device:$inode" names_in_tree["$key"]=$(( ${names_in_tree[$key]:-0} + 1 )) inode_links["$key"]="$link_count" - done < <(find "$root" -type f -printf '%D %i %n\n') - for key in "${!names_in_tree[@]}"; do - [[ "${names_in_tree[$key]}" == "${inode_links[$key]}" ]] || return 1 done + exec {scan_fd}<&- + wait "$scan_pid" || scan_status=$? + (( scan_status == 0 )) || return 1 + for key in "${!names_in_tree[@]}"; do + [[ "${names_in_tree[$key]}" == "${inode_links[$key]}" ]] || invalid=1 + done + (( invalid == 0 )) } _atomic_symlink() ( @@ -348,6 +371,8 @@ _install_bun() ( listing="$(unzip -Z1 "$archive")" || { rm -rf -- "$stage"; return 1; } [[ "$listing" == "$archive_top/"$'\n'"$archive_top/bun" ]] \ || { rm -rf -- "$stage"; return 1; } + _bun_zip_entry_types_match "$archive" "$archive_top" \ + || { rm -rf -- "$stage"; return 1; } while IFS= read -r member; do _archive_member_is_safe "$member" "$archive_top" \ || { rm -rf -- "$stage"; return 1; } @@ -1064,7 +1089,7 @@ _install_claude_code() { "$staged_repo" /etc/yum.repos.d/claude-code.repo || status=$? if (( status == 0 )); then sudo dnf install -y --repo=claude-code --repo=fedora --repo=updates \ - claude-code || status=$? + --from-repo=claude-code claude-code || status=$? fi rm -rf -- "$work" return "$status" diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index 52bae54..c8b07c3 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -379,6 +379,27 @@ for arch_spec in \ "$artifact_root/build/codex-resources" rm -f -- "$artifact_root/build/codex-package.json" done +python3 - "$artifact_root/bun-symlink.zip" "$artifact_root/bun-special.zip" \ + "$artifact_root/bun-directory.zip" <<'PY' +import stat +import sys +import zipfile + +for destination, directory_type, entry_type, content in ( + (sys.argv[1], stat.S_IFDIR, stat.S_IFLNK, b"../../outside-bun"), + (sys.argv[2], stat.S_IFDIR, stat.S_IFSOCK, b'#!/usr/bin/env bash\nprintf "1.4.0\\n"\n'), + (sys.argv[3], stat.S_IFREG, stat.S_IFREG, b'#!/usr/bin/env bash\nprintf "1.4.0\\n"\n'), +): + with zipfile.ZipFile(destination, "w") as archive: + directory = zipfile.ZipInfo("bun-linux-x64/") + directory.create_system = 3 + directory.external_attr = (directory_type | 0o755) << 16 + archive.writestr(directory, b"") + binary = zipfile.ZipInfo("bun-linux-x64/bun") + binary.create_system = 3 + binary.external_attr = (entry_type | 0o755) << 16 + archive.writestr(binary, content) +PY mkdir -p "$artifact_root/build/wrong-node/bin" "$artifact_root/build/wrong-codex" printf '#!/usr/bin/env bash\nprintf "v24.20.0\\n"\n' > "$artifact_root/build/wrong-node/bin/node" printf '#!/usr/bin/env bash\nprintf "codex-cli 0.150.1\\n"\n' > "$artifact_root/build/wrong-codex/codex" @@ -391,10 +412,12 @@ printf 'unexpected\n' > "$artifact_root/build/bun-linux-x64/extra" chmod +x "$artifact_root/build/bun-linux-x64/bun" (cd "$artifact_root/build" && zip -q "$artifact_root/bun-bad.zip" \ bun-linux-x64/ bun-linux-x64/bun bun-linux-x64/extra) -printf 'reviewed rustdesk fixture\n' > "$artifact_root/rustdesk.rpm" +cp "$test_tmp/unsigned-fixture.rpm" "$artifact_root/rustdesk.rpm" printf '#!/usr/bin/env bash\nprintf "executed\\n" > "$OUTSIDE_EXECUTED"\nprintf "v24.20.0\\n"\n' \ > "$artifact_root/outside-node" -chmod +x "$artifact_root/outside-node" +printf '#!/usr/bin/env bash\nprintf "executed\\n" > "$OUTSIDE_EXECUTED"\nprintf "codex-cli 0.150.1\\n"\n' \ + > "$artifact_root/outside-codex" +chmod +x "$artifact_root/outside-node" "$artifact_root/outside-codex" node_escape_top='node-v24.20.0-linux-x64' mkdir -p "$artifact_root/build/$node_escape_top/bin" ln -s "$artifact_root/outside-node" "$artifact_root/build/$node_escape_top/bin/node" @@ -442,6 +465,68 @@ if [[ "${STUB_NODE_ESCAPE:-}" == hardlink && "$*" == *'-xJf'* ]]; then rm -f -- "$node" ln "$ARTIFACT_ROOT/outside-node" "$node" fi +if [[ -n "${STUB_TRAVERSAL_ERROR:-}" && "$*" == *'-x'* ]]; then + destination='' + arguments=("$@") + for ((index = 0; index < ${#arguments[@]}; index++)); do + if [[ "${arguments[index]}" == -C ]]; then + destination="${arguments[index + 1]}" + break + fi + done + [[ -n "$destination" ]] || exit 73 + case "$STUB_TRAVERSAL_ERROR" in + Node) + selected="$destination/node-v24.20.0-linux-x64/bin/node" + outside="$ARTIFACT_ROOT/outside-node" + mkdir "$destination/node-v24.20.0-linux-x64/.unreadable" + chmod 000 "$destination/node-v24.20.0-linux-x64/.unreadable" + ;; + Codex) + selected="$destination/bin/codex" + outside="$ARTIFACT_ROOT/outside-codex" + ;; + esac + rm -f -- "$selected" + ln -s -- "$outside" "$selected" +fi +STUB + + cat > "$case_root/bin/find" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${STUB_TRAVERSAL_ERROR:-}" && "${1:-}" == *'.stage.'* ]]; then + printf 'find:traversal-error:%s\n' "$STUB_TRAVERSAL_ERROR" >> "$COMMAND_LOG" + if [[ "$STUB_TRAVERSAL_ERROR" == Node ]]; then + root="$1" + shift + exec /usr/bin/find "$root/.unreadable" "$@" + fi + printf 'malformed traversal output' + exit 74 +fi +exec /usr/bin/find "$@" +STUB + + cat > "$case_root/bin/unzip" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${STUB_BUN_ATTRIBUTE:-}" && "${1:-}" == -q ]]; then + destination='' + while (($#)); do + if [[ "$1" == -d ]]; then + destination="$2" + break + fi + shift + done + [[ -n "$destination" ]] || exit 72 + mkdir -p "$destination/bun-linux-x64" + cp "$ARTIFACT_ROOT/bun-x86_64.bin" "$destination/bun-linux-x64/bun" + chmod +x "$destination/bun-linux-x64/bun" + exit 0 +fi +exec /usr/bin/unzip "$@" STUB cat > "$case_root/bin/mv" <<'STUB' @@ -548,7 +633,8 @@ case "$url" in ;; *node-v24.20.0-linux-arm64.tar.xz) cp "$ARTIFACT_ROOT/node-aarch64.tar.xz" "$output" ;; *bun-linux-x64.zip) - cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+bun-bad.zip}" "$output" 2>/dev/null \ + cp "$ARTIFACT_ROOT/${STUB_BUN_ATTRIBUTE:+bun-$STUB_BUN_ATTRIBUTE.zip}" "$output" 2>/dev/null \ + || cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+bun-bad.zip}" "$output" 2>/dev/null \ || cp "$ARTIFACT_ROOT/bun-x86_64.zip" "$output" ;; *bun-linux-aarch64.zip) cp "$ARTIFACT_ROOT/bun-aarch64.zip" "$output" ;; @@ -570,6 +656,15 @@ if [[ "${STUB_DIGEST_MISMATCH:-}" == 1 ]]; then printf '%064d %s\n' 0 "$file" exit 0 fi +if [[ "${STUB_TRAVERSAL_ERROR:-}" == Node ]] \ + && cmp -s "$file" "$ARTIFACT_ROOT/outside-node"; then + printf '%s %s\n' 89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7 "$file" + exit 0 +elif [[ "${STUB_TRAVERSAL_ERROR:-}" == Codex ]] \ + && cmp -s "$file" "$ARTIFACT_ROOT/outside-codex"; then + printf '%s %s\n' abf1bb1643a79f73aa78ee627e111e02d4f8c98f25813a0cf6ce277709664386 "$file" + exit 0 +fi for spec in \ 'node-x86_64.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ 'node-aarch64.tar.xz 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7' \ @@ -578,12 +673,18 @@ for spec in \ 'bun-x86_64.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ 'bun-aarch64.zip 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e' \ 'bun-bad.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'bun-symlink.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'bun-special.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'bun-directory.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ 'codex-x86_64.tar.gz 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ 'codex-aarch64.tar.gz 1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639' \ 'codex-bad.tar.gz 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ 'rustdesk.rpm eb1b053ac5b2f774f2271f7fbbfd2ea475899f7a55135c5e172bc54b9388f108'; do read -r fixture digest <<<"$spec" if cmp -s "$file" "$ARTIFACT_ROOT/$fixture"; then + if [[ "$fixture" == rustdesk.rpm ]]; then + stat -c '%d:%i' "$file" > "$VERIFIED_RUSTDESK_INODE" + fi printf '%s %s\n' "$digest" "$file" exit 0 fi @@ -695,11 +796,24 @@ printf 'sudo:%s\n' "${logged[*]}" >> "$COMMAND_LOG" if [[ "$original" == *'/rustdesk.rpm'* ]]; then [[ "$original" == *'--setopt=localpkg_gpgcheck=0'* \ && "$original" != *'--setopt=localpkg_gpgcheck=1'* ]] || exit 70 + rustdesk_path='' + for argument in "$@"; do + [[ "$(basename -- "$argument")" != rustdesk.rpm ]] || rustdesk_path="$argument" + done + [[ "$rustdesk_path" == "$TMPDIR"/tmp.*/rustdesk.rpm \ + && -s "$VERIFIED_RUSTDESK_INODE" + && "$(stat -c '%d:%i' "$rustdesk_path")" == "$(<"$VERIFIED_RUSTDESK_INODE")" ]] \ + && cmp -s "$rustdesk_path" "$UNSIGNED_RPM" || exit 72 + /usr/bin/rpm -qp --queryformat '%{NAME}\n' "$rustdesk_path" >/dev/null || exit 73 + signature_status="$(/usr/bin/rpmkeys --checksig --verbose "$rustdesk_path")" || exit 74 + [[ "$signature_status" == *'Header SHA256 digest: OK'* \ + && "$signature_status" == *'Payload SHA256 digest: OK'* \ + && "${signature_status,,}" != *signature* ]] || exit 75 fi if [[ "$original" == *' pnpm' || "$original" == *' claude-code' ]]; then case "$original" in 'dnf install -y --repo=fedora --repo=updates pnpm'|\ - 'dnf install -y --repo=claude-code --repo=fedora --repo=updates claude-code') ;; + 'dnf install -y --repo=claude-code --repo=fedora --repo=updates --from-repo=claude-code claude-code') ;; *) exit 71 ;; esac fi @@ -869,6 +983,7 @@ run_installer_function() { make_stub_commands "$case_root" : > "$case_root/commands.log" printf '0\n' > "$case_root/install-counter" + : > "$case_root/verified-rustdesk-inode" printf 'preserved\n' > "$case_root/flatpak-state" : > "$case_root/softly-failed" case "${STUB_SEED_OLD:-}" in @@ -1046,6 +1161,8 @@ run_installer_function() { STUB_FLATPAK_STATE="$case_root/flatpak-state" \ STUB_FLATPAK_REPO="$case_root/flatpak-repo" \ STUB_INSTALL_COUNTER="$case_root/install-counter" \ + VERIFIED_RUSTDESK_INODE="$case_root/verified-rustdesk-inode" \ + UNSIGNED_RPM="$test_tmp/unsigned-fixture.rpm" \ LC_ALL="${STUB_CALLER_LOCALE:-C}" \ HOME="$case_root/home" \ NVM_DIR="$case_root/home/.nvm" \ @@ -1230,6 +1347,24 @@ for escape_kind in symlink hardlink; do assert_no_runtime_staging "$name" done +# A traversal command that cannot read/describe the staged tree is itself a +# trust failure. Node traverses an unreadable subtree and Codex emits malformed +# output before failing; scanners that lose the status execute the outside tool. +for traversal_spec in 'Node install_node' 'Codex install_codex'; do + read -r component function_name <<<"$traversal_spec" + name="${component,,}-traversal-error" + reset_installer_fixture + STUB_TRAVERSAL_ERROR="$component" expect_failure \ + run_installer_function "$name" "$function_name" + grep -qFx "find:traversal-error:$component" \ + "$test_tmp/cases/$name/commands.log" \ + || fail "$name did not exercise the traversal error" + [[ ! -e "$test_tmp/cases/$name/outside-executed" ]] \ + || fail "$name executed a target hidden by a failed traversal" + assert_soft_failure "$name" "$component" + assert_no_runtime_staging "$name" +done + # A bad digest or interrupted transfer cannot replace the previously active # tool and cannot leave reusable bytes behind. for failure_mode in digest interrupted; do @@ -1366,6 +1501,20 @@ for layout_case in \ assert_no_runtime_staging "$name" done +# ZIP names alone do not establish entry type. The extraction adapter +# deliberately materializes all crafted entries as regular executable files, +# so only central-directory attribute validation can reject them pre-extraction. +for attribute_kind in symlink special directory; do + name="bun-$attribute_kind-attribute" + reset_installer_fixture + STUB_BUN_ATTRIBUTE="$attribute_kind" expect_failure \ + run_installer_function "$name" install_bun + assert_soft_failure "$name" Bun + [[ ! -e "$test_tmp/cases/$name/home/.bun/versions/1.4.0" ]] \ + || fail "$name activated a ZIP entry with non-regular metadata" + assert_no_runtime_staging "$name" +done + # Legacy version-only and mismatched collisions are preserved and reported; # neither can be deleted and recreated or treated as installer provenance. for collision_mode in legacy collision; do @@ -1503,7 +1652,7 @@ rpm:release gpg:fingerprint:31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE sudo:install:claude-code.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama sudo:install:claude-code.repo:/etc/yum.repos.d/claude-code.repo -sudo:dnf install -y --repo=claude-code --repo=fedora --repo=updates claude-code +sudo:dnf install -y --repo=claude-code --repo=fedora --repo=updates --from-repo=claude-code claude-code EXPECTED )" cmp -s "$installer_fixture/setup/provenance/keys/claude-code.asc" \