diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index c07ba46c..b154a129 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -44,6 +44,16 @@ jobs: || git clone --depth 200 https://github.com/hyperpolymath/standards.git "$HOME/standards" - name: Run staleness check + env: + # The gate confirms a pin server-side before ever accusing it of being + # forged, and before accepting a deny-list negative from a clone it + # cannot trust. Unauthenticated that is 60 requests/hour per runner IP; + # exhausting it makes the gate degrade to ::warning (loudly — it never + # silently passes, see check-workflow-staleness.sh). A token removes + # the cliff entirely. Note this only reaches a consumer once it re-pins + # past this commit: the script comes from standards HEAD, but the job + # definition comes from whatever SHA the consumer pins. + GITHUB_TOKEN: ${{ github.token }} run: | bash "$HOME/standards/scripts/check-workflow-staleness.sh" . diff --git a/docs/decisions/ADR-003-workflow-pin-staleness-window.adoc b/docs/decisions/ADR-003-workflow-pin-staleness-window.adoc index 0f4ad6dc..cff897e9 100644 --- a/docs/decisions/ADR-003-workflow-pin-staleness-window.adoc +++ b/docs/decisions/ADR-003-workflow-pin-staleness-window.adoc @@ -223,3 +223,23 @@ Cost is zero API calls on the happy path — the server is consulted only when the gate is about to accuse. The deny-list gets the same fallback, so a degraded runner cannot silently skip it: "could not check" is reported as SKIPPED, never as passed. + +==== Rate limiting, and why it is not a hole + +The staleness job is given no token, so the compare call is unauthenticated: 60 +requests/hour per runner IP. Exhausting it makes the gate emit `::warning` and +pass — verified by accident while testing, which is exactly the intended +fail-open, and it is *loud*: two warnings naming the pin, never a silent pass. + +Three things keep this from mattering: + +. The common path never calls the API. A pin that is genuinely deny-listed is + caught by a local *positive*, which is trusted without the network — so the + 294 `d7c22711` denials do not depend on it at all. +. The API is consulted only when the gate is about to accuse, or when a + negative comes from a clone it cannot trust. +. `governance-reusable.yml` now passes `GITHUB_TOKEN: ${{ github.token }}` to + the step, which removes the limit. This reaches a consumer only once it + re-pins past that commit — the script is read from `standards` HEAD, but the + job definition comes from the pinned SHA — so it lands naturally with the + propagation that these deny-listed pins need anyway. diff --git a/scripts/check-workflow-staleness.sh b/scripts/check-workflow-staleness.sh index 9d065157..51faa3ea 100755 --- a/scripts/check-workflow-staleness.sh +++ b/scripts/check-workflow-staleness.sh @@ -67,6 +67,9 @@ set -eo pipefail # server-side integrity check (default # hyperpolymath/standards). # STALENESS_STANDARDS_BRANCH— branch a pin must be reachable from (default main). +# STALENESS_API_BASE — API root for the server-side integrity check +# (default https://api.github.com; set for GHES, +# or to an unreachable host in tests). # STALENESS_KNOWN_BAD_BEFORE— override the known-bad deny-list, as space- or # comma-separated : entries. # "-" disables it. For the hermetic fixture tests @@ -82,6 +85,7 @@ WINDOW_DAYS="${STALENESS_WINDOW_DAYS:-14}" # below). Overridable so a fork can point the gate at its own standards. STANDARDS_NWO="${STALENESS_STANDARDS_NWO:-hyperpolymath/standards}" STANDARDS_BRANCH="${STALENESS_STANDARDS_BRANCH:-main}" +STANDARDS_API="${STALENESS_API_BASE:-https://api.github.com}" # ── Known-bad pins: hard fail regardless of age ───────────────────────────── # A recency *window* is only a proxy for "this pin might contain a known @@ -213,8 +217,12 @@ ensure_commit() { clone_is_complete() { [ "$HAVE_HISTORY" = true ] || return 1 local gd - gd=$(git -C "$STANDARDS_DIR" rev-parse --git-dir 2>/dev/null) || return 1 - [ -e "$STANDARDS_DIR/$gd/shallow" ] || [ -e "$gd/shallow" ] && return 1 + # --absolute-git-dir, not --git-dir: the latter returns a path relative to the + # CWD, which here is the *consumer's* checkout. actions/checkout is shallow by + # default, so a relative ".git/shallow" test would see the consumer's marker + # and call every standards clone incomplete. + gd=$(git -C "$STANDARDS_DIR" rev-parse --absolute-git-dir 2>/dev/null) || return 1 + [ -e "$gd/shallow" ] && return 1 [ -n "$(git -C "$STANDARDS_DIR" config --get remote.origin.partialclonefilter 2>/dev/null)" ] && return 1 [ "$(git -C "$STANDARDS_DIR" config --get remote.origin.promisor 2>/dev/null)" = "true" ] && return 1 return 0 @@ -235,7 +243,7 @@ resolve_negative() { api_compare() { local pin="$1" head="${2:-$STANDARDS_BRANCH}" url body code st ahead behind command -v curl >/dev/null 2>&1 || return 1 - url="https://api.github.com/repos/${STANDARDS_NWO}/compare/${pin}...${head}" + url="${STANDARDS_API}/repos/${STANDARDS_NWO}/compare/${pin}...${head}" local -a auth=() [ -n "${GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}") body=$(curl -sS --max-time 20 -w '\n%{http_code}' \ @@ -298,7 +306,13 @@ pin_is_known_bad() { KNOWN_BAD_FIX="$fix_full" return 0 fi - continue + # Local says "not affected". A local POSITIVE is trustworthy (no false + # positives observed), but a local NEGATIVE here is a FALSE GREEN — the + # exact failure this list exists to prevent — and the same + # `merge-base --is-ancestor` call is measured unreliable on the partial + # clone CI actually uses. Trust the negative only from a complete clone; + # otherwise fall through and confirm with the server. + clone_is_complete && continue fi # Degraded clone: ask the server rather than skip. A check that quietly diff --git a/scripts/tests/check-workflow-staleness-test.sh b/scripts/tests/check-workflow-staleness-test.sh index 54c06f68..6c211fa5 100755 --- a/scripts/tests/check-workflow-staleness-test.sh +++ b/scripts/tests/check-workflow-staleness-test.sh @@ -91,7 +91,12 @@ run_case_out() { bash "$CHECK_SCRIPT" "$repo" 2>&1) rc=$? set -e - if [ "$rc" -eq "$expected" ] && printf '%s' "$out" | grep -qE "$regex"; then + # A leading '!' inverts the match: assert the gate did NOT say this. + local want=0 + case "$regex" in '!'*) want=1; regex="${regex#!}" ;; esac + local got=0 + printf '%s' "$out" | grep -qE "$regex" || got=1 + if [ "$rc" -eq "$expected" ] && [ "$got" -eq "$want" ]; then echo "PASS: $desc" PASS=$((PASS + 1)) else @@ -214,6 +219,49 @@ write_pin "$R/.github/workflows/hypatia.yml" "hypatia-scan-reusable.yml" "$C4" write_pin "$R/.github/workflows/scorecard.yml" "scorecard-reusable.yml" "$C4" run_case "clean multi-reusable repo passes" 0 "$R" +# ── 13. THE PRODUCTION PATH: partial clone, where a local negative is not +# trustworthy ────────────────────────────────────────────────────────── +# In CI the governance reusable clones standards with `--filter=tree:0`, so the +# checkout is ALWAYS partial — and `merge-base --is-ancestor` is measured +# unreliable there (awesome-haskell, four consecutive runs). Cases 1-12 all run +# against a complete `git init` fixture, so none of them exercise the code that +# actually protects production. Mark the fixture partial to reach it. +# +# The API is pointed at a closed port so these stay hermetic and fast: the +# question is what the gate does when it can neither trust the clone nor reach +# the server. +UNREACHABLE="http://127.0.0.1:1" +git -C "$FIX" config remote.origin.promisor true + +# 13a. A local POSITIVE is still trusted — a deny must not need the network. +R="$TEST_DIR/partial-deny"; mk_repo "$R" +write_pin "$R/.github/workflows/governance.yml" "governance-reusable.yml" "$C1" +run_case_out "partial clone: local positive still denies without the network" 1 \ + 'predates' "$R" \ + STALENESS_WINDOW_COMMITS=9999 STALENESS_WINDOW_DAYS=9999 \ + STALENESS_API_BASE="$UNREACHABLE" \ + STALENESS_KNOWN_BAD_BEFORE="governance-reusable.yml:$C3" + +# 13b. A local NEGATIVE must NOT be silently accepted. Unverifiable is reported +# as SKIPPED — "could not check" must never read as "passed". +R="$TEST_DIR/partial-unchecked"; mk_repo "$R" +write_pin "$R/.github/workflows/governance.yml" "governance-reusable.yml" "$C4" +run_case_out "partial clone: unverifiable deny-list check is reported SKIPPED, not passed" 0 \ + 'SKIPPED, not passed' "$R" \ + STALENESS_WINDOW_COMMITS=9999 STALENESS_WINDOW_DAYS=9999 \ + STALENESS_API_BASE="$UNREACHABLE" \ + STALENESS_KNOWN_BAD_BEFORE="governance-reusable.yml:$C3" + +# 13c. A complete clone's negative IS trusted — no warning, no network. +git -C "$FIX" config --unset remote.origin.promisor +R="$TEST_DIR/complete-quiet"; mk_repo "$R" +write_pin "$R/.github/workflows/governance.yml" "governance-reusable.yml" "$C4" +run_case_out "complete clone: local negative is trusted silently (no SKIPPED warning)" 0 \ + '!SKIPPED' "$R" \ + STALENESS_WINDOW_COMMITS=9999 STALENESS_WINDOW_DAYS=9999 \ + STALENESS_API_BASE="$UNREACHABLE" \ + STALENESS_KNOWN_BAD_BEFORE="governance-reusable.yml:$C3" + echo "----------------------------------------" echo "$PASS/$TOTAL test cases passed." [ "$PASS" -eq "$TOTAL" ] || { echo "Some regression tests FAILED."; exit 1; }