From f6b0aaf1229cf8d54f7a4e65793c4fa0a57b2fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 28 Aug 2026 20:34:16 +0200 Subject: [PATCH] findings X-1: pin every dependency tarball fetch to a SHA256 and enforce it security-audit-v3 #15. download_file already had a verify branch; no caller used it and every *_SHA256 in versions.sh was a commented placeholder, so a tampered mirror tarball flowed straight into configure/make and the shipped WASM. - versions.sh: 13 pins (cross-checked against Homebrew/Buildroot/nixpkgs/ FreeBSD/vcpkg/boost.org/curl PGP; glm .zip is TOFU), boost/curl/libgit2 versions moved beside their pins. - all 13 download_file call sites pass "${NAME_SHA256}". - download_file refuses an empty or malformed pin (PCBJAM_ALLOW_UNPINNED=1 to bootstrap a new dep); file_sha256 prefers sha256sum, falls back to shasum. - scripts/deps/check-pins.sh: static 3-arg check + offline file:// enforcement test; runs in wasm-build.yml before the deps cache, on cache hits too. Expect one cold --build-deps run: the deps-cache key hashes versions.sh. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GcsgJZ77bhZatLAVU8R84H --- .github/workflows/wasm-build.yml | 6 ++ scripts/common/functions.sh | 30 +++++++++- scripts/common/versions.sh | 35 +++++++++--- scripts/deps/build-boost.sh | 2 +- scripts/deps/build-cairo.sh | 2 +- scripts/deps/build-curl-headers.sh | 2 +- scripts/deps/build-freetype.sh | 2 +- scripts/deps/build-glm.sh | 2 +- scripts/deps/build-harfbuzz.sh | 2 +- scripts/deps/build-libgit2-headers.sh | 2 +- scripts/deps/build-ngspice.sh | 2 +- scripts/deps/build-opencascade.sh | 4 +- scripts/deps/build-pixman.sh | 2 +- scripts/deps/build-protobuf.sh | 2 +- scripts/deps/build-zstd.sh | 2 +- scripts/deps/check-pins.sh | 82 +++++++++++++++++++++++++++ 16 files changed, 157 insertions(+), 22 deletions(-) create mode 100755 scripts/deps/check-pins.sh diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index 7d36479..48f1e0c 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -140,6 +140,12 @@ jobs: !output/*.wasm.debug.wasm key: ${{ steps.keys.outputs.key }} + # X-1: every dep tarball fetch must carry a SHA256 pin and download_file + # must enforce it. Seconds, no docker, offline — runs even on cache hits + # so a regression is caught before it can poison the deps cache. + - name: Check dependency tarball pins + run: scripts/deps/check-pins.sh + # deps (sysroot + stamps) are only needed when we must COMPILE (cache miss). - name: Restore deps cache id: deps-cache diff --git a/scripts/common/functions.sh b/scripts/common/functions.sh index c4edc63..2d0c6f1 100755 --- a/scripts/common/functions.sh +++ b/scripts/common/functions.sh @@ -77,15 +77,41 @@ verify_submodules() { } # Download file with optional verification +# sha256 of a file. Prefer coreutils (always in the builder image); fall back +# to perl's shasum (macOS hosts; present in the image only because git pulls +# in perl). +file_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# download_file URL DEST SHA256 +# The pin is mandatory (X-1 / security-audit-v3 #15): a fetched-then-compiled +# tarball with no content pin is the supply-chain hole. Set +# PCBJAM_ALLOW_UNPINNED=1 only while bootstrapping a new dependency, to obtain +# the value that then goes into scripts/common/versions.sh. download_file() { local url="$1" local dest="$2" local expected_sha256="${3:-}" + if [ -z "$expected_sha256" ] && [ "${PCBJAM_ALLOW_UNPINNED:-0}" != "1" ]; then + log_error "download_file: no SHA256 pin for $url" + log_error " add _SHA256 to scripts/common/versions.sh (PCBJAM_ALLOW_UNPINNED=1 to bootstrap)" + return 1 + fi + if [ -n "$expected_sha256" ] && ! printf '%s' "$expected_sha256" | grep -Eq '^[0-9a-f]{64}$'; then + log_error "download_file: malformed SHA256 pin for $url: $expected_sha256" + return 1 + fi + if [ -f "$dest" ]; then if [ -n "$expected_sha256" ]; then local actual_sha256 - actual_sha256=$(shasum -a 256 "$dest" 2>/dev/null | cut -d' ' -f1) + actual_sha256=$(file_sha256 "$dest" 2>/dev/null) if [ "$actual_sha256" = "$expected_sha256" ]; then log_info "$(basename "$dest") already downloaded and verified" return 0 @@ -133,7 +159,7 @@ download_file() { if [ -n "$expected_sha256" ]; then local actual_sha256 - actual_sha256=$(shasum -a 256 "$dest" | cut -d' ' -f1) + actual_sha256=$(file_sha256 "$dest") if [ "$actual_sha256" != "$expected_sha256" ]; then log_error "SHA256 mismatch for $dest" log_error " Expected: $expected_sha256" diff --git a/scripts/common/versions.sh b/scripts/common/versions.sh index c26eb94..c7807ed 100755 --- a/scripts/common/versions.sh +++ b/scripts/common/versions.sh @@ -59,10 +59,31 @@ export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_C # 404s). old-releases is the durable home for a pinned version. export NGSPICE_URL="https://downloads.sourceforge.net/project/ngspice/ng-spice-rework/old-releases/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz" -# SHA256 checksums (to be filled in after first successful download) -# export ZSTD_SHA256="" -# export HARFBUZZ_SHA256="" -# export CAIRO_SHA256="" -# export PIXMAN_SHA256="" -# export OCC_SHA256="" -# export NGSPICE_SHA256="" +# Pinned versions for deps whose URL is composed in their build script. +export BOOST_VERSION="1.84.0" +export CURL_VERSION="8.5.0" +export LIBGIT2_VERSION="1.7.1" + +# SHA256 pins for every download_file() fetch (X-1 / security-audit-v3 #15). +# download_file refuses an empty pin unless PCBJAM_ALLOW_UNPINNED=1. +# Values computed 2026-08-28 from the URLs above and cross-checked against an +# independent publisher (Homebrew / Buildroot / nixpkgs / FreeBSD ports / vcpkg / +# boost.org / curl PGP); the table with each corroboration lives in +# docs/features/findings/groups/X-build-supply-chain-audit-tooling.md (private +# superproject). glm's .zip is the one nobody else records (TOFU). +# OCC / rapidjson / libgit2 are GitHub auto-generated tag archives: if one of +# those pins ever fails on an unchanged tag, GitHub's archive bytes moved +# (it happened 2023-01) — re-corroborate before re-pinning. +export ZSTD_SHA256="9c4396cc829cfae319a6e2615202e82aad41372073482fce286fac78646d3ee4" +export FREETYPE_SHA256="12991c4e55c506dd7f9b765933e62fd2be2e06d421505d7950a132e4f1bb484d" +export HARFBUZZ_SHA256="109501eaeb8bde3eadb25fab4164e993fbace29c3d775bcaa1c1e58e2f15f847" +export CAIRO_SHA256="243a0736b978a33dee29f9cca7521733b78a65b5418206fef7bd1c3d4cf10b64" +export PIXMAN_SHA256="ea1480efada2fd948bc75366f7c349e1c96d3297d09a3fe62626e38e234a625e" +export OCC_SHA256="096cd0f268fa9f6a50818e1d628ac92ecf87e10fd72187e2e8d6be57dfe12530" +export RAPIDJSON_SHA256="2d2601a82d2d3b7e143a3c8d43ef616671391034bc46891a9816b79cf2d3e7a8" +export NGSPICE_SHA256="a0d1699af1940b06649276dcd6ff5a566c8c0cad01b2f7b5e99dedbb4d64c19b" +export GLM_SHA256="37e2a3d62ea3322e43593c34bae29f57e3e251ea89f4067506c94043769ade4c" +export BOOST_SHA256="a5800f405508f5df8114558ca9855d2640a2de8f0445f051fa1c7c3383045724" +export CURL_SHA256="05fc17ff25b793a437a0906e0484b82172a9f4de02be5ed447e0cab8c3475add" +export LIBGIT2_SHA256="17d2b292f21be3892b704dddff29327b3564f96099a1c53b00edc23160c71327" +export PROTOBUF_SHA256="4eab9b524aa5913c6fffb20b2a8abf5ef7f95a80bc0701f3a6dbb4c607f73460" diff --git a/scripts/deps/build-boost.sh b/scripts/deps/build-boost.sh index 8905e79..30be195 100755 --- a/scripts/deps/build-boost.sh +++ b/scripts/deps/build-boost.sh @@ -48,7 +48,7 @@ if [ ! -d "${BOOST_DIR}" ]; then BOOST_ARCHIVE="boost_${BOOST_VERSION_UNDERSCORE}.tar.gz" # Download and extract - download_file "${BOOST_URL}" "${BOOST_ARCHIVE}" + download_file "${BOOST_URL}" "${BOOST_ARCHIVE}" "${BOOST_SHA256}" tar -xzf "${BOOST_ARCHIVE}" rm "${BOOST_ARCHIVE}" fi diff --git a/scripts/deps/build-cairo.sh b/scripts/deps/build-cairo.sh index a46aa2c..1886102 100755 --- a/scripts/deps/build-cairo.sh +++ b/scripts/deps/build-cairo.sh @@ -46,7 +46,7 @@ if [ ! -d "${CAIRO_DIR}" ]; then cd "${DEPS_ROOT}" CAIRO_URL="https://cairographics.org/releases/cairo-${CAIRO_VERSION}.tar.xz" - download_file "${CAIRO_URL}" "cairo-${CAIRO_VERSION}.tar.xz" + download_file "${CAIRO_URL}" "cairo-${CAIRO_VERSION}.tar.xz" "${CAIRO_SHA256}" tar -xJf "cairo-${CAIRO_VERSION}.tar.xz" rm "cairo-${CAIRO_VERSION}.tar.xz" fi diff --git a/scripts/deps/build-curl-headers.sh b/scripts/deps/build-curl-headers.sh index ed96313..343446f 100755 --- a/scripts/deps/build-curl-headers.sh +++ b/scripts/deps/build-curl-headers.sh @@ -45,7 +45,7 @@ if [ ! -d "${CURL_DIR}" ]; then # Download from curl official releases CURL_URL="https://curl.se/download/curl-${CURL_VERSION}.tar.gz" - download_file "${CURL_URL}" "curl-${CURL_VERSION}.tar.gz" + download_file "${CURL_URL}" "curl-${CURL_VERSION}.tar.gz" "${CURL_SHA256}" tar -xzf "curl-${CURL_VERSION}.tar.gz" rm "curl-${CURL_VERSION}.tar.gz" fi diff --git a/scripts/deps/build-freetype.sh b/scripts/deps/build-freetype.sh index 5327051..066180b 100755 --- a/scripts/deps/build-freetype.sh +++ b/scripts/deps/build-freetype.sh @@ -42,7 +42,7 @@ if [ ! -d "${FREETYPE_DIR}" ]; then cd "${DEPS_ROOT}" FREETYPE_URL="https://download.savannah.gnu.org/releases/freetype/freetype-${FREETYPE_VERSION}.tar.xz" - download_file "${FREETYPE_URL}" "freetype-${FREETYPE_VERSION}.tar.xz" + download_file "${FREETYPE_URL}" "freetype-${FREETYPE_VERSION}.tar.xz" "${FREETYPE_SHA256}" tar -xJf "freetype-${FREETYPE_VERSION}.tar.xz" rm "freetype-${FREETYPE_VERSION}.tar.xz" fi diff --git a/scripts/deps/build-glm.sh b/scripts/deps/build-glm.sh index 3c5cb17..2745856 100755 --- a/scripts/deps/build-glm.sh +++ b/scripts/deps/build-glm.sh @@ -41,7 +41,7 @@ if [ ! -d "${GLM_DIR}" ]; then cd "${DEPS_ROOT}" GLM_URL="https://github.com/g-truc/glm/releases/download/${GLM_VERSION}/glm-${GLM_VERSION}.zip" - download_file "${GLM_URL}" "glm-${GLM_VERSION}.zip" + download_file "${GLM_URL}" "glm-${GLM_VERSION}.zip" "${GLM_SHA256}" unzip -q "glm-${GLM_VERSION}.zip" mv glm "glm-${GLM_VERSION}" rm "glm-${GLM_VERSION}.zip" diff --git a/scripts/deps/build-harfbuzz.sh b/scripts/deps/build-harfbuzz.sh index 925b0d3..b2a3112 100755 --- a/scripts/deps/build-harfbuzz.sh +++ b/scripts/deps/build-harfbuzz.sh @@ -45,7 +45,7 @@ if [ ! -d "${HARFBUZZ_DIR}" ]; then cd "${DEPS_ROOT}" HARFBUZZ_URL="https://github.com/harfbuzz/harfbuzz/releases/download/${HARFBUZZ_VERSION}/harfbuzz-${HARFBUZZ_VERSION}.tar.xz" - download_file "${HARFBUZZ_URL}" "harfbuzz-${HARFBUZZ_VERSION}.tar.xz" + download_file "${HARFBUZZ_URL}" "harfbuzz-${HARFBUZZ_VERSION}.tar.xz" "${HARFBUZZ_SHA256}" tar -xJf "harfbuzz-${HARFBUZZ_VERSION}.tar.xz" rm "harfbuzz-${HARFBUZZ_VERSION}.tar.xz" fi diff --git a/scripts/deps/build-libgit2-headers.sh b/scripts/deps/build-libgit2-headers.sh index 43af3e2..2d3c86b 100755 --- a/scripts/deps/build-libgit2-headers.sh +++ b/scripts/deps/build-libgit2-headers.sh @@ -43,7 +43,7 @@ if [ ! -d "${LIBGIT2_DIR}" ]; then cd "${DEPS_ROOT}" LIBGIT2_URL="https://github.com/libgit2/libgit2/archive/refs/tags/v${LIBGIT2_VERSION}.tar.gz" - download_file "${LIBGIT2_URL}" "libgit2-${LIBGIT2_VERSION}.tar.gz" + download_file "${LIBGIT2_URL}" "libgit2-${LIBGIT2_VERSION}.tar.gz" "${LIBGIT2_SHA256}" tar -xzf "libgit2-${LIBGIT2_VERSION}.tar.gz" rm "libgit2-${LIBGIT2_VERSION}.tar.gz" fi diff --git a/scripts/deps/build-ngspice.sh b/scripts/deps/build-ngspice.sh index 91c4d60..af8df70 100755 --- a/scripts/deps/build-ngspice.sh +++ b/scripts/deps/build-ngspice.sh @@ -66,7 +66,7 @@ if [ ! -d "${NGSPICE_DIR}" ]; then mkdir -p "${DEPS_ROOT}" cd "${DEPS_ROOT}" - download_file "${NGSPICE_URL}" "ngspice-${NGSPICE_VERSION}.tar.gz" + download_file "${NGSPICE_URL}" "ngspice-${NGSPICE_VERSION}.tar.gz" "${NGSPICE_SHA256}" tar -xzf "ngspice-${NGSPICE_VERSION}.tar.gz" rm "ngspice-${NGSPICE_VERSION}.tar.gz" fi diff --git a/scripts/deps/build-opencascade.sh b/scripts/deps/build-opencascade.sh index 89de5db..2155ec5 100755 --- a/scripts/deps/build-opencascade.sh +++ b/scripts/deps/build-opencascade.sh @@ -43,7 +43,7 @@ if [ ! -d "${OCC_DIR}" ]; then # OpenCASCADE releases are on GitHub OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OCC_VERSION//./_}.tar.gz" - download_file "${OCC_URL}" "opencascade-${OCC_VERSION}.tar.gz" + download_file "${OCC_URL}" "opencascade-${OCC_VERSION}.tar.gz" "${OCC_SHA256}" tar -xzf "opencascade-${OCC_VERSION}.tar.gz" # Directory name in tarball is OCCT-7_8_0 (without V prefix) mv "OCCT-${OCC_VERSION//./_}" "opencascade-${OCC_VERSION}" @@ -60,7 +60,7 @@ if [ ! -d "${RAPIDJSON_DIR}" ]; then log_info "Downloading RapidJSON ${RAPIDJSON_VERSION} (master snapshot ${RAPIDJSON_COMMIT:0:12})..." mkdir -p "${DEPS_ROOT}" cd "${DEPS_ROOT}" - download_file "${RAPIDJSON_URL}" "rapidjson-${RAPIDJSON_VERSION}.tar.gz" + download_file "${RAPIDJSON_URL}" "rapidjson-${RAPIDJSON_VERSION}.tar.gz" "${RAPIDJSON_SHA256}" tar -xzf "rapidjson-${RAPIDJSON_VERSION}.tar.gz" mv "rapidjson-${RAPIDJSON_COMMIT}" "rapidjson-${RAPIDJSON_VERSION}" rm "rapidjson-${RAPIDJSON_VERSION}.tar.gz" diff --git a/scripts/deps/build-pixman.sh b/scripts/deps/build-pixman.sh index e079525..38c72a1 100755 --- a/scripts/deps/build-pixman.sh +++ b/scripts/deps/build-pixman.sh @@ -42,7 +42,7 @@ if [ ! -d "${PIXMAN_DIR}" ]; then cd "${DEPS_ROOT}" PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz" - download_file "${PIXMAN_URL}" "pixman-${PIXMAN_VERSION}.tar.gz" + download_file "${PIXMAN_URL}" "pixman-${PIXMAN_VERSION}.tar.gz" "${PIXMAN_SHA256}" tar -xzf "pixman-${PIXMAN_VERSION}.tar.gz" rm "pixman-${PIXMAN_VERSION}.tar.gz" fi diff --git a/scripts/deps/build-protobuf.sh b/scripts/deps/build-protobuf.sh index 3eb8734..52c695c 100755 --- a/scripts/deps/build-protobuf.sh +++ b/scripts/deps/build-protobuf.sh @@ -44,7 +44,7 @@ if [ ! -d "${PROTOBUF_DIR}" ]; then # Protobuf 3.21.x uses tag format v21.12 (major version 3 is implicit) PROTOBUF_TAG_VERSION="${PROTOBUF_VERSION#3.}" # Strip leading "3." -> "21.12" PROTOBUF_URL="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOBUF_TAG_VERSION}/protobuf-cpp-${PROTOBUF_VERSION}.tar.gz" - download_file "${PROTOBUF_URL}" "protobuf-${PROTOBUF_VERSION}.tar.gz" + download_file "${PROTOBUF_URL}" "protobuf-${PROTOBUF_VERSION}.tar.gz" "${PROTOBUF_SHA256}" tar -xzf "protobuf-${PROTOBUF_VERSION}.tar.gz" rm "protobuf-${PROTOBUF_VERSION}.tar.gz" fi diff --git a/scripts/deps/build-zstd.sh b/scripts/deps/build-zstd.sh index 79b4fc9..671fce9 100755 --- a/scripts/deps/build-zstd.sh +++ b/scripts/deps/build-zstd.sh @@ -42,7 +42,7 @@ if [ ! -d "${ZSTD_DIR}" ]; then cd "${DEPS_ROOT}" ZSTD_URL="https://github.com/facebook/zstd/releases/download/v${ZSTD_VERSION}/zstd-${ZSTD_VERSION}.tar.gz" - download_file "${ZSTD_URL}" "zstd-${ZSTD_VERSION}.tar.gz" + download_file "${ZSTD_URL}" "zstd-${ZSTD_VERSION}.tar.gz" "${ZSTD_SHA256}" tar -xzf "zstd-${ZSTD_VERSION}.tar.gz" rm "zstd-${ZSTD_VERSION}.tar.gz" fi diff --git a/scripts/deps/check-pins.sh b/scripts/deps/check-pins.sh new file mode 100755 index 0000000..b4fbc7d --- /dev/null +++ b/scripts/deps/check-pins.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Oracle for X-1 (security-audit-v3 #15): every dependency tarball fetch is +# pinned to a SHA256, and download_file actually enforces the pin. +# +# 1. static — every download_file call in scripts/deps/*.sh passes a third +# argument that resolves to a 64-hex value from versions.sh. +# 2. dynamic — download_file against a file:// URL (fully offline): a wrong +# pin fails and removes the file, the right pin succeeds, and an +# empty pin is refused unless PCBJAM_ALLOW_UNPINNED=1. +# +# Runs in seconds with no docker; wired into wasm-build.yml before the deps +# step and usable locally: scripts/deps/check-pins.sh +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../common/versions.sh" +source "${SCRIPT_DIR}/../common/functions.sh" + +fail=0 +ok() { echo " ok $*"; } +bad() { echo " FAIL $*"; fail=1; } + +echo "[check-pins] static: download_file call sites" +while IFS= read -r line; do + file="${line%%:*}"; rest="${line#*:}"; lineno="${rest%%:*}"; call="${rest#*:}" + # third argument must be a "${NAME_SHA256}" reference + if [[ "$call" =~ download_file[[:space:]]+\"[^\"]*\"[[:space:]]+\"[^\"]*\"[[:space:]]+\"\$\{([A-Z0-9_]+_SHA256)\}\" ]]; then + var="${BASH_REMATCH[1]}" + val="${!var:-}" + if printf '%s' "$val" | grep -Eq '^[0-9a-f]{64}$'; then + ok "$(basename "$file"):$lineno -> $var" + else + bad "$(basename "$file"):$lineno -> $var is not a 64-hex pin in versions.sh ('$val')" + fi + else + bad "$(basename "$file"):$lineno has no \"\${NAME_SHA256}\" third argument: $call" + fi +done < <(grep -n '^[[:space:]]*download_file ' "${SCRIPT_DIR}"/build-*.sh) + +echo "[check-pins] dynamic: download_file enforces the pin (file:// URL)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +printf 'pcbjam pin oracle\n' > "$tmp/src.txt" +tar -czf "$tmp/src.tar.gz" -C "$tmp" src.txt +good="$(file_sha256 "$tmp/src.tar.gz")" +wrong="$(printf '%064d' 1)" +url="file://$tmp/src.tar.gz" + +if download_file "$url" "$tmp/d1.tar.gz" "$wrong" >/dev/null 2>&1; then + bad "wrong pin was accepted" +elif [ -e "$tmp/d1.tar.gz" ]; then + bad "wrong pin: mismatched file left on disk" +else + ok "wrong pin rejected and file removed" +fi + +if download_file "$url" "$tmp/d2.tar.gz" "$good" >/dev/null 2>&1 && [ -f "$tmp/d2.tar.gz" ]; then + ok "right pin accepted" +else + bad "right pin rejected" +fi + +if PCBJAM_ALLOW_UNPINNED=0 download_file "$url" "$tmp/d3.tar.gz" >/dev/null 2>&1; then + bad "empty pin accepted without PCBJAM_ALLOW_UNPINNED=1" +else + ok "empty pin refused" +fi + +if PCBJAM_ALLOW_UNPINNED=1 download_file "$url" "$tmp/d4.tar.gz" >/dev/null 2>&1; then + ok "empty pin allowed with PCBJAM_ALLOW_UNPINNED=1 (bootstrap)" +else + bad "bootstrap escape hatch broken" +fi + +if download_file "$url" "$tmp/d5.tar.gz" "not-a-hash" >/dev/null 2>&1; then + bad "malformed pin accepted" +else + ok "malformed pin refused" +fi + +if [ $fail -ne 0 ]; then echo "[check-pins] FAILED"; exit 1; fi +echo "[check-pins] all good"