From 1ba3f5b0670c475506db73e214da20058d59191f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:29:18 +0100 Subject: [PATCH] fix(apply-workflow-pins): list installation repos under an App token list_repos enumerated with users//repos (falling back to orgs//repos). Both answer an App installation token (ghs_) with public repositories only, so the private repositories the applier App can write were never audited or re-pointed. Under a ghs_ token the installation's own repositories (from installation/repositories, archived ones dropped, filtered to --owners) are now ADDED to the public listings. They do not replace them: GITHUB_TOKEN is ghs_ too and its installation is this one repository, so a replacement would shrink the scheduled audit census to standards. Under a PAT nothing changes and installation/repositories is never called. Enumeration now fails closed. A failed installation listing, or an owner whose users/ and orgs/ listings both fail, returns 1 and prints nothing, and main stops before writing a census. Before, the second listing's stderr went to /dev/null and its failure was ignored, so a rate-limited owner silently contributed zero repositories. tests/test_apply_workflow_pins_remote.sh gains section 4: a gh stub that serves listing fixtures and refuses installation/repositories to a PAT, two planted positives, nine cases (two end to end through main) and six mutants, each checked with bash -n and killed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015bTuGfwCcvjrmNFejydTML --- scripts/apply-workflow-pins-remote.sh | 52 ++++- tests/test_apply_workflow_pins_remote.sh | 282 +++++++++++++++++++++++ 2 files changed, 327 insertions(+), 7 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index c7dade0ac..0c9ce9bbc 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -301,15 +301,48 @@ refine_behind() { done < <(pin_shas "$1" | sort -u) } +# list_repos — print every non-archived repository of the owners in OWNERS that +# this credential can see, one full_name per line, sorted and deduplicated. +# +# Every credential gets each owner's PUBLIC listing. Under a GitHub App +# installation token (prefix ghs_) the installation's own repositories are ADDED +# to it: users//repos and orgs//repos return public repositories only to an +# installation, so the private repositories the App can write never reached the +# census. The installation listing never REPLACES the public one, because +# GITHUB_TOKEN is ghs_ too and its installation is this one repository; a +# replacement would shrink the audit-mode census to standards alone. +# +# Returns 1 and prints nothing when any listing fails. A rate-limited owner that +# silently contributed zero repositories would read as an owner with nothing to +# re-point, which is the same fail-open fetch_workflows was cured of. list_repos() { - local owner + local owner all part tok="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + all=$(mktemp); part=$(mktemp) for owner in ${OWNERS//,/ }; do - # /users//repos covers a user; if that 404s the owner is an org. - gh api "users/${owner}/repos" --paginate \ - --jq '.[] | select(.archived == false) | .full_name' 2>/dev/null \ - || gh api "orgs/${owner}/repos" --paginate \ - --jq '.[] | select(.archived == false) | .full_name' 2>/dev/null + # users//repos covers a user; if that fails the owner may be an org. + if gh api "users/${owner}/repos" --paginate \ + --jq '.[] | select(.archived == false) | .full_name' > "$part" \ + || gh api "orgs/${owner}/repos" --paginate \ + --jq '.[] | select(.archived == false) | .full_name' > "$part"; then + cat "$part" >> "$all" + else + log "FATAL: could not list the repositories of ${owner}: users/${owner}/repos and orgs/${owner}/repos both failed."; rm -f "$all" "$part"; return 1 + fi done + case "$tok" in + ghs_*) + gh api --paginate 'installation/repositories?per_page=100' \ + --jq '.repositories[] | select(.archived | not) | .full_name' > "$part" \ + || { log "FATAL: an App installation token could not list installation/repositories."; rm -f "$all" "$part"; return 1; } + # One installation belongs to one account; keep only the owners being + # walked, so --owners narrows the census under an App token as well. + for owner in ${OWNERS//,/ }; do + awk -v o="${owner,,}/" 'index(tolower($0), o) == 1' "$part" >> "$all" + done + ;; + esac + sort -u "$all" + rm -f "$all" "$part" } # fetch_workflows — download .github/workflows/*.y*ml. @@ -480,7 +513,12 @@ main() { WORKDIR=$(mktemp -d); trap 'rm -rf "${WORKDIR:-}"' EXIT local repos n=0 - if [ -n "$ONLY_REPO" ]; then repos="$ONLY_REPO"; else repos=$(list_repos); fi + if [ -n "$ONLY_REPO" ]; then + repos="$ONLY_REPO" + elif ! repos=$(list_repos); then + log "FATAL: repository enumeration failed for ${OWNERS}. Refusing to report a partial census." + exit 1 + fi [ -n "$repos" ] || { log "FATAL: enumerated zero repositories for ${OWNERS} (rate limit or auth?). Refusing to report an empty census."; exit 1; } local repo diff --git a/tests/test_apply_workflow_pins_remote.sh b/tests/test_apply_workflow_pins_remote.sh index 2e3351fd8..9c75f49a4 100755 --- a/tests/test_apply_workflow_pins_remote.sh +++ b/tests/test_apply_workflow_pins_remote.sh @@ -183,6 +183,288 @@ else *) fail "mutant 'fetch_fail_open' stayed GREEN" ;; esac fi +# --- 4. list_repos sees every repository the credential can see --------------- +# Under a GitHub App installation token (ghs_), users//repos and +# orgs//repos return PUBLIC repositories only, so the private repositories the +# App can write never reached the census (finding 2026-10-08). The cure ADDS +# installation/repositories. It must not REPLACE the public listing: GITHUB_TOKEN +# is ghs_ too, its installation is one repository, and the scheduled audit runs +# on it while no App is configured. +echo "== 4. enumeration under App, GITHUB_TOKEN and PAT credentials ==" +command -v jq >/dev/null || { fail "jq is required by section 4"; exit 1; } +T="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +LSTUB="$TMP/lstub"; mkdir -p "$LSTUB" +cat > "$LSTUB/gh" <<'EOF' +#!/usr/bin/env bash +# gh — test double serving repository listings and workflow trees from +# $GH_FIX, and modelling which token kinds may call installation/repositories. +echo "$*" >> "$GH_FIX/calls.log" +[ "${1:-} ${2:-}" = "auth status" ] && exit 0 +[ "${1:-}" = api ] || exit 64 +shift +path="" jqx="" gql=0 +while [ $# -gt 0 ]; do + case "$1" in + --jq|-q) jqx="$2"; shift ;; + -F|-f|-H) shift ;; + -*) ;; + graphql) gql=1 ;; + *) [ -n "$path" ] || path="$1" ;; + esac + shift +done +# refuse — fail the way gh does on a non-2xx response: message on stderr, rc 1. +refuse() { echo "gh: $2 (HTTP $1)" >&2; exit 1; } +if [ "$gql" = 1 ]; then + # Every repository holds one workflow, pinned FRESH at $TARGET. + jq -n --arg sha "$TARGET" '{data: {repository: {object: {entries: [{name: "ci.yml", type: "blob", + object: {text: ("jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@" + $sha + "\n")}}]}}}}' + exit 0 +fi +p="${path%%\?*}" +case "$p" in + users/*/repos|orgs/*/repos) + kind="${p%%/*}"; o="${p#*/}"; o="${o%/repos}" + [ -f "$GH_FIX/${kind}_fail_$o" ] && refuse 403 "API rate limit exceeded" + body="$GH_FIX/${kind}_$o.json" ;; + installation/repositories) + case "${GH_TOKEN:-}" in ghs_*) ;; *) refuse 403 "This endpoint requires an installation access token" ;; esac + [ -f "$GH_FIX/inst_fail" ] && refuse 502 "Bad Gateway" + body="$GH_FIX/inst.json" ;; + *) refuse 404 "Not Found" ;; +esac +[ -f "$body" ] || refuse 404 "Not Found" +if [ -n "$jqx" ]; then jq -r "$jqx" "$body"; else cat "$body"; fi +EOF +chmod +x "$LSTUB/gh" + +# Mutants are sourced, and the applier sources lib/ beside itself. +MUT="$TMP/mut"; mkdir -p "$MUT" +ln -s "$(cd "$(dirname "$APPLIER")" && pwd)/lib" "$MUT/lib" + +# fixture_repos ... — write a REST repository list. +fixture_repos() { + local f="$1"; shift + printf '%s\n' "$@" \ + | jq -R 'split(":") | {full_name: .[0], archived: (.[1] == "true")}' | jq -s . > "$f" +} + +# new_case