From cc36c706484ae44c80ae99a4ad905922613781fb Mon Sep 17 00:00:00 2001 From: ErenAri Date: Sun, 18 Oct 2026 23:35:50 +0300 Subject: [PATCH 1/5] install.sh: name the missing gh capability instead of reporting a failed verification The gh path probed only `gh attestation --help`, but its tag-bound check also needs `gh attestation verify --signer-workflow` (gh 2.51.0) and `--source-ref` (gh 2.68.0), with gh's errors discarded. So a host with an older gh and no cosign got either "requires cosign or GitHub CLI (gh)" (gh < 2.49, although gh is installed) or "GitHub attestation verification FAILED" (gh 2.49-2.67), which reads like a tampered release. When cosign is absent and gh is present, ask gh's own help whether it has the attestation command and both flags (capability, not version parsing) and, if not, refuse naming what it lacks and the remedy (gh >= 2.68.0 or cosign). Still fail-closed; cosign is still preferred whenever present; a real verification failure still says FAILED; the cosign identity, the --source-ref value, checksum, license-bundle and sudo logic are unchanged. The tag guard matched line by line, so a BPFCOMPAT_VERSION carrying a valid tag on one line of a multi-line value passed it and was stopped only by curl. Reject any character a tag cannot hold before the regex, so the whole value is checked. install_test.sh: the gh shim can play gh < 2.49 / < 2.51 / < 2.68; new cases for each (and an unparsable version), for cosign still winning over an old gh, and for newline-bearing tags. Signed-off-by: ErenAri --- scripts/install.sh | 28 ++++++++++++- scripts/install_test.sh | 91 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 55b5885..43b4731 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -77,7 +77,12 @@ if [ "$VERSION" = "latest" ]; then [ -n "$VERSION" ] || die "could not resolve the latest release tag" fi # The tag is spliced into download URLs and into the exact signing identity -# below, so it must be a plain release tag (no path segments, no regex). +# below, so it must be a plain release tag (no path segments, no regex). grep +# matches line by line, so first reject any character a tag cannot hold (a +# newline included): the whole value is checked, not just one of its lines. +case "$VERSION" in + *[!0123456789.crv-]*) die "invalid release tag '$VERSION' (expected vX.Y.Z or vX.Y.Z-rc.N)" ;; +esac printf '%s\n' "$VERSION" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?' || die "invalid release tag '$VERSION' (expected vX.Y.Z or vX.Y.Z-rc.N)" base="https://github.com/$REPO/releases/download/$VERSION" @@ -143,7 +148,26 @@ if have cosign; then else die "cosign signature verification FAILED for SHA256SUMS - refusing to install" fi -elif have gh && gh attestation --help >/dev/null 2>&1; then +elif have gh; then + # The tag-bound check below needs `gh attestation` (gh 2.49.0), + # --signer-workflow (2.51.0) and --source-ref (2.68.0). An older gh would + # fail it like a forged release would, so ask gh's own help what it offers + # (not its version: distro builds vary) and name what is missing instead. + gh_lacks="" + if ! gh attestation --help >/dev/null 2>&1; then + gh_lacks="the 'gh attestation' command" + else + gh_help="$(gh attestation verify --help 2>&1 || true)" + for flag in --signer-workflow --source-ref; do + printf '%s\n' "$gh_help" | grep -Eq -- "(^|[^[:alnum:]-])$flag([^[:alnum:]-]|\$)" || + gh_lacks="${gh_lacks:+$gh_lacks and }$flag" + done + [ -z "$gh_lacks" ] || gh_lacks="$gh_lacks in 'gh attestation verify'" + fi + if [ -n "$gh_lacks" ]; then + gh_ver="$(gh --version 2>/dev/null | sed -n '1s/^gh version \([0-9][0-9.]*\).*/\1/p')" + die "installed gh${gh_ver:+ $gh_ver} lacks $gh_lacks, needed for tag-bound attestation verification; upgrade to gh >= 2.68.0 or install cosign - refusing checksum-only installation" + fi err "cosign not found; verifying GitHub build attestations" GH_REPO="$REPO" gh attestation verify "$CLI_ASSET" --repo "$REPO" \ --signer-workflow "$REPO/.github/workflows/release-artifacts.yml" \ diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 1356743..6fb1fb3 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -11,15 +11,20 @@ # the identity baked into SHA256SUMS.crt (the release tag that signed it) # and the signature covers this exact SHA256SUMS; # * gh accepts an attestation only for a binary the fake release pipeline -# attested, and honours --source-ref like the real CLI does; +# attested, and honours --source-ref like the real CLI does; with +# FAKE_GH_LACKS it plays an older gh that has no `attestation` command or +# no --signer-workflow / --source-ref flag, failing like cobra does; # * sudo pretends to be root by unlocking the test's read-only tree. # # Cases: latest -> stable, arch mapping (incl. extra SHA256SUMS lines, the # v0.3.6 bug class), fail-closed without sha tools / without cosign+gh / on a # checksum mismatch, signatures bound to the requested tag, truncated -# `curl | sh` streams, custom install dirs, tag validation, and the -# third-party license bundle (installed when SHA256SUMS lists it, verified like -# the binaries, refused when corrupt/missing/unattested, absent in old releases). +# `curl | sh` streams, custom install dirs, tag validation (the whole value, +# embedded newlines included), the third-party license bundle (installed when +# SHA256SUMS lists it, verified like the binaries, refused when +# corrupt/missing/unattested, absent in old releases), and a gh too old for +# the tag-bound check (refused naming what it lacks, never reported as a +# failed verification or a missing gh; cosign still preferred when present). set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -42,6 +47,7 @@ SHIM="$tmp/shim" TOOLS="$tmp/tools" ATTEST_DB="$tmp/attestations" CURL_LOG="$tmp/curl.log" +GH_LOG="$tmp/gh.log" mkdir -p "$WWW" "$SHIM" "$TOOLS" "$tmp/home" : >"$ATTEST_DB" @@ -134,11 +140,40 @@ EOF cat >"$SHIM/gh" <>"$GH_LOG" +lacks=" \${FAKE_GH_LACKS:-} " +if [[ "\${1:-}" == "--version" ]]; then + printf 'gh version %s (2026-01-01)\nhttps://github.com/cli/cli/releases/latest\n' "\${FAKE_GH_VERSION:-2.102.0}" + exit 0 +fi +if [[ "\${1:-}" == attestation && "\$lacks" == *" attestation "* ]]; then + printf 'unknown command "attestation" for "gh"\n\nUsage: gh [flags]\n' >&2 + exit 1 +fi [[ "\${1:-} \${2:-}" == "attestation --help" ]] && exit 0 +if [[ "\${1:-} \${2:-} \${3:-}" == "attestation verify --help" ]]; then + echo 'Verify the integrity and provenance of an artifact using its associated' + echo 'cryptographically signed attestations.' + [[ "\$lacks" == *" --signer-workflow "* ]] || + echo 'When using reusable workflows, use the \`--signer-repo\` or \`--signer-workflow\` flags.' + echo + echo 'FLAGS' + echo ' -o, --owner string GitHub organization to scope attestation lookup by' + echo ' -R, --repo string Repository name in the format /' + [[ "\$lacks" == *" --signer-workflow "* ]] || + echo ' --signer-workflow string Workflow that signed attestation in the format [host/]////' + [[ "\$lacks" == *" --source-ref "* ]] || + echo ' --source-ref string Ref associated with the source workflow' + exit 0 +fi [[ "\${1:-} \${2:-}" == "attestation verify" ]] || exit 1 shift 2 file="" repo="" signer="" ref="" while [[ \$# -gt 0 ]]; do + if [[ "\$1" == --* && "\$lacks" == *" \$1 "* ]]; then echo "unknown flag: \$1" >&2; exit 1; fi case "\$1" in --repo) repo="\$2"; shift 2;; --signer-workflow) signer="\$2"; shift 2;; @@ -207,7 +242,7 @@ done OUT="$tmp/out" run() { local scen="$1" path="$2"; shift 2 - : >"$CURL_LOG"; rm -f "$tmp/sudo.log" + : >"$CURL_LOG"; : >"$GH_LOG"; rm -f "$tmp/sudo.log" set +e env -i HOME="$tmp/home" PATH="$path" FAKE_PORT="$FAKE_PORT" SCENARIO="$scen" "$@" \ sh "$INSTALL" >"$OUT" 2>&1 @@ -219,7 +254,7 @@ GH_ONLY="$tmp/ghonly:$TOOLS" show() { sed 's/^/ | /' "$OUT" >&2; } # ---------------------------------------------------------------- fixtures -for s in stable arm64 nosha noverify mismatch bindcosign bindgh trunc dirs badver badlatest; do +for s in stable arm64 nosha noverify mismatch bindcosign bindgh trunc dirs badver badlatest ghcap; do release "$s" v0.3.7 amd64 release "$s" v0.4.0-rc.4 amd64 latest "$s" v0.3.7 @@ -346,12 +381,16 @@ else fi # 7. INST-5: BPFCOMPAT_VERSION and the resolved latest tag must look like a tag. -for v in "../../../../evil/x/releases/download/v1" "v0.3.7/../v0.4.0-rc.4" "latest-ish" "v1.2" "v1.2.3-beta.1" "v1.2.3 "; do +# The whole value is checked: a valid tag on one line of a multi-line +# value must not get through either. +for v in "../../../../evil/x/releases/download/v1" "v0.3.7/../v0.4.0-rc.4" "latest-ish" "v1.2" "v1.2.3-beta.1" "v1.2.3 " \ + $'v0.3.7\nx' $'x\nv0.3.7' $'v0.3.7\n'; do + printf -v shown '%q' "$v" d="$tmp/i-badver" run badver "$WITH_COSIGN" BPFCOMPAT_VERSION="$v" BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_NO_VALIDATOR=1 if [[ $rc -ne 0 && ! -e "$d" && ! -s "$CURL_LOG" ]] && grep -q 'invalid release tag' "$OUT"; then - ok "BPFCOMPAT_VERSION '$v' refused before any download" - else fail "BPFCOMPAT_VERSION '$v' not refused up front (rc=$rc)"; show; fi + ok "BPFCOMPAT_VERSION $shown refused before any download" + else fail "BPFCOMPAT_VERSION $shown not refused up front (rc=$rc)"; show; fi done latest badlatest '../../evil' d="$tmp/i-badlatest" @@ -406,6 +445,40 @@ if [[ "$(id -u)" -ne 0 ]]; then chmod -R u+w "$tmp/ro" fi +# 9. A gh too old for the tag-bound attestation check is refused, naming what +# it lacks and the gh that has it, not reported as a failed verification +# (which reads like tampering) or as a missing gh. It is judged by what its +# help offers, so an unparsable version still gets the precise reason. +# cosign, when present, is preferred and used as before. +# gh_lacking +gh_lacking() { + local lacks="$1" ver="$2" want="$3" d="$tmp/i-ghcap" bad="" + run ghcap "$GH_ONLY" FAKE_GH_LACKS="$lacks" FAKE_GH_VERSION="$ver" \ + BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" + [[ $rc -ne 0 && ! -e "$d" ]] || bad="$bad installed-or-rc0" + grep -qF -- "$want" "$OUT" || bad="$bad reason-not-named" + grep -qF 'upgrade to gh >= 2.68.0 or install cosign - refusing checksum-only installation' "$OUT" || + bad="$bad no-remedy" + ! grep -q 'requires cosign or GitHub CLI' "$OUT" || bad="$bad says-gh-missing" + ! grep -q 'FAILED' "$OUT" || bad="$bad says-verification-failed" + ! grep -qE '^attestation verify [^-]' "$GH_LOG" || bad="$bad verify-attempted" + if [[ -z "$bad" ]]; then ok "gh $ver lacking '$lacks': refused, naming what it lacks" + else fail "gh $ver lacking '$lacks' (rc=$rc):$bad"; show; fi +} +gh_lacking attestation 2.45.0 "installed gh 2.45.0 lacks the 'gh attestation' command, needed for tag-bound attestation verification" +gh_lacking --source-ref 2.67.0 "installed gh 2.67.0 lacks --source-ref in 'gh attestation verify', needed for" +gh_lacking "--signer-workflow --source-ref" 2.50.0 "installed gh 2.50.0 lacks --signer-workflow and --source-ref in 'gh attestation verify'" +gh_lacking --source-ref "custom-build" "installed gh lacks --source-ref in 'gh attestation verify'" +for lacks in attestation --source-ref; do + d="$tmp/i-ghcap-cosign-${lacks#--}" + run ghcap "$WITH_COSIGN" FAKE_GH_LACKS="$lacks" FAKE_GH_VERSION=2.45.0 \ + BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" + if [[ $rc -eq 0 && -x "$d/bin/bpfcompat" && -x "$d/libexec/bpfcompat-validator" ]] && + grep -q 'cosign signature over SHA256SUMS verified' "$OUT"; then + ok "gh lacking '$lacks' + cosign: verified with cosign and installed" + else fail "gh lacking '$lacks' with cosign present (rc=$rc)"; show; fi +done + if [[ $failures -ne 0 ]]; then echo "[install-test] $failures failure(s)" >&2 exit 1 From f8809a9df266cf0df21c54641547afb8e1a792b8 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Sun, 18 Oct 2026 23:58:14 +0300 Subject: [PATCH 2/5] install.sh: report a gh that cannot run; test that every gh verify call is made Review follow-ups for the gh capability probe: - A gh that cannot run at all (every command fails, e.g. an unreadable ~/.config/gh/config.yml) was told it "lacks the 'gh attestation' command" and to upgrade. When `gh attestation --help` fails, the installer now also runs `gh --help`; if that fails too it refuses with "installed gh could not run 'gh --help' (); fix gh or install cosign". Still fail-closed; nothing else changes. - Comment: `gh attestation` exists, hidden, from gh 2.47.0 (public from 2.49.0). Behaviour is unchanged: on 2.47/2.48 the flag probe already names --signer-workflow and --source-ref as missing. install_test.sh: removing or neutralising one gh verify call used to go unnoticed (only the whole gh block and the bundle call were guarded). The gh stub now records every verify call; the gh-path install cases assert that exactly the installed assets (CLI, validator, and the license bundle when listed) were verified, each with the release workflow and --source-ref refs/tags/, and the cosign cases that gh verified nothing. New cases swap only the CLI, or only the validator, for bytes attested by another tag's run (SHA256SUMS regenerated to match), so each call must refuse on its own. Also: a gh that cannot run, with and without cosign, and a root `--help` in the stub as real gh has. Mutation check (scratch copies of install.sh): replacing a verify call with `true`, turning its `die` into a warning, or dropping its --source-ref, for each of the three calls, fails this suite (9/9); the previous suite caught 2/9. Signed-off-by: ErenAri --- scripts/install.sh | 14 ++++-- scripts/install_test.sh | 101 +++++++++++++++++++++++++++++++++------- 2 files changed, 93 insertions(+), 22 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 43b4731..2021b38 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -149,12 +149,18 @@ if have cosign; then die "cosign signature verification FAILED for SHA256SUMS - refusing to install" fi elif have gh; then - # The tag-bound check below needs `gh attestation` (gh 2.49.0), - # --signer-workflow (2.51.0) and --source-ref (2.68.0). An older gh would - # fail it like a forged release would, so ask gh's own help what it offers - # (not its version: distro builds vary) and name what is missing instead. + # The tag-bound check below needs `gh attestation` (gh 2.47.0, hidden until + # 2.49.0), --signer-workflow (2.51.0) and --source-ref (2.68.0). An older gh + # would fail it like a forged release would, so ask gh's own help what it + # offers (not its version: distro builds vary) and name what is missing. gh_lacks="" if ! gh attestation --help >/dev/null 2>&1; then + # A gh that cannot run at all (say, an unreadable config) fails every + # command; say so with gh's own error rather than blame its version. + if ! gh --help >/dev/null 2>&1; then + gh_err="$( { gh --help >/dev/null; } 2>&1 | sed -n '1p')" + die "installed gh could not run 'gh --help'${gh_err:+ ($gh_err)}; fix gh or install cosign - refusing checksum-only installation" + fi gh_lacks="the 'gh attestation' command" else gh_help="$(gh attestation verify --help 2>&1 || true)" diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 6fb1fb3..919828d 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -11,9 +11,12 @@ # the identity baked into SHA256SUMS.crt (the release tag that signed it) # and the signature covers this exact SHA256SUMS; # * gh accepts an attestation only for a binary the fake release pipeline -# attested, and honours --source-ref like the real CLI does; with -# FAKE_GH_LACKS it plays an older gh that has no `attestation` command or -# no --signer-workflow / --source-ref flag, failing like cobra does; +# attested, and honours --source-ref like the real CLI does. It records +# every verify call, so the gh-path cases check that each installed asset +# was verified, bound to the release workflow and tag. With FAKE_GH_LACKS +# it plays an older gh that has no `attestation` command or no +# --signer-workflow / --source-ref flag, failing like cobra does; with +# FAKE_GH_BROKEN=1, a gh that cannot run at all (say, a bad config); # * sudo pretends to be root by unlocking the test's read-only tree. # # Cases: latest -> stable, arch mapping (incl. extra SHA256SUMS lines, the @@ -24,7 +27,8 @@ # SHA256SUMS lists it, verified like the binaries, refused when # corrupt/missing/unattested, absent in old releases), and a gh too old for # the tag-bound check (refused naming what it lacks, never reported as a -# failed verification or a missing gh; cosign still preferred when present). +# failed verification or a missing gh; a gh that cannot run is reported as +# such; cosign still preferred when present). set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -48,6 +52,7 @@ TOOLS="$tmp/tools" ATTEST_DB="$tmp/attestations" CURL_LOG="$tmp/curl.log" GH_LOG="$tmp/gh.log" +GH_VERIFY_LOG="$tmp/gh-verify.log" mkdir -p "$WWW" "$SHIM" "$TOOLS" "$tmp/home" : >"$ATTEST_DB" @@ -140,11 +145,21 @@ EOF cat >"$SHIM/gh" <>"$GH_LOG" +if [[ -n "\${FAKE_GH_BROKEN:-}" ]]; then + echo 'failed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml' >&2 + exit 1 +fi lacks=" \${FAKE_GH_LACKS:-} " +if [[ "\${1:-}" == "--help" ]]; then + echo 'Work seamlessly with GitHub from the command line.' + exit 0 +fi if [[ "\${1:-}" == "--version" ]]; then printf 'gh version %s (2026-01-01)\nhttps://github.com/cli/cli/releases/latest\n' "\${FAKE_GH_VERSION:-2.102.0}" exit 0 @@ -182,6 +197,7 @@ while [[ \$# -gt 0 ]]; do *) file="\$1"; shift;; esac done +printf 'verify %s repo=%s signer=%s ref=%s\n' "\$file" "\$repo" "\$signer" "\$ref" >>"$GH_VERIFY_LOG" [[ "\$repo" == "$REPO" ]] || exit 1 [[ "\$signer" == "$REPO/.github/workflows/release-artifacts.yml" ]] || exit 1 sum="\$("$REAL_SHA" <"\$file" | cut -d' ' -f1)" @@ -242,7 +258,7 @@ done OUT="$tmp/out" run() { local scen="$1" path="$2"; shift 2 - : >"$CURL_LOG"; : >"$GH_LOG"; rm -f "$tmp/sudo.log" + : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; rm -f "$tmp/sudo.log" set +e env -i HOME="$tmp/home" PATH="$path" FAKE_PORT="$FAKE_PORT" SCENARIO="$scen" "$@" \ sh "$INSTALL" >"$OUT" 2>&1 @@ -252,9 +268,27 @@ run() { WITH_COSIGN="$SHIM:$TOOLS" GH_ONLY="$tmp/ghonly:$TOOLS" show() { sed 's/^/ | /' "$OUT" >&2; } +# verified : on the gh-only PATH, gh verified exactly +# these assets, once each, bound to the release workflow and refs/tags/; +# with cosign on PATH, gh verified nothing. So a dropped, unbound or extra +# verify call fails the case. +verified() { + local path="$1" tag="$2" a want=""; shift 2 + if [[ "$path" == "$GH_ONLY" ]]; then + for a in "$@"; do + want+="verify $a repo=$REPO signer=$REPO/.github/workflows/release-artifacts.yml ref=refs/tags/$tag"$'\n' + done + fi + [[ "$(sort "$GH_VERIFY_LOG")" == "$(printf '%s' "$want" | sort)" ]] && return 0 + printf '%s' "$want" | sort | sed 's/^/ want gh | /' >&2 + sort "$GH_VERIFY_LOG" | sed 's/^/ got gh | /' >&2 + return 1 +} +CLI_AMD64=bpfcompat-linux-amd64 +VAL_AMD64=bpfcompat-validator-static-linux-amd64 # ---------------------------------------------------------------- fixtures -for s in stable arm64 nosha noverify mismatch bindcosign bindgh trunc dirs badver badlatest ghcap; do +for s in stable arm64 nosha noverify mismatch bindcosign bindgh bindghcli bindghval trunc dirs badver badlatest ghcap; do release "$s" v0.3.7 amd64 release "$s" v0.4.0-rc.4 amd64 latest "$s" v0.3.7 @@ -274,7 +308,8 @@ for path in "$WITH_COSIGN" "$GH_ONLY"; do run stable "$path" BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.3.7 amd64' "$OUT" && grep -qx 'validator v0.3.7 amd64' "$d/libexec/bpfcompat-validator" && - [[ ! -e "$d/share" ]] && grep -q 'publishes no third-party license bundle' "$OUT"; then + [[ ! -e "$d/share" ]] && grep -q 'publishes no third-party license bundle' "$OUT" && + verified "$path" v0.3.7 "$CLI_AMD64" "$VAL_AMD64"; then ok "latest -> v0.3.7 (PATH=${path%%:*}), no license bundle in that release" else fail "latest did not install v0.3.7 (rc=$rc)"; show; fi done @@ -283,7 +318,8 @@ done for path in "$WITH_COSIGN" "$GH_ONLY"; do d="$tmp/i-rc-${#path}" run stable "$path" BPFCOMPAT_VERSION=v0.4.0-rc.4 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" - if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.4.0-rc.4 amd64' "$OUT"; then ok "pinned v0.4.0-rc.4" + if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.4.0-rc.4 amd64' "$OUT" && + verified "$path" v0.4.0-rc.4 "$CLI_AMD64" "$VAL_AMD64"; then ok "pinned v0.4.0-rc.4" else fail "pinned prerelease did not install (rc=$rc)"; show; fi done @@ -295,7 +331,8 @@ if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.3.7 arm64' "$OUT" && [[ ! -e "$ else fail "aarch64 mapping (rc=$rc)"; show; fi d="$tmp/i-amd64" run arm64 "$GH_ONLY" FAKE_ARCH=amd64 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" -if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.3.7 amd64' "$OUT" && [[ -x "$d/libexec/bpfcompat-validator" ]]; then +if [[ $rc -eq 0 ]] && grep -q 'fake bpfcompat v0.3.7 amd64' "$OUT" && [[ -x "$d/libexec/bpfcompat-validator" ]] && + verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64"; then ok "amd64 with extra SHA256SUMS entries" else fail "amd64 with extra SHA256SUMS entries (rc=$rc)"; show; fi d="$tmp/i-mips" @@ -332,6 +369,21 @@ run bindgh "$GH_ONLY" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOM if [[ $rc -ne 0 && ! -e "$d" ]] && grep -q 'attestation verification FAILED' "$OUT"; then ok "gh: rc.4 attestation refused for v0.3.7" else fail "gh accepted a binary attested for another tag (rc=$rc)"; show; fi +# 4b. Each gh verify call stands on its own: swap only the CLI, or only the +# validator, for rc.4's attested bytes and regenerate SHA256SUMS to match, +# so the attestation is the only thing that can catch it. Dropping or +# neutralising that asset's verify call makes this install succeed. (The +# license bundle's call is covered by the unattested-bundle case in 8.) +for c in "bindghcli $CLI_AMD64" "bindghval $VAL_AMD64"; do + scen="${c%% *}" asset="${c#* }" + cp "$(rel_dir "$scen" v0.4.0-rc.4)/$asset" "$(rel_dir "$scen" v0.3.7)/$asset" + (cd "$(rel_dir "$scen" v0.3.7)" && "$REAL_SHA" bpfcompat-* >SHA256SUMS) + d="$tmp/i-$scen" + run "$scen" "$GH_ONLY" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" + if [[ $rc -ne 0 && ! -e "$d" ]] && grep -q "attestation verification FAILED for $asset\$" "$OUT"; then + ok "gh: only $asset attested for rc.4 -> refused for v0.3.7" + else fail "gh accepted $asset attested for another tag (rc=$rc)"; show; fi +done # 5. INST-3: a truncated `curl | sh` stream must not run any part of the install. total="$(wc -l <"$INSTALL")" @@ -406,7 +458,8 @@ for path in "$WITH_COSIGN" "$GH_ONLY"; do run lic "$path" BPFCOMPAT_VERSION=v0.4.0-rc.5 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" if [[ $rc -eq 0 ]] && grep -q '"version": "v0.4.0-rc.5"' "$d/share/doc/bpfcompat/v0.4.0-rc.5/INVENTORY.json" && [[ -s "$d/share/doc/bpfcompat/v0.4.0-rc.5/go/toolchain/LICENSE" ]] && - grep -q 'bpfcompat-third-party-licenses.tar.gz' "$OUT"; then + grep -q 'bpfcompat-third-party-licenses.tar.gz' "$OUT" && + verified "$path" v0.4.0-rc.5 "$CLI_AMD64" "$VAL_AMD64" bpfcompat-third-party-licenses.tar.gz; then ok "license bundle verified and installed next to the CLI (PATH=${path%%:*})" else fail "license bundle install (rc=$rc)"; show; fi done @@ -469,14 +522,26 @@ gh_lacking attestation 2.45.0 "installed gh 2.45.0 lacks the 'gh attestation' co gh_lacking --source-ref 2.67.0 "installed gh 2.67.0 lacks --source-ref in 'gh attestation verify', needed for" gh_lacking "--signer-workflow --source-ref" 2.50.0 "installed gh 2.50.0 lacks --signer-workflow and --source-ref in 'gh attestation verify'" gh_lacking --source-ref "custom-build" "installed gh lacks --source-ref in 'gh attestation verify'" -for lacks in attestation --source-ref; do - d="$tmp/i-ghcap-cosign-${lacks#--}" - run ghcap "$WITH_COSIGN" FAKE_GH_LACKS="$lacks" FAKE_GH_VERSION=2.45.0 \ +# A gh that cannot run at all (every command fails, as with an unreadable +# config) is reported as such, with gh's own error, not as lacking a command. +d="$tmp/i-ghbroken" bad="" +run ghcap "$GH_ONLY" FAKE_GH_BROKEN=1 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +[[ $rc -ne 0 && ! -e "$d" ]] || bad="$bad installed-or-rc0" +grep -qF "installed gh could not run 'gh --help' (failed to create root command: failed to read configuration: invalid config file" "$OUT" || + bad="$bad reason-not-named" +grep -qF 'fix gh or install cosign - refusing checksum-only installation' "$OUT" || bad="$bad no-remedy" +! grep -qE 'lacks|FAILED|requires cosign or GitHub CLI' "$OUT" || bad="$bad wrong-reason" +! grep -qE '^attestation verify [^-]' "$GH_LOG" || bad="$bad verify-attempted" +if [[ -z "$bad" ]]; then ok "gh that cannot run: refused, naming gh's own error" +else fail "gh that cannot run (rc=$rc):$bad"; show; fi +for ghenv in FAKE_GH_LACKS=attestation FAKE_GH_LACKS=--source-ref FAKE_GH_BROKEN=1; do + d="$tmp/i-ghcap-cosign-${ghenv//[^A-Za-z]/}" + run ghcap "$WITH_COSIGN" "$ghenv" FAKE_GH_VERSION=2.45.0 \ BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" if [[ $rc -eq 0 && -x "$d/bin/bpfcompat" && -x "$d/libexec/bpfcompat-validator" ]] && grep -q 'cosign signature over SHA256SUMS verified' "$OUT"; then - ok "gh lacking '$lacks' + cosign: verified with cosign and installed" - else fail "gh lacking '$lacks' with cosign present (rc=$rc)"; show; fi + ok "gh with $ghenv + cosign: verified with cosign and installed" + else fail "gh with $ghenv and cosign present (rc=$rc)"; show; fi done if [[ $failures -ne 0 ]]; then From 723b5e2b4a04ab740978044c8b133894c13e65ca Mon Sep 17 00:00:00 2001 From: ErenAri Date: Mon, 19 Oct 2026 08:40:21 +0300 Subject: [PATCH 3/5] install.sh: report gh auth and API failures apart from a refused verification On the gh path every `gh attestation verify` failure was reported as "GitHub attestation verification FAILED for ", with gh's output sent to /dev/null. A capable gh that is not logged in, holds a rejected token, or cannot reach the GitHub API got the same tampering-like words as a release whose attestation does not match (review S2). The installer now keeps four outcomes apart, and every one still refuses (exit 1, nothing installed, temp dir removed, no checksum-only fallback, no retry): 1. gh lacks the attestation command or flags: the existing help probe, "installed gh lacks ...", unchanged. 2. No usable credentials: "GitHub authentication needed for attestation verification: gh is not logged in ()", or "GitHub rejected gh's credentials (HTTP 401)". 3. GitHub API out of reach: "GitHub API unavailable for attestation verification: no HTTP response | HTTP 5xx | HTTP 429, rate limited | HTTP 403, rate limited ()". 4. Anything else, including no attestation for the digest (404), another signer workflow or source ref, or a digest mismatch: the existing "GitHub attestation verification FAILED for ", unchanged. Classes 2 and 3 rest only on positive evidence from one read-only request made before the first verify call: `gh api --method GET --include repos//attestations/sha256:?per_page=1`, the endpoint that call queries, through the same gh, host, token and proxy. gh exits 4 only from its root auth check (internal/ghcmd/cmd.go, pkg/cmd/root/root.go at v2.68.0 and v2.102.0), which also guards `attestation verify` (only --bundle lifts it). With --include the first stdout line is the status line gh builds from the response, and a transport failure exits 1 with empty stdout. A 403 counts only with GitHub's X-Ratelimit-Remaining: 0 or Retry-After header. Any other answer leaves the decision to the verify calls, unchanged (same assets, --repo, --signer-workflow, --source-ref refs/tags/), whose failures other than exit code 4 always read "verification FAILED". gh's verify text is never parsed: with stdout not a terminal it prints only its final error, worded differently by 2.68 and 2.102 for the same 401 or 404, and on 2.102 a dead proxy fails before the API fetch with a Sigstore message. Probing before verifying, not after a failure, means a refusal can never be relabelled as an auth or network problem when the verify itself spends a nearly exhausted rate limit or a token expires in between. Cost: one extra GET on the gh path (about 240 KB for v0.3.7's CLI, next to about 22 MB of downloads). The cosign path is unchanged and still runs no gh command. gh's words are quoted as one line with control characters removed, also in the "could not run 'gh --help'" refusal (review R2-N2), and the gh_err and gh_ver captures tolerate an imported pipefail (R2-N1). install_test.sh: the gh stub answers the probe from the attestation DB and can play a gh that is not logged in (exit 4), holds a rejected token (401), gets no response, a 503, a 429, a rate-limited 403 (also with coloured headers) or a plain 403, a verify that exits 4, and a refusing verify whose stderr claims a network or auth error. 35 new cases check the class named, the absence of the other classes' wording, nothing installed, the installer's temp dir removed, no control bytes, exactly one probe made before any verify call, the cosign path untouched in every mode, and imported pipefail. The 45 existing cases pass unchanged; against f8809a9's install.sh the new suite fails 24 cases. All 34 mutants of the change are caught (a refusal reworded as network or auth, a die removed on the auth or network path, a 404 taken for an outage, the probe ignored or removed, S1 regressions on the new calls). Real gh 2.68.0 and 2.102.0 (official tarballs) with the real v0.3.7 assets: no token and an invalid token give class 2, a dead HTTPS proxy class 3, a modified CLI or rc.4's assets served as v0.3.7 class 4, and the real release installs. f8809a9 said "FAILED" for the first three. Signed-off-by: ErenAri --- scripts/install.sh | 71 +++++++++-- scripts/install_test.sh | 273 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 319 insertions(+), 25 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 2021b38..041f27a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -35,6 +35,23 @@ ALT_LIBEXEC_DIR="/usr/libexec/bpfcompat" err() { printf 'bpfcompat-install: %s\n' "$*" >&2; } die() { err "$*"; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } +# first_line FILE: its first non-blank line, without control characters, to +# quote gh's own words in a refusal (never more, never terminal escapes). +first_line() { sed -n '/[^[:space:]]/{p;q;}' "$1" | tr -d '[:cntrl:]'; } +# gh_no_auth REASON FILE: gh cannot authenticate to GitHub; quote FILE's +# first line (gh's own words) and refuse. +gh_no_auth() { + gh_err="$(first_line "$2")" || : + die "GitHub authentication needed for attestation verification: $1${gh_err:+ ($gh_err)}; run 'gh auth login' or set a valid GH_TOKEN, or install cosign - refusing checksum-only installation" +} +# gh_refused ASSET STATUS: a `gh attestation verify` call failed. gh exits +# 4 only when it has no credentials at all (its root check, which runs +# before the asset is read), so that is no refusal; any other failure is +# reported as a failed verification, whatever gh printed. +gh_refused() { + [ "$2" != 4 ] || gh_no_auth "gh is not logged in" .gh-verify.out + die "GitHub attestation verification FAILED for $1" +} # needs_root DIR: true when DIR (or, if it does not exist yet, its nearest # existing ancestor, which `install -d` would create it under) is not writable. @@ -55,9 +72,10 @@ DOC_DIR="${BPFCOMPAT_DOC_DIR:-$(dirname "$BIN_DIR")/share/doc/bpfcompat}" LICENSE_ASSET="bpfcompat-third-party-licenses.tar.gz" have curl || die "curl is required" -if have sha256sum; then SHACHK="sha256sum -c"; elif have shasum; then SHACHK="shasum -a 256 -c"; else +if have sha256sum; then SHA256="sha256sum"; elif have shasum; then SHA256="shasum -a 256"; else die "need sha256sum or shasum to verify downloads" fi +SHACHK="$SHA256 -c" os="$(uname -s)"; arch="$(uname -m)" [ "$os" = "Linux" ] || die "unsupported OS '$os' (only Linux is published today)" @@ -158,7 +176,7 @@ elif have gh; then # A gh that cannot run at all (say, an unreadable config) fails every # command; say so with gh's own error rather than blame its version. if ! gh --help >/dev/null 2>&1; then - gh_err="$( { gh --help >/dev/null; } 2>&1 | sed -n '1p')" + gh_err="$( { gh --help >/dev/null; } 2>&1 | sed -n '1p' | tr -d '[:cntrl:]')" || : die "installed gh could not run 'gh --help'${gh_err:+ ($gh_err)}; fix gh or install cosign - refusing checksum-only installation" fi gh_lacks="the 'gh attestation' command" @@ -171,25 +189,60 @@ elif have gh; then [ -z "$gh_lacks" ] || gh_lacks="$gh_lacks in 'gh attestation verify'" fi if [ -n "$gh_lacks" ]; then - gh_ver="$(gh --version 2>/dev/null | sed -n '1s/^gh version \([0-9][0-9.]*\).*/\1/p')" + gh_ver="$(gh --version 2>/dev/null | sed -n '1s/^gh version \([0-9][0-9.]*\).*/\1/p')" || : die "installed gh${gh_ver:+ $gh_ver} lacks $gh_lacks, needed for tag-bound attestation verification; upgrade to gh >= 2.68.0 or install cosign - refusing checksum-only installation" fi err "cosign not found; verifying GitHub build attestations" + # A capable gh can still be unable to check anything: not logged in, its + # token rejected, or the GitHub API out of reach. Tell those apart from a + # refused verification without trusting gh's wording, which changes + # between releases and is all gh prints when stdout is not a terminal: + # before the first verify call, make one read-only request to the + # attestation API that call uses (same gh, host, token and proxy), and + # refuse here only on positive evidence - gh's documented auth exit code + # (4), an HTTP 401, no HTTP response at all, a 5xx, a 429 or a + # rate-limited 403. Any other answer (200, 404 = no attestation for this + # digest, ...) leaves the decision to the verify calls, whose failures are + # always reported as a failed verification. Probing first, not after a + # failure, means a refusal can never be relabelled as an auth or network + # problem, however gh words it or whatever the API does next. + gh_digest="$($SHA256 <"$CLI_ASSET" | sed -n '1s/^\([0-9a-fA-F]\{64\}\).*/\1/p')" || : + gh_rc=0 + gh api --method GET --include "repos/$REPO/attestations/sha256:$gh_digest?per_page=1" \ + >.gh-api.out 2>.gh-api.err || gh_rc=$? + gh_status="$(tr -d '\r' <.gh-api.out | sed -n '1s/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p')" || : + gh_down="" + case "$gh_rc:$gh_status" in + 4:) gh_no_auth "gh is not logged in" .gh-api.err ;; + *:401) gh_no_auth "GitHub rejected gh's credentials (HTTP 401)" .gh-api.err ;; + *:5[0-9][0-9]) gh_down="HTTP $gh_status" ;; + *:429) gh_down="HTTP 429, rate limited" ;; + *:403) + # A 403 is an outage only with GitHub's own rate-limit headers. + gh_rl="$(tr -d '\r\033' <.gh-api.out | sed -n '2,/^$/{s/\[[0-9;]*m//g;p;}' | + grep -ciE '^(x-ratelimit-remaining: *0|retry-after: *[0-9]+)$')" || : + [ "${gh_rl:-0}" = 0 ] || gh_down="HTTP 403, rate limited" ;; + 1:) [ -s .gh-api.out ] || gh_down="no HTTP response" ;; + esac + if [ -n "$gh_down" ]; then + gh_err="$(first_line .gh-api.err)" || : + die "GitHub API unavailable for attestation verification: $gh_down${gh_err:+ ($gh_err)}; check the network or proxy and https://www.githubstatus.com, then retry, or install cosign - refusing checksum-only installation" + fi GH_REPO="$REPO" gh attestation verify "$CLI_ASSET" --repo "$REPO" \ --signer-workflow "$REPO/.github/workflows/release-artifacts.yml" \ - --source-ref "refs/tags/$VERSION" >/dev/null 2>&1 || - die "GitHub attestation verification FAILED for $CLI_ASSET" + --source-ref "refs/tags/$VERSION" >.gh-verify.out 2>&1 || + gh_refused "$CLI_ASSET" "$?" if [ "$install_validator" = "1" ]; then GH_REPO="$REPO" gh attestation verify "$VALIDATOR_ASSET" --repo "$REPO" \ --signer-workflow "$REPO/.github/workflows/release-artifacts.yml" \ - --source-ref "refs/tags/$VERSION" >/dev/null 2>&1 || - die "GitHub attestation verification FAILED for $VALIDATOR_ASSET" + --source-ref "refs/tags/$VERSION" >.gh-verify.out 2>&1 || + gh_refused "$VALIDATOR_ASSET" "$?" fi if [ "$install_licenses" = "1" ]; then GH_REPO="$REPO" gh attestation verify "$LICENSE_ASSET" --repo "$REPO" \ --signer-workflow "$REPO/.github/workflows/release-artifacts.yml" \ - --source-ref "refs/tags/$VERSION" >/dev/null 2>&1 || - die "GitHub attestation verification FAILED for $LICENSE_ASSET" + --source-ref "refs/tags/$VERSION" >.gh-verify.out 2>&1 || + gh_refused "$LICENSE_ASSET" "$?" fi err "GitHub build attestations verified" else diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 919828d..c7b5f14 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -16,7 +16,11 @@ # was verified, bound to the release workflow and tag. With FAKE_GH_LACKS # it plays an older gh that has no `attestation` command or no # --signer-workflow / --source-ref flag, failing like cobra does; with -# FAKE_GH_BROKEN=1, a gh that cannot run at all (say, a bad config); +# FAKE_GH_BROKEN=1, a gh that cannot run at all (say, a bad config). It +# also answers the installer's read-only API probe (`gh api --include`) +# from the attestation DB and records it, and can play a capable gh that +# is not logged in, holds a rejected token, cannot reach the API or gets +# a 5xx / 429 / rate-limited 403, modelled on real gh 2.68.0 / 2.102.0; # * sudo pretends to be root by unlocking the test's read-only tree. # # Cases: latest -> stable, arch mapping (incl. extra SHA256SUMS lines, the @@ -25,10 +29,15 @@ # `curl | sh` streams, custom install dirs, tag validation (the whole value, # embedded newlines included), the third-party license bundle (installed when # SHA256SUMS lists it, verified like the binaries, refused when -# corrupt/missing/unattested, absent in old releases), and a gh too old for +# corrupt/missing/unattested, absent in old releases), a gh too old for # the tag-bound check (refused naming what it lacks, never reported as a # failed verification or a missing gh; a gh that cannot run is reported as -# such; cosign still preferred when present). +# such; cosign still preferred when present), and a capable gh that cannot +# complete the check (not logged in / credentials rejected / GitHub API +# unavailable, each refused under its own name, never as a failed +# verification, while every refusal - including one whose stderr claims a +# network or auth problem - still reads "verification FAILED"), with gh's +# words stripped of control bytes and imported pipefail tolerated. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -53,6 +62,8 @@ ATTEST_DB="$tmp/attestations" CURL_LOG="$tmp/curl.log" GH_LOG="$tmp/gh.log" GH_VERIFY_LOG="$tmp/gh-verify.log" +GH_API_LOG="$tmp/gh-api.log" +ITMP="$tmp/itmp" mkdir -p "$WWW" "$SHIM" "$TOOLS" "$tmp/home" : >"$ATTEST_DB" @@ -144,15 +155,33 @@ exit 0 EOF cat >"$SHIM/gh" <), answered from the same DB: 200 when the digest is +# attested, 404 when it is not, as the real API does. # FAKE_GH_LACKS plays an older gh (feature history from gh's source): # "attestation" = gh < 2.47.0 (hidden in 2.47-2.48, public from 2.49.0), # "--signer-workflow" = gh < 2.51.0, "--source-ref" = gh < 2.68.0. -# FAKE_GH_VERSION is what --version reports. FAKE_GH_BROKEN=1 fails every -# command the way gh does with an unreadable config. +# FAKE_GH_VERSION is what --version reports ("fail": --version fails). +# FAKE_GH_BROKEN=1 fails every command the way gh does with an unreadable +# config. A capable gh that cannot finish the check, modelled on real gh +# 2.68.0/2.102.0 runs (exit codes, --include output; help and --version +# are exempt, like gh's root auth check): +# FAKE_GH_AUTH=none no credentials: gh's login hint on stderr, exit 4 +# FAKE_GH_AUTH=bad token rejected: HTTP 401 +# FAKE_GH_NET=down no HTTP response (proxy/DNS/connect failure), exit 1 +# FAKE_GH_NET=503|429|403|403rl|403ra that HTTP status; 403rl carries +# X-Ratelimit-Remaining: 0, 403ra a Retry-After header +# FAKE_GH_COLOR=1 header names coloured, as with CLICOLOR_FORCE +# FAKE_GH_ERR_CTRL=1 terminal control bytes in gh's error lines +# FAKE_GH_VERIFY_RC=4 verify exits 4 even though the API probe answered +# FAKE_GH_VERIFY_ERR text a failing verify prints instead of its error +# FAKE_GH_ATTEST_SIGNER workflow the stored attestations were signed by echo "\$*" >>"$GH_LOG" +ctl="" cr="" +if [[ -n "\${FAKE_GH_ERR_CTRL:-}" ]]; then ctl=\$'\e]0;PWNED\a\e[31m' cr=\$'\r'; fi if [[ -n "\${FAKE_GH_BROKEN:-}" ]]; then - echo 'failed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml' >&2 + echo "\${ctl}failed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml\$cr" >&2 exit 1 fi lacks=" \${FAKE_GH_LACKS:-} " @@ -161,9 +190,66 @@ if [[ "\${1:-}" == "--help" ]]; then exit 0 fi if [[ "\${1:-}" == "--version" ]]; then + [[ "\${FAKE_GH_VERSION:-}" != fail ]] || exit 1 printf 'gh version %s (2026-01-01)\nhttps://github.com/cli/cli/releases/latest\n' "\${FAKE_GH_VERSION:-2.102.0}" exit 0 fi +login_hint() { + printf 'To get started with GitHub CLI, please run: gh auth login\nAlternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.\n' >&2 + exit 4 +} +# hdrs [header...]: what gh api --include prints before the body. +hdrs() { + local h c="" r="" + if [[ -n "\${FAKE_GH_COLOR:-}" ]]; then c=\$'\e[1;34m' r=\$'\e[m'; fi + printf '%s\n' "\$1"; shift + for h in "Content-Type: application/json; charset=utf-8" "\$@"; do + printf '%s%s%s:%s\r\n' "\$c" "\${h%%:*}" "\$r" "\${h#*:}" + done + printf '\r\n' +} +if [[ "\${1:-}" == api ]]; then + [[ \$# -eq 5 && "\$2 \$3 \$4" == "--method GET --include" ]] || { echo "gh shim: unexpected api call: \$*" >&2; exit 2; } + path="\$5" st="" + if [[ "\${FAKE_GH_AUTH:-}" == none ]]; then echo "\$path noauth" >>"$GH_API_LOG"; login_hint; fi + if [[ "\${FAKE_GH_AUTH:-}" == bad ]]; then st=401 + else + case "\${FAKE_GH_NET:-}" in + down) + echo "\$path -" >>"$GH_API_LOG" + echo "\${ctl}Get \"https://api.github.com/\$path\": proxyconnect tcp: dial tcp 127.0.0.1:9: connect: connection refused\$cr" >&2 + exit 1;; + 503|429|403|403rl|403ra) st="\$FAKE_GH_NET";; + "") + d="\${path#repos/$REPO/attestations/sha256:}"; d="\${d%%\?*}" + if [[ "\$path" == "repos/$REPO/attestations/sha256:\$d?per_page=1" && "\$d" =~ ^[0-9a-f]{64}\$ ]] && + grep -q "^\$d " "$ATTEST_DB"; then st=200; else st=404; fi;; + *) echo "gh shim: unknown FAKE_GH_NET" >&2; exit 2;; + esac + fi + echo "\$path \$st" >>"$GH_API_LOG" + case "\$st" in + 200) hdrs "HTTP/2.0 200 OK" "X-Ratelimit-Remaining: 4999"; echo '{"attestations":[{"bundle":{}}]}'; exit 0;; + 404) hdrs "HTTP/2.0 404 Not Found" "X-Ratelimit-Remaining: 4998" + echo '{"message":"Not Found","status":"404"}'; echo "gh: Not Found (HTTP 404)" >&2;; + 401) hdrs "HTTP/2.0 401 Unauthorized" + echo '{"message":"Bad credentials","status":"401"}'; echo "gh: Bad credentials (HTTP 401)" >&2;; + 503) hdrs "HTTP/2.0 503 Service Unavailable"; echo 'unicorn'; echo "\${ctl}gh: HTTP 503\$cr" >&2;; + 429) hdrs "HTTP/2.0 429 Too Many Requests" "Retry-After: 60" + echo '{"message":"You have exceeded a secondary rate limit."}' + echo "gh: You have exceeded a secondary rate limit. (HTTP 429)" >&2;; + 403rl) hdrs "HTTP/2.0 403 Forbidden" "X-Ratelimit-Remaining: 0" "X-Ratelimit-Reset: 1760000000" + echo '{"message":"API rate limit exceeded for user ID 1."}' + echo "gh: API rate limit exceeded for user ID 1. (HTTP 403)" >&2;; + 403ra) hdrs "HTTP/2.0 403 Forbidden" "Retry-After: 60" "X-Ratelimit-Remaining: 4000" + echo '{"message":"You have exceeded a secondary rate limit."}' + echo "gh: You have exceeded a secondary rate limit. (HTTP 403)" >&2;; + 403) hdrs "HTTP/2.0 403 Forbidden" "X-Ratelimit-Remaining: 4000" + echo '{"message":"Resource not accessible by personal access token"}' + echo "gh: Resource not accessible by personal access token (HTTP 403)" >&2;; + esac + exit 1 +fi if [[ "\${1:-}" == attestation && "\$lacks" == *" attestation "* ]]; then printf 'unknown command "attestation" for "gh"\n\nUsage: gh [flags]\n' >&2 exit 1 @@ -198,11 +284,27 @@ while [[ \$# -gt 0 ]]; do esac done printf 'verify %s repo=%s signer=%s ref=%s\n' "\$file" "\$repo" "\$signer" "\$ref" >>"$GH_VERIFY_LOG" -[[ "\$repo" == "$REPO" ]] || exit 1 -[[ "\$signer" == "$REPO/.github/workflows/release-artifacts.yml" ]] || exit 1 +# refuse : how gh reports a failed verify when stdout is not a TTY. +refuse() { + if [[ -n "\${FAKE_GH_VERIFY_ERR:-}" ]]; then printf '%s\n' "\$FAKE_GH_VERIFY_ERR" >&2 + else printf '\nError: %s\n' "\$1" >&2; fi + exit 1 +} +[[ "\${FAKE_GH_AUTH:-}" != none && "\${FAKE_GH_VERIFY_RC:-}" != 4 ]] || login_hint +url="https://api.github.com/repos/\$repo/attestations/sha256:...?per_page=30" +[[ "\${FAKE_GH_AUTH:-}" != bad ]] || refuse "failed to fetch attestations from \$repo: HTTP 401: Bad credentials (\$url)" +case "\${FAKE_GH_NET:-}" in + "") ;; + down) refuse "failed to fetch attestations from \$repo: Get \"\$url\": proxyconnect tcp: dial tcp 127.0.0.1:9: connect: connection refused";; + *) refuse "failed to fetch attestations from \$repo: HTTP \${FAKE_GH_NET:0:3} (\$url)";; +esac +[[ "\$repo" == "$REPO" ]] || refuse "no attestations found" +[[ "\$signer" == "$REPO/.github/workflows/release-artifacts.yml" && + "\$signer" == "\${FAKE_GH_ATTEST_SIGNER:-$REPO/.github/workflows/release-artifacts.yml}" ]] || + refuse "expected SAN to match signer workflow \$signer" sum="\$("$REAL_SHA" <"\$file" | cut -d' ' -f1)" -if [[ -n "\$ref" ]]; then grep -qx "\$sum \$ref" "$ATTEST_DB" -else grep -q "^\$sum " "$ATTEST_DB"; fi +if [[ -n "\$ref" ]]; then grep -qx "\$sum \$ref" "$ATTEST_DB" || refuse "expected SourceRepositoryRef to be \$ref" +else grep -q "^\$sum " "$ATTEST_DB" || refuse "no attestations found"; fi EOF cat >"$SHIM/uname" <<'EOF' #!/bin/sh @@ -254,14 +356,18 @@ for _ in $(seq 100); do done [[ -n "$FAKE_PORT" ]] || { echo "[install-test] fake server did not start" >&2; exit 1; } -# run [VAR=value...] : run install.sh from a file. +# run [VAR=value...] : run install.sh from a file, with +# "${RUN_SH[@]}" (default: sh) and TMPDIR pointing at an empty $ITMP, so a +# case can check that the installer removed its temp dir. OUT="$tmp/out" +RUN_SH=(sh) run() { local scen="$1" path="$2"; shift 2 - : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; rm -f "$tmp/sudo.log" + : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; : >"$GH_API_LOG"; rm -f "$tmp/sudo.log" + rm -rf "$ITMP"; mkdir -p "$ITMP" set +e - env -i HOME="$tmp/home" PATH="$path" FAKE_PORT="$FAKE_PORT" SCENARIO="$scen" "$@" \ - sh "$INSTALL" >"$OUT" 2>&1 + env -i HOME="$tmp/home" TMPDIR="$ITMP" PATH="$path" FAKE_PORT="$FAKE_PORT" SCENARIO="$scen" "$@" \ + "${RUN_SH[@]}" "$INSTALL" >"$OUT" 2>&1 rc=$? set -e } @@ -288,7 +394,8 @@ CLI_AMD64=bpfcompat-linux-amd64 VAL_AMD64=bpfcompat-validator-static-linux-amd64 # ---------------------------------------------------------------- fixtures -for s in stable arm64 nosha noverify mismatch bindcosign bindgh bindghcli bindghval trunc dirs badver badlatest ghcap; do +for s in stable arm64 nosha noverify mismatch bindcosign bindgh bindghcli bindghval trunc dirs badver badlatest ghcap \ + ghapi ghrefuse ghnoatt; do release "$s" v0.3.7 amd64 release "$s" v0.4.0-rc.4 amd64 latest "$s" v0.3.7 @@ -515,6 +622,7 @@ gh_lacking() { ! grep -q 'requires cosign or GitHub CLI' "$OUT" || bad="$bad says-gh-missing" ! grep -q 'FAILED' "$OUT" || bad="$bad says-verification-failed" ! grep -qE '^attestation verify [^-]' "$GH_LOG" || bad="$bad verify-attempted" + ! grep -q '^api ' "$GH_LOG" || bad="$bad api-probed" if [[ -z "$bad" ]]; then ok "gh $ver lacking '$lacks': refused, naming what it lacks" else fail "gh $ver lacking '$lacks' (rc=$rc):$bad"; show; fi } @@ -532,6 +640,7 @@ grep -qF "installed gh could not run 'gh --help' (failed to create root command: grep -qF 'fix gh or install cosign - refusing checksum-only installation' "$OUT" || bad="$bad no-remedy" ! grep -qE 'lacks|FAILED|requires cosign or GitHub CLI' "$OUT" || bad="$bad wrong-reason" ! grep -qE '^attestation verify [^-]' "$GH_LOG" || bad="$bad verify-attempted" +! grep -q '^api ' "$GH_LOG" || bad="$bad api-probed" if [[ -z "$bad" ]]; then ok "gh that cannot run: refused, naming gh's own error" else fail "gh that cannot run (rc=$rc):$bad"; show; fi for ghenv in FAKE_GH_LACKS=attestation FAKE_GH_LACKS=--source-ref FAKE_GH_BROKEN=1; do @@ -544,6 +653,138 @@ for ghenv in FAKE_GH_LACKS=attestation FAKE_GH_LACKS=--source-ref FAKE_GH_BROKEN else fail "gh with $ghenv and cosign present (rc=$rc)"; show; fi done +# 10. S2: a capable gh that cannot complete the check is told apart from a +# refused verification, and every outcome refuses. Before the first +# verify call, one read-only probe of the attestation API the verify +# uses decides "not authenticated" (gh's auth exit code 4, or HTTP 401) +# and "GitHub API unavailable" (no HTTP response, 5xx, 429, rate-limited +# 403); any other answer hands over to the verify calls, whose failures +# (other than exit code 4) always read "verification FAILED", whatever +# gh prints. Each case: nonzero exit, nothing installed, the installer's +# temp dir gone, only its own class named, no terminal control bytes. +C2='GitHub authentication needed for attestation verification' +C3='GitHub API unavailable for attestation verification' +C4='GitHub attestation verification FAILED for' +# ghrefuse: the v0.3.7 CLI is rc.4's attested binary (attested, other tag). +# ghnoatt: the v0.3.7 CLI has no attestation at all. SHA256SUMS matches both. +cp "$(rel_dir ghrefuse v0.4.0-rc.4)/$CLI_AMD64" "$(rel_dir ghrefuse v0.3.7)/$CLI_AMD64" +printf '#!/bin/sh\necho "unattested build"\n' >"$(rel_dir ghnoatt v0.3.7)/$CLI_AMD64" +for s in ghrefuse ghnoatt; do (cd "$(rel_dir "$s" v0.3.7)" && "$REAL_SHA" bpfcompat-* >SHA256SUMS); done +# probe_path : the probe the installer must make for v0.3.7's CLI. +probe_path() { + echo "repos/$REPO/attestations/sha256:$("$REAL_SHA" <"$(rel_dir "$1" v0.3.7)/$CLI_AMD64" | cut -d' ' -f1)?per_page=1" +} +itmp_clean() { [[ -d "$ITMP" && -z "$(ls -A "$ITMP")" ]]; } +no_ctl() { [[ -z "$(LC_ALL=C tr -d '\n[:print:]' <"$OUT")" ]]; } +# gh_class <scenario> <class 2|3|4> <text> <verify calls> <probe status> [VAR=value...] +# verify calls: how many `gh attestation verify` calls may have been made; +# probe status: what the one probe got ("-" = no HTTP response). +gh_class() { + local title="$1" scen="$2" cls="$3" want="$4" nver="$5" pst="$6" d="$tmp/i-ghclass" bad="" n + shift 6 + run "$scen" "$GH_ONLY" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" "$@" + [[ $rc -ne 0 && ! -e "$d" ]] || bad+=" installed-or-rc0" + itmp_clean || bad+=" tmpdir-left" + grep -qF -- "$want" "$OUT" || bad+=" reason-not-named" + case "$cls" in + 2) grep -qF 'or install cosign - refusing checksum-only installation' "$OUT" || bad+=" no-remedy" + ! grep -qE "$C3|FAILED|lacks|requires cosign or GitHub CLI" "$OUT" || bad+=" other-class-named";; + 3) grep -qF 'then retry, or install cosign - refusing checksum-only installation' "$OUT" || bad+=" no-remedy" + ! grep -qE "$C2|FAILED|lacks|requires cosign or GitHub CLI" "$OUT" || bad+=" other-class-named";; + 4) ! grep -qiE 'authenticat|logged in|credential|GH_TOKEN|network|proxy|unavailable|unreachable|rate.limit|refused|retry|HTTP [0-9]|lacks' "$OUT" || + bad+=" auth-or-network-wording" + ! grep -qF -- "$C2" "$OUT" && ! grep -qF -- "$C3" "$OUT" || bad+=" other-class-named";; + esac + no_ctl || bad+=" control-bytes" + [[ "$(cat "$GH_API_LOG")" == "$(probe_path "$scen") $pst" ]] || bad+=" probe($(tr '\n' '|' <"$GH_API_LOG"))" + n="$(grep -c . "$GH_VERIFY_LOG" || true)" + [[ "$n" == "$nver" ]] || bad+=" verify-calls=$n" + if [[ -z "$bad" ]]; then ok "gh $title: class $cls, refused" + else fail "gh $title (rc=$rc):$bad"; show; fi +} +gh_class "not logged in" ghapi 2 \ + "$C2: gh is not logged in (To get started with GitHub CLI, please run: gh auth login); run 'gh auth login' or set a valid GH_TOKEN" \ + 0 noauth FAKE_GH_AUTH=none +gh_class "token rejected (HTTP 401)" ghapi 2 \ + "$C2: GitHub rejected gh's credentials (HTTP 401) (gh: Bad credentials (HTTP 401)); run 'gh auth login' or set a valid GH_TOKEN" \ + 0 401 FAKE_GH_AUTH=bad +gh_class "API unreachable" ghapi 3 \ + "$C3: no HTTP response (Get \"https://api.github.com/$(probe_path ghapi)\": proxyconnect tcp: dial tcp 127.0.0.1:9: connect: connection refused); check the network" \ + 0 - FAKE_GH_NET=down +gh_class "API HTTP 503" ghapi 3 "$C3: HTTP 503 (gh: HTTP 503)" 0 503 FAKE_GH_NET=503 +gh_class "API HTTP 429" ghapi 3 "$C3: HTTP 429, rate limited (gh: You have exceeded a secondary rate limit. (HTTP 429))" 0 429 FAKE_GH_NET=429 +gh_class "API rate limit (403, X-Ratelimit-Remaining: 0, coloured headers)" ghapi 3 \ + "$C3: HTTP 403, rate limited (gh: API rate limit exceeded for user ID 1. (HTTP 403))" 0 403rl FAKE_GH_NET=403rl FAKE_GH_COLOR=1 +gh_class "API secondary rate limit (403, Retry-After)" ghapi 3 "$C3: HTTP 403, rate limited" 0 403ra FAKE_GH_NET=403ra +gh_class "API 403 without rate-limit headers" ghapi 4 "$C4 $CLI_AMD64" 1 403 FAKE_GH_NET=403 +gh_class "no attestation for the CLI digest (API 404)" ghnoatt 4 "$C4 $CLI_AMD64" 1 404 +gh_class "CLI attested for another tag (API answers)" ghrefuse 4 "$C4 $CLI_AMD64" 1 200 +gh_class "attestation signed by another workflow (API answers)" ghapi 4 "$C4 $CLI_AMD64" 1 200 \ + FAKE_GH_ATTEST_SIGNER="$REPO/.github/workflows/ci.yml" +gh_class "refusal whose stderr claims a network error" ghrefuse 4 "$C4 $CLI_AMD64" 1 200 \ + FAKE_GH_VERIFY_ERR=$'error connecting to api.github.com\ncheck your internet connection or https://githubstatus.com' +gh_class "refusal whose stderr claims missing auth" ghrefuse 4 "$C4 $CLI_AMD64" 1 200 \ + FAKE_GH_VERIFY_ERR=$'To get started with GitHub CLI, please run: gh auth login\nHTTP 401: Bad credentials' +gh_class "verify exits 4 after the API answered" ghapi 2 \ + "$C2: gh is not logged in (To get started with GitHub CLI, please run: gh auth login)" 1 200 FAKE_GH_VERIFY_RC=4 +gh_class "API unreachable, control bytes in gh's error" ghapi 3 "$C3: no HTTP response (]0;PWNED[31mGet \"https://api.github.com/" \ + 0 - FAKE_GH_NET=down FAKE_GH_ERR_CTRL=1 +# R2-N2: the "could not run 'gh --help'" line is stripped of control bytes too. +d="$tmp/i-ghbroken-ctl" bad="" +run ghcap "$GH_ONLY" FAKE_GH_BROKEN=1 FAKE_GH_ERR_CTRL=1 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +[[ $rc -ne 0 && ! -e "$d" ]] && itmp_clean || bad+=" installed-or-rc0-or-tmpdir" +grep -qF "installed gh could not run 'gh --help' (]0;PWNED[31mfailed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml); fix gh" "$OUT" || + bad+=" reason-not-named" +no_ctl || bad+=" control-bytes" +if [[ -z "$bad" ]]; then ok "gh that cannot run, control bytes in its error: stripped, refused" +else fail "gh that cannot run with control bytes (rc=$rc):$bad"; show; fi + +# 10b. The probe on a good release: exactly one read-only GET of the CLI's +# attestations, before the first verify call; with cosign present, gh +# is never run. The cosign path is the same for every gh mode above. +d="$tmp/i-ghprobe" bad="" +run ghapi "$GH_ONLY" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +[[ $rc -eq 0 && -x "$d/bin/bpfcompat" ]] && itmp_clean || bad+=" not-installed-or-tmpdir" +verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64" || bad+=" verify-calls" +[[ "$(cat "$GH_API_LOG")" == "$(probe_path ghapi) 200" ]] || bad+=" probe" +[[ "$(grep -nE '^(api |attestation verify [^-])' "$GH_LOG" | head -1)" == *":api --method GET --include $(probe_path ghapi)" ]] || + bad+=" probe-not-first" +if [[ -z "$bad" ]]; then ok "gh path: one read-only API probe of the CLI digest, before the verify calls" +else fail "gh path probe (rc=$rc):$bad"; show; sed 's/^/ gh | /' "$GH_LOG" >&2; fi +for ghenv in FAKE_GH_AUTH=none FAKE_GH_AUTH=bad FAKE_GH_NET=down FAKE_GH_NET=503 FAKE_GH_NET=429 FAKE_GH_NET=403rl \ + FAKE_GH_NET=403 FAKE_GH_VERIFY_RC=4 FAKE_GH_VERIFY_ERR=x FAKE_GH_ERR_CTRL=1; do + d="$tmp/i-ghclass-cosign" + run ghapi "$WITH_COSIGN" "$ghenv" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" + if [[ $rc -eq 0 && -x "$d/bin/bpfcompat" && -x "$d/libexec/bpfcompat-validator" && ! -s "$GH_LOG" ]] && itmp_clean && + grep -q 'cosign signature over SHA256SUMS verified' "$OUT"; then + ok "cosign path with $ghenv: gh never run, cosign verified, installed" + else fail "cosign path with $ghenv (rc=$rc)"; show; fi + rm -rf "$d" +done + +# 10c. R2-N1: with pipefail imported (bash as sh, SHELLOPTS exported) every +# refusal still prints its reason, and a good release still installs. +RUN_SH=(bash --posix) +PF=SHELLOPTS=braceexpand:hashall:interactive-comments:pipefail +for c in "FAKE_GH_BROKEN=1|installed gh could not run 'gh --help' (failed to create root command" \ + "FAKE_GH_VERSION=fail FAKE_GH_LACKS=attestation|installed gh lacks the 'gh attestation' command" \ + "FAKE_GH_NET=down|$C3: no HTTP response (Get " "FAKE_GH_NET=403rl|$C3: HTTP 403, rate limited" \ + "FAKE_GH_NET=403|$C4 $CLI_AMD64" "FAKE_GH_AUTH=none|$C2: gh is not logged in (To get started" \ + "FAKE_GH_AUTH=bad|$C2: GitHub rejected gh's credentials (HTTP 401) (gh: Bad credentials"; do + d="$tmp/i-pipefail" + read -r -a envs <<<"${c%%|*}" + run ghapi "$GH_ONLY" "$PF" "${envs[@]}" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" + if [[ $rc -ne 0 && ! -e "$d" ]] && itmp_clean && grep -qF -- "${c#*|}" "$OUT"; then + ok "pipefail imported, ${c%%|*}: refused with its reason" + else fail "pipefail imported, ${c%%|*} (rc=$rc)"; show; fi +done +d="$tmp/i-pipefail-ok" +run ghapi "$GH_ONLY" "$PF" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +if [[ $rc -eq 0 && -x "$d/bin/bpfcompat" ]] && verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64" && + [[ "$(cat "$GH_API_LOG")" == "$(probe_path ghapi) 200" ]]; then ok "pipefail imported: gh path installs a good release" +else fail "pipefail imported, good release (rc=$rc)"; show; fi +RUN_SH=(sh) + if [[ $failures -ne 0 ]]; then echo "[install-test] $failures failure(s)" >&2 exit 1 From 6705bb1bad0847bef401cf8c24d92761397cd657 Mon Sep 17 00:00:00 2001 From: ErenAri <erenari27@gmail.com> Date: Mon, 19 Oct 2026 09:39:55 +0300 Subject: [PATCH 4/5] install.sh: retry the API probe on a 5xx or no response only The RC canary installs through the gh path, where one transient 5xx or dropped connection at the read-only API probe ended the install, although gh's own verify retries a 5xx. The probe now makes up to three attempts, 3 s apart (6 s of waiting at most), for a 5xx or no HTTP response only, and the last attempt decides. A 429 or rate-limited 403 is not retried: GitHub asks the client to wait longer than an installer should, and asking again sooner spends more of the limit. 401, 404 and 200 are answers. Every outcome still refuses or verifies as before. Text quoted from gh is reduced to printable ASCII by an allow-list (octal, because busybox tr has no [:print:] class), so C1 controls such as a raw or UTF-8 encoded CSI no longer pass. Tests: retry sequences (5xx then 200; 502, 503, 200; no response then 200; 5xx then 401, 429 or 404; no response then a rate-limited 403 or a refusal) with exact attempt counts and waits; C1 bytes in both quoting places; and the defensive branches no case pinned: a probe exit other than 1 or 4 with no output, exit 1 with output that is no status line, HTTP/1.1 status lines, and a verify exit other than 0, 1 or 4. Signed-off-by: ErenAri <erenari27@gmail.com> --- scripts/install.sh | 60 ++++++++----- scripts/install_test.sh | 181 +++++++++++++++++++++++++++++++++------- 2 files changed, 191 insertions(+), 50 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 041f27a..4474d6d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -35,9 +35,13 @@ ALT_LIBEXEC_DIR="/usr/libexec/bpfcompat" err() { printf 'bpfcompat-install: %s\n' "$*" >&2; } die() { err "$*"; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } -# first_line FILE: its first non-blank line, without control characters, to -# quote gh's own words in a refusal (never more, never terminal escapes). -first_line() { sed -n '/[^[:space:]]/{p;q;}' "$1" | tr -d '[:cntrl:]'; } +# printable: keep printable ASCII only (space to '~'), so text quoted from +# gh carries no C0 or C1 control (terminal escapes) and no other bytes. +# Octal, not [:print:]: busybox tr has no [:print:] class. +printable() { LC_ALL=C tr -cd '\040-\176'; } +# first_line FILE: its first non-blank line, printable, to quote gh's own +# words in a refusal (never more, never terminal escapes). +first_line() { sed -n '/[^[:space:]]/{p;q;}' "$1" | printable; } # gh_no_auth REASON FILE: gh cannot authenticate to GitHub; quote FILE's # first line (gh's own words) and refuse. gh_no_auth() { @@ -176,7 +180,7 @@ elif have gh; then # A gh that cannot run at all (say, an unreadable config) fails every # command; say so with gh's own error rather than blame its version. if ! gh --help >/dev/null 2>&1; then - gh_err="$( { gh --help >/dev/null; } 2>&1 | sed -n '1p' | tr -d '[:cntrl:]')" || : + gh_err="$( { gh --help >/dev/null; } 2>&1 | sed -n '1p' | printable)" || : die "installed gh could not run 'gh --help'${gh_err:+ ($gh_err)}; fix gh or install cosign - refusing checksum-only installation" fi gh_lacks="the 'gh attestation' command" @@ -197,7 +201,7 @@ elif have gh; then # token rejected, or the GitHub API out of reach. Tell those apart from a # refused verification without trusting gh's wording, which changes # between releases and is all gh prints when stdout is not a terminal: - # before the first verify call, make one read-only request to the + # before the first verify call, make a read-only request to the # attestation API that call uses (same gh, host, token and proxy), and # refuse here only on positive evidence - gh's documented auth exit code # (4), an HTTP 401, no HTTP response at all, a 5xx, a 429 or a @@ -206,24 +210,36 @@ elif have gh; then # always reported as a failed verification. Probing first, not after a # failure, means a refusal can never be relabelled as an auth or network # problem, however gh words it or whatever the API does next. + # A 5xx or no HTTP response can be transient (gh's own verify retries a + # 5xx), so those alone are tried again: three attempts at most, 3 s apart, + # and the last one decides. A 429 or rate-limited 403 is not retried: it + # asks the client to wait (Retry-After, X-Ratelimit-Reset) far longer than + # an installer should, and asking again sooner only spends more of the + # limit. 200, 404 and 401 are answers. gh_digest="$($SHA256 <"$CLI_ASSET" | sed -n '1s/^\([0-9a-fA-F]\{64\}\).*/\1/p')" || : - gh_rc=0 - gh api --method GET --include "repos/$REPO/attestations/sha256:$gh_digest?per_page=1" \ - >.gh-api.out 2>.gh-api.err || gh_rc=$? - gh_status="$(tr -d '\r' <.gh-api.out | sed -n '1s/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p')" || : - gh_down="" - case "$gh_rc:$gh_status" in - 4:) gh_no_auth "gh is not logged in" .gh-api.err ;; - *:401) gh_no_auth "GitHub rejected gh's credentials (HTTP 401)" .gh-api.err ;; - *:5[0-9][0-9]) gh_down="HTTP $gh_status" ;; - *:429) gh_down="HTTP 429, rate limited" ;; - *:403) - # A 403 is an outage only with GitHub's own rate-limit headers. - gh_rl="$(tr -d '\r\033' <.gh-api.out | sed -n '2,/^$/{s/\[[0-9;]*m//g;p;}' | - grep -ciE '^(x-ratelimit-remaining: *0|retry-after: *[0-9]+)$')" || : - [ "${gh_rl:-0}" = 0 ] || gh_down="HTTP 403, rate limited" ;; - 1:) [ -s .gh-api.out ] || gh_down="no HTTP response" ;; - esac + gh_try=1 + while :; do + gh_rc=0 + gh api --method GET --include "repos/$REPO/attestations/sha256:$gh_digest?per_page=1" \ + >.gh-api.out 2>.gh-api.err || gh_rc=$? + gh_status="$(tr -d '\r' <.gh-api.out | sed -n '1s/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p')" || : + gh_down="" gh_again="" + case "$gh_rc:$gh_status" in + 4:) gh_no_auth "gh is not logged in" .gh-api.err ;; + *:401) gh_no_auth "GitHub rejected gh's credentials (HTTP 401)" .gh-api.err ;; + *:5[0-9][0-9]) gh_down="HTTP $gh_status" gh_again=1 ;; + *:429) gh_down="HTTP 429, rate limited" ;; + *:403) + # A 403 is an outage only with GitHub's own rate-limit headers. + gh_rl="$(tr -d '\r\033' <.gh-api.out | sed -n '2,/^$/{s/\[[0-9;]*m//g;p;}' | + grep -ciE '^(x-ratelimit-remaining: *0|retry-after: *[0-9]+)$')" || : + [ "${gh_rl:-0}" = 0 ] || gh_down="HTTP 403, rate limited" ;; + 1:) [ -s .gh-api.out ] || gh_down="no HTTP response" gh_again=1 ;; + esac + if [ -z "$gh_again" ] || [ "$gh_try" -ge 3 ]; then break; fi + gh_try=$((gh_try + 1)) + sleep 3 + done if [ -n "$gh_down" ]; then gh_err="$(first_line .gh-api.err)" || : die "GitHub API unavailable for attestation verification: $gh_down${gh_err:+ ($gh_err)}; check the network or proxy and https://www.githubstatus.com, then retry, or install cosign - refusing checksum-only installation" diff --git a/scripts/install_test.sh b/scripts/install_test.sh index c7b5f14..2be0311 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -20,7 +20,9 @@ # also answers the installer's read-only API probe (`gh api --include`) # from the attestation DB and records it, and can play a capable gh that # is not logged in, holds a rejected token, cannot reach the API or gets -# a 5xx / 429 / rate-limited 403, modelled on real gh 2.68.0 / 2.102.0; +# a 5xx / 429 / rate-limited 403, modelled on real gh 2.68.0 / 2.102.0, +# one probe attempt at a time; +# * sleep only records how long the installer would have waited; # * sudo pretends to be root by unlocking the test's read-only tree. # # Cases: latest -> stable, arch mapping (incl. extra SHA256SUMS lines, the @@ -37,7 +39,8 @@ # unavailable, each refused under its own name, never as a failed # verification, while every refusal - including one whose stderr claims a # network or auth problem - still reads "verification FAILED"), with gh's -# words stripped of control bytes and imported pipefail tolerated. +# words reduced to printable ASCII and imported pipefail tolerated; the +# API probe retried only on a 5xx or no response, at most three times. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -63,6 +66,7 @@ CURL_LOG="$tmp/curl.log" GH_LOG="$tmp/gh.log" GH_VERIFY_LOG="$tmp/gh-verify.log" GH_API_LOG="$tmp/gh-api.log" +SLEEP_LOG="$tmp/sleep.log" ITMP="$tmp/itmp" mkdir -p "$WWW" "$SHIM" "$TOOLS" "$tmp/home" : >"$ATTEST_DB" @@ -172,14 +176,24 @@ cat >"$SHIM/gh" <<EOF # FAKE_GH_NET=down no HTTP response (proxy/DNS/connect failure), exit 1 # FAKE_GH_NET=503|429|403|403rl|403ra that HTTP status; 403rl carries # X-Ratelimit-Remaining: 0, 403ra a Retry-After header +# FAKE_GH_PROBE_SEQ what each API probe attempt gets, in turn (the last +# entry repeats): ok (the DB's answer), down, 401, 503, +# 429, 403rl, crash (exit 2, no stdout) or garbage +# (exit 1, stdout that is no status line); the verify +# calls are not affected +# FAKE_GH_PROTO=1.1 the API answers over HTTP/1.1 (default HTTP/2.0) # FAKE_GH_COLOR=1 header names coloured, as with CLICOLOR_FORCE -# FAKE_GH_ERR_CTRL=1 terminal control bytes in gh's error lines -# FAKE_GH_VERIFY_RC=4 verify exits 4 even though the API probe answered +# FAKE_GH_ERR_CTRL=1 C0 terminal control bytes in gh's error lines; =c1: +# C1 CSI bytes, raw (0x9b) and UTF-8 encoded (c2 9b) +# FAKE_GH_VERIFY_RC=4 verify exits 4 even though the API probe answered; +# =2 verify exits 2 (gh's "cancelled" code): no refusal, no auth # FAKE_GH_VERIFY_ERR text a failing verify prints instead of its error # FAKE_GH_ATTEST_SIGNER workflow the stored attestations were signed by +# More than 10 probes in one run is an unbounded retry: exit 4 to stop it. echo "\$*" >>"$GH_LOG" ctl="" cr="" -if [[ -n "\${FAKE_GH_ERR_CTRL:-}" ]]; then ctl=\$'\e]0;PWNED\a\e[31m' cr=\$'\r'; fi +if [[ "\${FAKE_GH_ERR_CTRL:-}" == c1 ]]; then ctl=\$'\x9b''31m'\$'\xc2\x9b''32m' +elif [[ -n "\${FAKE_GH_ERR_CTRL:-}" ]]; then ctl=\$'\e]0;PWNED\a\e[31m' cr=\$'\r'; fi if [[ -n "\${FAKE_GH_BROKEN:-}" ]]; then echo "\${ctl}failed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml\$cr" >&2 exit 1 @@ -198,11 +212,11 @@ login_hint() { printf 'To get started with GitHub CLI, please run: gh auth login\nAlternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.\n' >&2 exit 4 } -# hdrs <status line> [header...]: what gh api --include prints before the body. +# hdrs <status> <reason> [header...]: what gh api --include prints before the body. hdrs() { local h c="" r="" if [[ -n "\${FAKE_GH_COLOR:-}" ]]; then c=\$'\e[1;34m' r=\$'\e[m'; fi - printf '%s\n' "\$1"; shift + printf 'HTTP/%s %s %s\n' "\${FAKE_GH_PROTO:-2.0}" "\$1" "\$2"; shift 2 for h in "Content-Type: application/json; charset=utf-8" "\$@"; do printf '%s%s%s:%s\r\n' "\$c" "\${h%%:*}" "\$r" "\${h#*:}" done @@ -210,16 +224,32 @@ hdrs() { } if [[ "\${1:-}" == api ]]; then [[ \$# -eq 5 && "\$2 \$3 \$4" == "--method GET --include" ]] || { echo "gh shim: unexpected api call: \$*" >&2; exit 2; } - path="\$5" st="" + path="\$5" st="" n="\$(grep -c . "$GH_API_LOG" || true)" + if [[ \$n -ge 10 ]]; then echo "\$path stop" >>"$GH_API_LOG"; echo "gh shim: API probe made \$((n + 1)) times" >&2; exit 4; fi if [[ "\${FAKE_GH_AUTH:-}" == none ]]; then echo "\$path noauth" >>"$GH_API_LOG"; login_hint; fi - if [[ "\${FAKE_GH_AUTH:-}" == bad ]]; then st=401 + net="\${FAKE_GH_NET:-}" + if [[ -n "\${FAKE_GH_PROBE_SEQ:-}" ]]; then + read -r -a seq <<<"\$FAKE_GH_PROBE_SEQ" + [[ \$n -lt \${#seq[@]} ]] || n=\$((\${#seq[@]} - 1)) + net="\${seq[\$n]}"; [[ "\$net" != ok ]] || net="" + fi + if [[ "\${FAKE_GH_AUTH:-}" == bad || "\$net" == 401 ]]; then st=401 else - case "\${FAKE_GH_NET:-}" in + case "\$net" in down) echo "\$path -" >>"$GH_API_LOG" echo "\${ctl}Get \"https://api.github.com/\$path\": proxyconnect tcp: dial tcp 127.0.0.1:9: connect: connection refused\$cr" >&2 exit 1;; - 503|429|403|403rl|403ra) st="\$FAKE_GH_NET";; + crash) + echo "\$path crash" >>"$GH_API_LOG" + echo "panic: runtime error: invalid memory address or nil pointer dereference" >&2 + exit 2;; + garbage) + echo "\$path garbage" >>"$GH_API_LOG" + echo "<html>proxy login page</html>" + echo "gh: unexpected response" >&2 + exit 1;; + 5[0-9][0-9]|429|403|403rl|403ra) st="\$net";; "") d="\${path#repos/$REPO/attestations/sha256:}"; d="\${d%%\?*}" if [[ "\$path" == "repos/$REPO/attestations/sha256:\$d?per_page=1" && "\$d" =~ ^[0-9a-f]{64}\$ ]] && @@ -229,22 +259,23 @@ if [[ "\${1:-}" == api ]]; then fi echo "\$path \$st" >>"$GH_API_LOG" case "\$st" in - 200) hdrs "HTTP/2.0 200 OK" "X-Ratelimit-Remaining: 4999"; echo '{"attestations":[{"bundle":{}}]}'; exit 0;; - 404) hdrs "HTTP/2.0 404 Not Found" "X-Ratelimit-Remaining: 4998" + 200) hdrs 200 OK "X-Ratelimit-Remaining: 4999"; echo '{"attestations":[{"bundle":{}}]}'; exit 0;; + 404) hdrs 404 "Not Found" "X-Ratelimit-Remaining: 4998" echo '{"message":"Not Found","status":"404"}'; echo "gh: Not Found (HTTP 404)" >&2;; - 401) hdrs "HTTP/2.0 401 Unauthorized" + 401) hdrs 401 Unauthorized echo '{"message":"Bad credentials","status":"401"}'; echo "gh: Bad credentials (HTTP 401)" >&2;; - 503) hdrs "HTTP/2.0 503 Service Unavailable"; echo 'unicorn'; echo "\${ctl}gh: HTTP 503\$cr" >&2;; - 429) hdrs "HTTP/2.0 429 Too Many Requests" "Retry-After: 60" + 503) hdrs 503 "Service Unavailable"; echo 'unicorn'; echo "\${ctl}gh: HTTP 503\$cr" >&2;; + 5??) hdrs "\$st" "Bad Gateway"; echo '<html>bad gateway</html>'; echo "gh: HTTP \$st" >&2;; + 429) hdrs 429 "Too Many Requests" "Retry-After: 60" echo '{"message":"You have exceeded a secondary rate limit."}' echo "gh: You have exceeded a secondary rate limit. (HTTP 429)" >&2;; - 403rl) hdrs "HTTP/2.0 403 Forbidden" "X-Ratelimit-Remaining: 0" "X-Ratelimit-Reset: 1760000000" + 403rl) hdrs 403 Forbidden "X-Ratelimit-Remaining: 0" "X-Ratelimit-Reset: 1760000000" echo '{"message":"API rate limit exceeded for user ID 1."}' echo "gh: API rate limit exceeded for user ID 1. (HTTP 403)" >&2;; - 403ra) hdrs "HTTP/2.0 403 Forbidden" "Retry-After: 60" "X-Ratelimit-Remaining: 4000" + 403ra) hdrs 403 Forbidden "Retry-After: 60" "X-Ratelimit-Remaining: 4000" echo '{"message":"You have exceeded a secondary rate limit."}' echo "gh: You have exceeded a secondary rate limit. (HTTP 403)" >&2;; - 403) hdrs "HTTP/2.0 403 Forbidden" "X-Ratelimit-Remaining: 4000" + 403) hdrs 403 Forbidden "X-Ratelimit-Remaining: 4000" echo '{"message":"Resource not accessible by personal access token"}' echo "gh: Resource not accessible by personal access token (HTTP 403)" >&2;; esac @@ -284,6 +315,7 @@ while [[ \$# -gt 0 ]]; do esac done printf 'verify %s repo=%s signer=%s ref=%s\n' "\$file" "\$repo" "\$signer" "\$ref" >>"$GH_VERIFY_LOG" +if [[ "\${FAKE_GH_VERIFY_RC:-}" == 2 ]]; then echo "gh shim: verify ended with exit code 2" >&2; exit 2; fi # refuse <error>: how gh reports a failed verify when stdout is not a TTY. refuse() { if [[ -n "\${FAKE_GH_VERIFY_ERR:-}" ]]; then printf '%s\n' "\$FAKE_GH_VERIFY_ERR" >&2 @@ -306,6 +338,11 @@ sum="\$("$REAL_SHA" <"\$file" | cut -d' ' -f1)" if [[ -n "\$ref" ]]; then grep -qx "\$sum \$ref" "$ATTEST_DB" || refuse "expected SourceRepositoryRef to be \$ref" else grep -q "^\$sum " "$ATTEST_DB" || refuse "no attestations found"; fi EOF +cat >"$SHIM/sleep" <<EOF +#!/bin/sh +# Record the wait instead of waiting. +echo "\$*" >>"$SLEEP_LOG" +EOF cat >"$SHIM/uname" <<'EOF' #!/bin/sh case "$1" in -s) echo "${FAKE_OS:-Linux}";; -m) echo "${FAKE_ARCH:-x86_64}";; *) exit 1;; esac @@ -363,7 +400,8 @@ OUT="$tmp/out" RUN_SH=(sh) run() { local scen="$1" path="$2"; shift 2 - : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; : >"$GH_API_LOG"; rm -f "$tmp/sudo.log" + : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; : >"$GH_API_LOG"; : >"$SLEEP_LOG" + rm -f "$tmp/sudo.log" rm -rf "$ITMP"; mkdir -p "$ITMP" set +e env -i HOME="$tmp/home" TMPDIR="$ITMP" PATH="$path" FAKE_PORT="$FAKE_PORT" SCENARIO="$scen" "$@" \ @@ -374,6 +412,13 @@ run() { WITH_COSIGN="$SHIM:$TOOLS" GH_ONLY="$tmp/ghonly:$TOOLS" show() { sed 's/^/ | /' "$OUT" >&2; } +# A shell that runs its own sleep (busybox's applet) bypasses the sleep +# shim: then the retry cases can check the attempts but not the waits. +: >"$SLEEP_LOG" +env -i PATH="$GH_ONLY" sh -c 'sleep 0' || true +SLEEP_SHIMMED=0 +if [[ -s "$SLEEP_LOG" ]]; then SLEEP_SHIMMED=1 +else echo "[install-test] note: this sh does not run sleep from PATH; waits between probe attempts are not checked" >&2; fi # verified <PATH> <tag> <asset...>: on the gh-only PATH, gh verified exactly # these assets, once each, bound to the release workflow and refs/tags/<tag>; # with cosign on PATH, gh verified nothing. So a dropped, unbound or extra @@ -670,15 +715,26 @@ C4='GitHub attestation verification FAILED for' cp "$(rel_dir ghrefuse v0.4.0-rc.4)/$CLI_AMD64" "$(rel_dir ghrefuse v0.3.7)/$CLI_AMD64" printf '#!/bin/sh\necho "unattested build"\n' >"$(rel_dir ghnoatt v0.3.7)/$CLI_AMD64" for s in ghrefuse ghnoatt; do (cd "$(rel_dir "$s" v0.3.7)" && "$REAL_SHA" bpfcompat-* >SHA256SUMS); done -# probe_path <scenario>: the probe the installer must make for v0.3.7's CLI. +# probe_path <scenario> [tag] [cli asset]: the probe the installer must make +# for that release's CLI (default: v0.3.7's amd64 CLI). probe_path() { - echo "repos/$REPO/attestations/sha256:$("$REAL_SHA" <"$(rel_dir "$1" v0.3.7)/$CLI_AMD64" | cut -d' ' -f1)?per_page=1" + echo "repos/$REPO/attestations/sha256:$("$REAL_SHA" <"$(rel_dir "$1" "${2:-v0.3.7}")/${3:-$CLI_AMD64}" | cut -d' ' -f1)?per_page=1" +} +# probes <scenario> <status...>: the probe log of one attempt per status. +probes() { local s="$1" st; shift; for st in "$@"; do echo "$(probe_path "$s") $st"; done; } +# waited <status...>: the installer waited 3 s before every attempt but the +# first, and at no other time (unchecked when sh bypasses the sleep shim). +waited() { + local want="" i + for ((i = 1; i < $#; i++)); do want+="3"$'\n'; done + [[ $SLEEP_SHIMMED == 0 || "$(cat "$SLEEP_LOG")" == "${want%$'\n'}" ]] } itmp_clean() { [[ -d "$ITMP" && -z "$(ls -A "$ITMP")" ]]; } no_ctl() { [[ -z "$(LC_ALL=C tr -d '\n[:print:]' <"$OUT")" ]]; } -# gh_class <title> <scenario> <class 2|3|4> <text> <verify calls> <probe status> [VAR=value...] +# gh_class <title> <scenario> <class 2|3|4> <text> <verify calls> <probe statuses> [VAR=value...] # verify calls: how many `gh attestation verify` calls may have been made; -# probe status: what the one probe got ("-" = no HTTP response). +# probe statuses: what each probe attempt got, in order ("-" = no HTTP +# response); the waits between attempts are checked too. gh_class() { local title="$1" scen="$2" cls="$3" want="$4" nver="$5" pst="$6" d="$tmp/i-ghclass" bad="" n shift 6 @@ -696,7 +752,10 @@ gh_class() { ! grep -qF -- "$C2" "$OUT" && ! grep -qF -- "$C3" "$OUT" || bad+=" other-class-named";; esac no_ctl || bad+=" control-bytes" - [[ "$(cat "$GH_API_LOG")" == "$(probe_path "$scen") $pst" ]] || bad+=" probe($(tr '\n' '|' <"$GH_API_LOG"))" + # shellcheck disable=SC2086 # one probe attempt per word + [[ "$(cat "$GH_API_LOG")" == "$(probes "$scen" $pst)" ]] || bad+=" probe($(tr '\n' '|' <"$GH_API_LOG"))" + # shellcheck disable=SC2086 + waited $pst || bad+=" waits($(tr '\n' '|' <"$SLEEP_LOG"))" n="$(grep -c . "$GH_VERIFY_LOG" || true)" [[ "$n" == "$nver" ]] || bad+=" verify-calls=$n" if [[ -z "$bad" ]]; then ok "gh $title: class $cls, refused" @@ -710,8 +769,8 @@ gh_class "token rejected (HTTP 401)" ghapi 2 \ 0 401 FAKE_GH_AUTH=bad gh_class "API unreachable" ghapi 3 \ "$C3: no HTTP response (Get \"https://api.github.com/$(probe_path ghapi)\": proxyconnect tcp: dial tcp 127.0.0.1:9: connect: connection refused); check the network" \ - 0 - FAKE_GH_NET=down -gh_class "API HTTP 503" ghapi 3 "$C3: HTTP 503 (gh: HTTP 503)" 0 503 FAKE_GH_NET=503 + 0 "- - -" FAKE_GH_NET=down +gh_class "API HTTP 503" ghapi 3 "$C3: HTTP 503 (gh: HTTP 503)" 0 "503 503 503" FAKE_GH_NET=503 gh_class "API HTTP 429" ghapi 3 "$C3: HTTP 429, rate limited (gh: You have exceeded a secondary rate limit. (HTTP 429))" 0 429 FAKE_GH_NET=429 gh_class "API rate limit (403, X-Ratelimit-Remaining: 0, coloured headers)" ghapi 3 \ "$C3: HTTP 403, rate limited (gh: API rate limit exceeded for user ID 1. (HTTP 403))" 0 403rl FAKE_GH_NET=403rl FAKE_GH_COLOR=1 @@ -728,7 +787,7 @@ gh_class "refusal whose stderr claims missing auth" ghrefuse 4 "$C4 $CLI_AMD64" gh_class "verify exits 4 after the API answered" ghapi 2 \ "$C2: gh is not logged in (To get started with GitHub CLI, please run: gh auth login)" 1 200 FAKE_GH_VERIFY_RC=4 gh_class "API unreachable, control bytes in gh's error" ghapi 3 "$C3: no HTTP response (]0;PWNED[31mGet \"https://api.github.com/" \ - 0 - FAKE_GH_NET=down FAKE_GH_ERR_CTRL=1 + 0 "- - -" FAKE_GH_NET=down FAKE_GH_ERR_CTRL=1 # R2-N2: the "could not run 'gh --help'" line is stripped of control bytes too. d="$tmp/i-ghbroken-ctl" bad="" run ghcap "$GH_ONLY" FAKE_GH_BROKEN=1 FAKE_GH_ERR_CTRL=1 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" @@ -738,6 +797,18 @@ grep -qF "installed gh could not run 'gh --help' (]0;PWNED[31mfailed to create r no_ctl || bad+=" control-bytes" if [[ -z "$bad" ]]; then ok "gh that cannot run, control bytes in its error: stripped, refused" else fail "gh that cannot run with control bytes (rc=$rc):$bad"; show; fi +# N2: gh's words keep printable ASCII only, so C1 controls go too - a raw +# CSI byte (0x9b) and its UTF-8 form (c2 9b) - in both quoting places. +gh_class "API unreachable, C1 control bytes in gh's error" ghapi 3 "$C3: no HTTP response (31m32mGet \"https://api.github.com/" \ + 0 "- - -" FAKE_GH_NET=down FAKE_GH_ERR_CTRL=c1 +d="$tmp/i-ghbroken-c1" bad="" +run ghcap "$GH_ONLY" FAKE_GH_BROKEN=1 FAKE_GH_ERR_CTRL=c1 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +[[ $rc -ne 0 && ! -e "$d" ]] && itmp_clean || bad+=" installed-or-rc0-or-tmpdir" +grep -qF "installed gh could not run 'gh --help' (31m32mfailed to create root command: failed to read configuration: invalid config file /home/u/.config/gh/config.yml: invalid yaml); fix gh" "$OUT" || + bad+=" reason-not-named" +no_ctl || bad+=" control-bytes" +if [[ -z "$bad" ]]; then ok "gh that cannot run, C1 control bytes in its error: stripped, refused" +else fail "gh that cannot run with C1 control bytes (rc=$rc):$bad"; show; fi # 10b. The probe on a good release: exactly one read-only GET of the CLI's # attestations, before the first verify call; with cosign present, gh @@ -749,6 +820,7 @@ verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64" || bad+=" verify-calls" [[ "$(cat "$GH_API_LOG")" == "$(probe_path ghapi) 200" ]] || bad+=" probe" [[ "$(grep -nE '^(api |attestation verify [^-])' "$GH_LOG" | head -1)" == *":api --method GET --include $(probe_path ghapi)" ]] || bad+=" probe-not-first" +waited 200 || bad+=" waited" if [[ -z "$bad" ]]; then ok "gh path: one read-only API probe of the CLI digest, before the verify calls" else fail "gh path probe (rc=$rc):$bad"; show; sed 's/^/ gh | /' "$GH_LOG" >&2; fi for ghenv in FAKE_GH_AUTH=none FAKE_GH_AUTH=bad FAKE_GH_NET=down FAKE_GH_NET=503 FAKE_GH_NET=429 FAKE_GH_NET=403rl \ @@ -785,6 +857,59 @@ if [[ $rc -eq 0 && -x "$d/bin/bpfcompat" ]] && verified "$GH_ONLY" v0.3.7 "$CLI_ else fail "pipefail imported, good release (rc=$rc)"; show; fi RUN_SH=(sh) +# 10d. N7: a 5xx or no HTTP response at the probe may be transient (gh's +# own verify retries a 5xx), so the probe is made up to three times, +# 3 s apart, and the last attempt decides. Nothing else is retried: the +# single-attempt expectations in 10 pin 401, 404, 200, a plain 403, a +# 429, rate-limited 403s and gh's auth exit code 4. +# gh_installs <title> <scenario> <probe statuses> [VAR=value...]: the good +# v0.3.7 release still installs, verified, after exactly these attempts. +gh_installs() { + local title="$1" scen="$2" pst="$3" d="$tmp/i-ghok" bad="" + shift 3 + run "$scen" "$GH_ONLY" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" "$@" + [[ $rc -eq 0 && -x "$d/bin/bpfcompat" && -x "$d/libexec/bpfcompat-validator" ]] && itmp_clean || bad+=" not-installed-or-tmpdir" + grep -q 'GitHub build attestations verified' "$OUT" || bad+=" not-verified" + verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64" || bad+=" verify-calls" + # shellcheck disable=SC2086 # one probe attempt per word + [[ "$(cat "$GH_API_LOG")" == "$(probes "$scen" $pst)" ]] || bad+=" probe($(tr '\n' '|' <"$GH_API_LOG"))" + # shellcheck disable=SC2086 + waited $pst || bad+=" waits($(tr '\n' '|' <"$SLEEP_LOG"))" + ! grep -qE "$C2|$C3|FAILED" "$OUT" || bad+=" refusal-wording" + if [[ -z "$bad" ]]; then ok "gh $title: installed" + else fail "gh $title (rc=$rc):$bad"; show; fi + rm -rf "$d" +} +gh_installs "probe HTTP 503, then 200" ghapi "503 200" FAKE_GH_PROBE_SEQ="503 ok" +gh_installs "probe HTTP 502, 503, then 200 on the third and last attempt" ghapi "502 503 200" FAKE_GH_PROBE_SEQ="502 503 ok" +gh_installs "probe gets no HTTP response, then 200" ghapi "- 200" FAKE_GH_PROBE_SEQ="down ok" +gh_class "probe HTTP 503, then 401" ghapi 2 \ + "$C2: GitHub rejected gh's credentials (HTTP 401) (gh: Bad credentials (HTTP 401)); run 'gh auth login'" 0 "503 401" \ + FAKE_GH_PROBE_SEQ="503 401" +gh_class "probe HTTP 503, then 429 (not retried)" ghapi 3 "$C3: HTTP 429, rate limited (gh: You have exceeded" 0 "503 429" \ + FAKE_GH_PROBE_SEQ="503 429" +gh_class "probe: no HTTP response, then 403 rate-limited (not retried)" ghapi 3 "$C3: HTTP 403, rate limited" 0 "- 403rl" \ + FAKE_GH_PROBE_SEQ="down 403rl" +gh_class "probe HTTP 503, then 404: the verify calls decide" ghnoatt 4 "$C4 $CLI_AMD64" 1 "503 404" FAKE_GH_PROBE_SEQ="503 ok" +gh_class "probe: no HTTP response, then 200, CLI attested for another tag" ghrefuse 4 "$C4 $CLI_AMD64" 1 "- 200" \ + FAKE_GH_PROBE_SEQ="down ok" + +# 10e. N1: ambiguous probe results go to the verify calls; a verify exit +# code that is neither a refusal (1) nor gh's auth code (4) is a +# failed verification; the status line is read for HTTP/1.1 too (as +# behind a TLS-intercepting proxy). +gh_installs "probe exits 2 with no output (no status line): the verify calls decide" ghapi crash FAKE_GH_PROBE_SEQ=crash +gh_class "probe exits 2 with no output, CLI attested for another tag" ghrefuse 4 "$C4 $CLI_AMD64" 1 crash FAKE_GH_PROBE_SEQ=crash +gh_installs "probe exits 1 with output that is no status line: the verify calls decide" ghapi garbage FAKE_GH_PROBE_SEQ=garbage +gh_class "verify exits 2 after the API answered" ghapi 4 "$C4 $CLI_AMD64" 1 200 FAKE_GH_VERIFY_RC=2 +gh_installs "API over HTTP/1.1" ghapi 200 FAKE_GH_PROTO=1.1 +gh_class "token rejected over HTTP/1.1" ghapi 2 \ + "$C2: GitHub rejected gh's credentials (HTTP 401) (gh: Bad credentials (HTTP 401)); run 'gh auth login'" 0 401 \ + FAKE_GH_AUTH=bad FAKE_GH_PROTO=1.1 +gh_class "HTTP/1.1 503, three times" ghapi 3 "$C3: HTTP 503 (gh: HTTP 503)" 0 "503 503 503" FAKE_GH_NET=503 FAKE_GH_PROTO=1.1 +gh_class "HTTP/1.1 403 rate-limited" ghapi 3 "$C3: HTTP 403, rate limited (gh: API rate limit exceeded" 0 403rl \ + FAKE_GH_NET=403rl FAKE_GH_PROTO=1.1 + if [[ $failures -ne 0 ]]; then echo "[install-test] $failures failure(s)" >&2 exit 1 From 69cebeae6791d3c8f016cdb0a10ed3b68bce0778 Mon Sep 17 00:00:00 2001 From: ErenAri <erenari27@gmail.com> Date: Mon, 19 Oct 2026 09:40:05 +0300 Subject: [PATCH 5/5] install.sh: bind gh-path installs to the release's attested names On the gh path SHA256SUMS is unsigned, and gh attestation verify matches an artifact by digest only. So a party able to change release assets could delete the validator or license-bundle line (a CLI-only install with a false "not published" note), or serve one genuine asset of the tag under another asset's name, and the install still passed. The CLI's verify call now also prints the subjects of its verified provenance (--format json --jq, as "<sha256> <name>" lines), which name every asset the tag's release run attested. The installer refuses, as a failed verification naming the mismatch, when SHA256SUMS and those subjects disagree on whether the validator or the license bundle exists, or when a file to be installed is not attested under its own name. That call's stderr is still kept for the auth classification. The cosign path is unchanged. Tests: the gh stub prints the subjects of every statement that holds the verified digest. Refused: the validator line deleted (v0.3.7 and rc.5 shapes), the bundle line deleted, the arm64 CLI served as the amd64 CLI, the CLI served as the validator, an arm64 validator line added on an aarch64 host, and bytes from another statement of the run served as the validator or as the CLI; each also refused by cosign. Installed: a CLI-only install, an aarch64 host and a re-run release with two statements. Signed-off-by: ErenAri <erenari27@gmail.com> --- scripts/install.sh | 29 ++++++- scripts/install_test.sh | 176 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 9 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 4474d6d..817d6c1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -124,7 +124,8 @@ install_validator=1 # The static C validator is published for amd64 only today. If this release has # no validator for the current arch, install the CLI without it rather than # failing - VM artifact validation then needs `make validator-static` locally or -# command mode. (SHA256SUMS is integrity-checked below regardless.) +# command mode. (SHA256SUMS is authenticated below regardless: by its cosign +# signature, or on the gh path against the release run's attested assets.) if [ "$install_validator" = "1" ]; then if grep -qE "^[0-9a-fA-F]{64} ${VALIDATOR_ASSET}\$" SHA256SUMS; then curl -fsSLO --retry 2 --connect-timeout 10 --max-time 300 \ @@ -244,9 +245,13 @@ elif have gh; then gh_err="$(first_line .gh-api.err)" || : die "GitHub API unavailable for attestation verification: $gh_down${gh_err:+ ($gh_err)}; check the network or proxy and https://www.githubstatus.com, then retry, or install cosign - refusing checksum-only installation" fi + # The CLI's call also prints its verified provenance's subjects: every + # asset the tag's release run attested, as "<sha256> <name>" lines. GH_REPO="$REPO" gh attestation verify "$CLI_ASSET" --repo "$REPO" \ --signer-workflow "$REPO/.github/workflows/release-artifacts.yml" \ - --source-ref "refs/tags/$VERSION" >.gh-verify.out 2>&1 || + --source-ref "refs/tags/$VERSION" --format json \ + --jq '.[].verificationResult.statement.subject[] | "\(.digest.sha256) \(.name)"' \ + >.gh-attested 2>.gh-verify.out || gh_refused "$CLI_ASSET" "$?" if [ "$install_validator" = "1" ]; then GH_REPO="$REPO" gh attestation verify "$VALIDATOR_ASSET" --repo "$REPO" \ @@ -260,6 +265,26 @@ elif have gh; then --source-ref "refs/tags/$VERSION" >.gh-verify.out 2>&1 || gh_refused "$LICENSE_ASSET" "$?" fi + # SHA256SUMS is unsigned on this path, and an attestation vouches for + # bytes, not for a file name. So SHA256SUMS must agree with the CLI's + # verified provenance on whether the release has a validator and a license + # bundle (it decides what is downloaded), and that provenance must list + # every file under the name it is installed as - not only as some other + # asset of the run. + for f in "$VALIDATOR_ASSET" "$LICENSE_ASSET"; do + gh_a=0; grep -qE "^[0-9a-f]{64} ${f}\$" .gh-attested && gh_a=1 + gh_s=0; grep -qE "^[0-9a-fA-F]{64} ${f}\$" SHA256SUMS && gh_s=1 + case "$gh_a$gh_s" in + 10) die "GitHub attestation verification FAILED for $f: SHA256SUMS does not list it, but the $VERSION release provenance of $CLI_ASSET does" ;; + 01) die "GitHub attestation verification FAILED for $f: SHA256SUMS lists it, but the $VERSION release provenance of $CLI_ASSET does not" ;; + esac + done + for f in $DL_FILES; do + gh_d="$($SHA256 <"$f" | sed -n '1s/^\([0-9a-fA-F]\{64\}\).*/\1/p')" || : + grep -qxF "$gh_d $f" .gh-attested && continue + gh_as="$(sed -n "s/^$gh_d //p" .gh-attested | sed -n '1p' | printable)" || : + die "GitHub attestation verification FAILED for $f: the $VERSION release provenance of $CLI_ASSET does not list these bytes under that name${gh_as:+ (it lists them as $gh_as)}" + done err "GitHub build attestations verified" else die "cryptographic verification requires cosign or GitHub CLI (gh); refusing checksum-only installation" diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 2be0311..2eb0801 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -21,7 +21,9 @@ # from the attestation DB and records it, and can play a capable gh that # is not logged in, holds a rejected token, cannot reach the API or gets # a 5xx / 429 / rate-limited 403, modelled on real gh 2.68.0 / 2.102.0, -# one probe attempt at a time; +# one probe attempt at a time. For `--format json --jq <the installer's +# expression>` it prints, like real gh, the "<sha256> <name>" subjects +# of every attested statement that holds the verified digest; # * sleep only records how long the installer would have waited; # * sudo pretends to be root by unlocking the test's read-only tree. # @@ -40,7 +42,10 @@ # verification, while every refusal - including one whose stderr claims a # network or auth problem - still reads "verification FAILED"), with gh's # words reduced to printable ASCII and imported pipefail tolerated; the -# API probe retried only on a 5xx or no response, at most three times. +# API probe retried only on a 5xx or no response, at most three times; and +# on the gh path, SHA256SUMS held to the CLI's attested provenance (it must +# agree on whether a validator and a license bundle exist, and each file is +# installed only under the name its bytes were attested as). set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -62,14 +67,19 @@ WWW="$tmp/www" SHIM="$tmp/shim" TOOLS="$tmp/tools" ATTEST_DB="$tmp/attestations" +# One line per attested subject: "<statement> refs/tags/<tag> <sha256> <name>". +SUBJ_DB="$tmp/subjects" CURL_LOG="$tmp/curl.log" GH_LOG="$tmp/gh.log" GH_VERIFY_LOG="$tmp/gh-verify.log" GH_API_LOG="$tmp/gh-api.log" +GH_FMT_LOG="$tmp/gh-format.log" SLEEP_LOG="$tmp/sleep.log" ITMP="$tmp/itmp" +# The installer's request for the CLI's attested subjects (gh --jq). +JQ_SUBJECTS='.[].verificationResult.statement.subject[] | "\(.digest.sha256) \(.name)"' mkdir -p "$WWW" "$SHIM" "$TOOLS" "$tmp/home" -: >"$ATTEST_DB" +: >"$ATTEST_DB"; : >"$SUBJ_DB" failures=0 fail() { echo "[install-test] FAIL: $*" >&2; failures=$((failures + 1)); } @@ -78,7 +88,8 @@ ok() { echo "[install-test] ok: $*"; } # ---------------------------------------------------------------- fake release # release <scenario> <tag> [validator-arches] [bundle]: builds a signed, # attested release under the scenario's fake github.com tree, the way -# release-artifacts.yml does. bundle: "yes" adds the third-party license +# release-artifacts.yml does: one provenance statement ("<scenario>-<tag>") +# names every attested asset. bundle: "yes" adds the third-party license # bundle; "unattested" adds it to SHA256SUMS without an attestation. release() { local scen="$1" tag="$2" varches="${3:-amd64}" bundle="${4:-no}" d a b @@ -102,6 +113,7 @@ release() { for a in "$d"/bpfcompat-*; do [[ "$bundle" == unattested && "$a" == *third-party-licenses* ]] && continue printf '%s refs/tags/%s\n' "$("$REAL_SHA" <"$a" | cut -d' ' -f1)" "$tag" >>"$ATTEST_DB" + printf '%s-%s refs/tags/%s %s %s\n' "$scen" "$tag" "$tag" "$("$REAL_SHA" <"$a" | cut -d' ' -f1)" "${a##*/}" >>"$SUBJ_DB" done printf 'sig:%s\n' "$("$REAL_SHA" <"$d/SHA256SUMS" | cut -d' ' -f1)" >"$d/SHA256SUMS.sig" printf '%s@refs/tags/%s\n' "$WF" "$tag" >"$d/SHA256SUMS.crt" @@ -303,18 +315,21 @@ if [[ "\${1:-} \${2:-} \${3:-}" == "attestation verify --help" ]]; then fi [[ "\${1:-} \${2:-}" == "attestation verify" ]] || exit 1 shift 2 -file="" repo="" signer="" ref="" +file="" repo="" signer="" ref="" fmt="" jq="" fmtset="" while [[ \$# -gt 0 ]]; do if [[ "\$1" == --* && "\$lacks" == *" \$1 "* ]]; then echo "unknown flag: \$1" >&2; exit 1; fi case "\$1" in --repo) repo="\$2"; shift 2;; --signer-workflow) signer="\$2"; shift 2;; --source-ref) ref="\$2"; shift 2;; + --format) fmt="\$2" fmtset=1; shift 2;; + --jq) jq="\$2" fmtset=1; shift 2;; -*) echo "gh shim: unknown flag \$1" >&2; exit 2;; *) file="\$1"; shift;; esac done printf 'verify %s repo=%s signer=%s ref=%s\n' "\$file" "\$repo" "\$signer" "\$ref" >>"$GH_VERIFY_LOG" +[[ -z "\$fmtset" ]] || printf '%s fmt=%s jq=%s\n' "\$file" "\$fmt" "\$jq" >>"$GH_FMT_LOG" if [[ "\${FAKE_GH_VERIFY_RC:-}" == 2 ]]; then echo "gh shim: verify ended with exit code 2" >&2; exit 2; fi # refuse <error>: how gh reports a failed verify when stdout is not a TTY. refuse() { @@ -337,6 +352,17 @@ esac sum="\$("$REAL_SHA" <"\$file" | cut -d' ' -f1)" if [[ -n "\$ref" ]]; then grep -qx "\$sum \$ref" "$ATTEST_DB" || refuse "expected SourceRepositoryRef to be \$ref" else grep -q "^\$sum " "$ATTEST_DB" || refuse "no attestations found"; fi +[[ -n "\$fmtset" ]] || exit 0 +[[ "\$fmt" == json && "\$jq" == '$JQ_SUBJECTS' ]] || + { echo "gh shim: unexpected --format/--jq: \$fmt \$jq" >&2; exit 2; } +# Like real gh: the subjects of every verified statement (one whose ref +# passed) that holds this digest, as "<sha256> <name>" lines. +seen=" " +while read -r st sref sdig _; do + [[ "\$sdig" == "\$sum" && ( -z "\$ref" || "\$sref" == "\$ref" ) && "\$seen" != *" \$st "* ]] || continue + seen+="\$st " + while read -r s2 _ d2 n2; do [[ "\$s2" != "\$st" ]] || printf '%s %s\n' "\$d2" "\$n2"; done <"$SUBJ_DB" +done <"$SUBJ_DB" EOF cat >"$SHIM/sleep" <<EOF #!/bin/sh @@ -400,7 +426,7 @@ OUT="$tmp/out" RUN_SH=(sh) run() { local scen="$1" path="$2"; shift 2 - : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; : >"$GH_API_LOG"; : >"$SLEEP_LOG" + : >"$CURL_LOG"; : >"$GH_LOG"; : >"$GH_VERIFY_LOG"; : >"$GH_API_LOG"; : >"$GH_FMT_LOG"; : >"$SLEEP_LOG" rm -f "$tmp/sudo.log" rm -rf "$ITMP"; mkdir -p "$ITMP" set +e @@ -445,6 +471,11 @@ for s in stable arm64 nosha noverify mismatch bindcosign bindgh bindghcli bindgh release "$s" v0.4.0-rc.4 amd64 latest "$s" v0.3.7 done +for s in rbval037 rbcli rbval rbarm rbsep rbsepcli rbrerun; do + release "$s" v0.3.7 amd64 + latest "$s" v0.3.7 +done +for s in rbval5 rblic5; do release "$s" v0.4.0-rc.5 amd64 yes; done for s in lic licbad licmissing licunattested; do release "$s" v0.3.7 amd64 latest "$s" v0.3.7 @@ -637,7 +668,10 @@ if [[ $rc -ne 0 && ! -e "$d" ]] && grep -q 'download failed: bpfcompat-third-par else fail "missing license bundle (rc=$rc)"; show; fi d="$tmp/i-licunattested" run licunattested "$GH_ONLY" BPFCOMPAT_VERSION=v0.4.0-rc.5 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" -if [[ $rc -ne 0 && ! -e "$d" ]] && grep -q 'attestation verification FAILED for bpfcompat-third-party-licenses.tar.gz' "$OUT"; then +# The bundle's own verify call refuses it (the whole line), before the +# comparison with the CLI's attested subjects in 11 would. +if [[ $rc -ne 0 && ! -e "$d" ]] && grep -q 'attestation verification FAILED for bpfcompat-third-party-licenses.tar.gz' "$OUT" && + grep -qx 'bpfcompat-install: GitHub attestation verification FAILED for bpfcompat-third-party-licenses.tar.gz' "$OUT"; then ok "unattested license bundle refused on the gh path" else fail "unattested license bundle (rc=$rc)"; show; fi if [[ "$(id -u)" -ne 0 ]]; then @@ -820,6 +854,8 @@ verified "$GH_ONLY" v0.3.7 "$CLI_AMD64" "$VAL_AMD64" || bad+=" verify-calls" [[ "$(cat "$GH_API_LOG")" == "$(probe_path ghapi) 200" ]] || bad+=" probe" [[ "$(grep -nE '^(api |attestation verify [^-])' "$GH_LOG" | head -1)" == *":api --method GET --include $(probe_path ghapi)" ]] || bad+=" probe-not-first" +# Only the CLI's verify call asks for its attested subjects (T4 R-B). +[[ "$(cat "$GH_FMT_LOG")" == "$CLI_AMD64 fmt=json jq=$JQ_SUBJECTS" ]] || bad+=" subjects-request($(tr '\n' '|' <"$GH_FMT_LOG"))" waited 200 || bad+=" waited" if [[ -z "$bad" ]]; then ok "gh path: one read-only API probe of the CLI digest, before the verify calls" else fail "gh path probe (rc=$rc):$bad"; show; sed 's/^/ gh | /' "$GH_LOG" >&2; fi @@ -910,6 +946,132 @@ gh_class "HTTP/1.1 503, three times" ghapi 3 "$C3: HTTP 503 (gh: HTTP 503)" 0 "5 gh_class "HTTP/1.1 403 rate-limited" ghapi 3 "$C3: HTTP 403, rate limited (gh: API rate limit exceeded" 0 403rl \ FAKE_GH_NET=403rl FAKE_GH_PROTO=1.1 +# 11. T4 (remedy R-B): on the gh path SHA256SUMS is unsigned, and an +# attestation binds bytes, not a file name. The CLI's verified provenance +# names every asset its release run attested, so SHA256SUMS must agree +# with it on whether the validator and the license bundle exist, and each +# installed file must be attested under the name it is installed as. +# Every tamper below passes each gh verify call (genuine bytes of the +# requested tag) and the checksum step: only that comparison can catch +# it. It reads as a failed verification (class 4) naming the mismatch; +# with cosign, the edited SHA256SUMS fails its signature. +C4_NOT='authenticat|logged in|credential|GH_TOKEN|network|proxy|unavailable|unreachable|rate.limit|refused|retry|HTTP [0-9]|lacks' +resum() { (cd "$1" && "$REAL_SHA" bpfcompat-* >SHA256SUMS); } +# rb_refused <title> <scenario> <tag> <text after "FAILED for "> <verified assets> [VAR=value...] +rb_refused() { + local title="$1" scen="$2" tag="$3" want="$4" assets="$5" d="$tmp/i-rb" bad="" cli ghrc + shift 5 + cli="${assets%% *}" + run "$scen" "$GH_ONLY" BPFCOMPAT_VERSION="$tag" BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" "$@" + ghrc=$rc + [[ $rc -ne 0 && ! -e "$d" ]] || bad+=" installed-or-rc0" + itmp_clean || bad+=" tmpdir-left" + grep -qxF -- "bpfcompat-install: $C4 $want" "$OUT" || bad+=" reason-not-named" + ! grep -qiE "$C4_NOT" "$OUT" || bad+=" auth-or-network-wording" + ! grep -qE "$C2|$C3|attestations verified|checksums verified" "$OUT" || bad+=" other-outcome-named" + no_ctl || bad+=" control-bytes" + # shellcheck disable=SC2086 # one asset per word + verified "$GH_ONLY" "$tag" $assets || bad+=" verify-calls" + [[ "$(cat "$GH_API_LOG")" == "$(probe_path "$scen" "$tag" "$cli") 200" ]] || bad+=" probe" + [[ "$(cat "$GH_FMT_LOG")" == "$cli fmt=json jq=$JQ_SUBJECTS" ]] || bad+=" subjects-request" + [[ -z "$bad" ]] || show + rm -rf "$d" + run "$scen" "$WITH_COSIGN" BPFCOMPAT_VERSION="$tag" BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" "$@" + if ! { [[ $rc -ne 0 && ! -e "$d" && ! -s "$GH_LOG" ]] && itmp_clean && + grep -q 'cosign signature verification FAILED for SHA256SUMS' "$OUT"; }; then bad+=" cosign-path"; show; fi + rm -rf "$d" + if [[ -z "$bad" ]]; then ok "gh: $title: refused as a failed verification (cosign: bad signature)" + else fail "gh: $title (gh rc=$ghrc, cosign rc=$rc):$bad"; fi +} +# rb_installs <title> <scenario> <tag> <verified assets> [VAR=value...]: an +# untampered release passes the comparison; the installed bytes are the +# verified ones. +rb_installs() { + local title="$1" scen="$2" tag="$3" assets="$4" d="$tmp/i-rbok" bad="" a + shift 4 + run "$scen" "$GH_ONLY" BPFCOMPAT_VERSION="$tag" BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" "$@" + [[ $rc -eq 0 ]] && itmp_clean && grep -q 'GitHub build attestations verified' "$OUT" || bad+=" not-installed" + # shellcheck disable=SC2086 # one asset per word + verified "$GH_ONLY" "$tag" $assets || bad+=" verify-calls" + [[ "$(cat "$GH_FMT_LOG")" == "${assets%% *} fmt=json jq=$JQ_SUBJECTS" ]] || bad+=" subjects-request" + for a in $assets; do + case "$a" in + bpfcompat-linux-*) cmp -s "$(rel_dir "$scen" "$tag")/$a" "$d/bin/bpfcompat" || bad+=" cli-bytes" ;; + bpfcompat-validator-*) cmp -s "$(rel_dir "$scen" "$tag")/$a" "$d/libexec/bpfcompat-validator" || bad+=" validator-bytes" ;; + esac + done + [[ "$assets" == *validator* || ! -e "$d/libexec" ]] || bad+=" unexpected-validator" + if [[ -z "$bad" ]]; then ok "gh: $title: installed" + else fail "gh: $title (rc=$rc):$bad"; show; fi + rm -rf "$d" +} +# S1: the validator line deleted from SHA256SUMS (v0.3.7 and rc.5 shapes). +sed -i "/ $VAL_AMD64\$/d" "$(rel_dir rbval037 v0.3.7)/SHA256SUMS" "$(rel_dir rbval5 v0.4.0-rc.5)/SHA256SUMS" +rb_refused "validator line deleted from SHA256SUMS (v0.3.7)" rbval037 v0.3.7 \ + "$VAL_AMD64: SHA256SUMS does not list it, but the v0.3.7 release provenance of $CLI_AMD64 does" "$CLI_AMD64" +rb_refused "validator line deleted from SHA256SUMS (v0.4.0-rc.5, with the license bundle)" rbval5 v0.4.0-rc.5 \ + "$VAL_AMD64: SHA256SUMS does not list it, but the v0.4.0-rc.5 release provenance of $CLI_AMD64 does" \ + "$CLI_AMD64 bpfcompat-third-party-licenses.tar.gz" +# S2: the license bundle line deleted (rc.5). +sed -i '/ bpfcompat-third-party-licenses\.tar\.gz$/d' "$(rel_dir rblic5 v0.4.0-rc.5)/SHA256SUMS" +rb_refused "license bundle line deleted from SHA256SUMS" rblic5 v0.4.0-rc.5 \ + "bpfcompat-third-party-licenses.tar.gz: SHA256SUMS does not list it, but the v0.4.0-rc.5 release provenance of $CLI_AMD64 does" \ + "$CLI_AMD64 $VAL_AMD64" +# S3: the same tag's arm64 CLI served as the amd64 CLI, its line rewritten. +cp "$(rel_dir rbcli v0.3.7)/bpfcompat-linux-arm64" "$(rel_dir rbcli v0.3.7)/$CLI_AMD64"; resum "$(rel_dir rbcli v0.3.7)" +rb_refused "the tag's arm64 CLI served as the amd64 CLI" rbcli v0.3.7 \ + "$CLI_AMD64: the v0.3.7 release provenance of $CLI_AMD64 does not list these bytes under that name (it lists them as bpfcompat-linux-arm64)" \ + "$CLI_AMD64 $VAL_AMD64" +# S4: the CLI's bytes served as the validator. +cp "$(rel_dir rbval v0.3.7)/$CLI_AMD64" "$(rel_dir rbval v0.3.7)/$VAL_AMD64"; resum "$(rel_dir rbval v0.3.7)" +rb_refused "the CLI's bytes served as the validator" rbval v0.3.7 \ + "$VAL_AMD64: the v0.3.7 release provenance of $CLI_AMD64 does not list these bytes under that name (it lists them as $CLI_AMD64)" \ + "$CLI_AMD64 $VAL_AMD64" +# T4-VERIFY's arm64-host variant: no arm64 validator is published, so +# SHA256SUMS gains a line for one, served with the amd64 validator's bytes. +cp "$(rel_dir rbarm v0.3.7)/$VAL_AMD64" "$(rel_dir rbarm v0.3.7)/bpfcompat-validator-static-linux-arm64" +resum "$(rel_dir rbarm v0.3.7)" +rb_refused "aarch64 host, an arm64 validator added to SHA256SUMS (amd64 bytes)" rbarm v0.3.7 \ + "bpfcompat-validator-static-linux-arm64: SHA256SUMS lists it, but the v0.3.7 release provenance of bpfcompat-linux-arm64 does not" \ + "bpfcompat-linux-arm64 bpfcompat-validator-static-linux-arm64" FAKE_ARCH=aarch64 +# Bytes the same run attested in another statement (release-candidate +# evidence) served as the validator: not in the CLI's provenance at all. +printf '{"tag": "v0.3.7", "checksums_sha256": "x"}\n' >"$tmp/rbsep-evidence.json" +ev="$("$REAL_SHA" <"$tmp/rbsep-evidence.json" | cut -d' ' -f1)" +printf '%s refs/tags/v0.3.7\n' "$ev" >>"$ATTEST_DB" +printf 'rbsep-v0.3.7-evidence refs/tags/v0.3.7 %s release-candidate-evidence.json\n' "$ev" >>"$SUBJ_DB" +cp "$tmp/rbsep-evidence.json" "$(rel_dir rbsep v0.3.7)/$VAL_AMD64"; resum "$(rel_dir rbsep v0.3.7)" +rb_refused "bytes from another statement of the same run served as the validator" rbsep v0.3.7 \ + "$VAL_AMD64: the v0.3.7 release provenance of $CLI_AMD64 does not list these bytes under that name" "$CLI_AMD64 $VAL_AMD64" +# The same bytes served as the CLI: the provenance verified for the CLI is +# then that other statement, which names no validator. +cp "$tmp/rbsep-evidence.json" "$(rel_dir rbsepcli v0.3.7)/$CLI_AMD64"; resum "$(rel_dir rbsepcli v0.3.7)" +rb_refused "bytes from another statement of the same run served as the CLI" rbsepcli v0.3.7 \ + "$VAL_AMD64: SHA256SUMS lists it, but the v0.3.7 release provenance of $CLI_AMD64 does not" "$CLI_AMD64 $VAL_AMD64" +# Controls: untampered releases still install on the gh path (the cases in +# 1, 1b, 2 and 8 are controls too). +rb_installs "untampered v0.3.7 with BPFCOMPAT_NO_VALIDATOR=1" ghapi v0.3.7 "$CLI_AMD64" BPFCOMPAT_NO_VALIDATOR=1 +rb_installs "aarch64 host, untampered: arm64 CLI only" arm64 v0.3.7 bpfcompat-linux-arm64 FAKE_ARCH=aarch64 +# A re-run release job: a second statement for the same tag holds the same +# CLI and a rebuilt validator, which is the one published. +v2="$(rel_dir rbrerun v0.3.7)/$VAL_AMD64" +printf 'validator v0.3.7 amd64, rebuilt by attempt 2\n' >"$v2" +for a in "$CLI_AMD64" bpfcompat-linux-arm64 "$VAL_AMD64"; do + printf 'rbrerun-v0.3.7-attempt2 refs/tags/v0.3.7 %s %s\n' "$("$REAL_SHA" <"$(rel_dir rbrerun v0.3.7)/$a" | cut -d' ' -f1)" "$a" >>"$SUBJ_DB" +done +printf '%s refs/tags/v0.3.7\n' "$("$REAL_SHA" <"$v2" | cut -d' ' -f1)" >>"$ATTEST_DB" +resum "$(rel_dir rbrerun v0.3.7)" +rb_installs "re-run release, validator from the second attested statement" rbrerun v0.3.7 "$CLI_AMD64 $VAL_AMD64" +# With pipefail imported, the name comparison still refuses with its reason. +RUN_SH=(bash --posix) +d="$tmp/i-rb-pipefail" +run rbcli "$GH_ONLY" "$PF" BPFCOMPAT_VERSION=v0.3.7 BPFCOMPAT_BIN_DIR="$d/bin" BPFCOMPAT_LIBEXEC_DIR="$d/libexec" +if [[ $rc -ne 0 && ! -e "$d" ]] && itmp_clean && + grep -qxF "bpfcompat-install: $C4 $CLI_AMD64: the v0.3.7 release provenance of $CLI_AMD64 does not list these bytes under that name (it lists them as bpfcompat-linux-arm64)" "$OUT"; then + ok "pipefail imported, arm64 CLI served as amd64: refused with its reason" +else fail "pipefail imported, arm64 CLI served as amd64 (rc=$rc)"; show; fi +RUN_SH=(sh) + if [[ $failures -ne 0 ]]; then echo "[install-test] $failures failure(s)" >&2 exit 1