From e7dd40ffc18b2d6ca74f1111de71d25a99d34791 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:35:47 +0000 Subject: [PATCH 1/2] fix(ci): keep the dispatch PAT out of the job that runs panic-attack estate-rescan built panic-attack from an unpinned HEAD and ran it over ~300 repositories in the same job that held HYPATIA_DISPATCH_PAT. Worse, `Checkout verisimdb-data` cloned with the PAT in the URL, so it sat in that clone's .git/config, readable by every later step, the freshly built scanner included. Split into three jobs: - preflight: the writability probe. Holds the PAT, runs only `gh api`, harden-runner with egress blocked to api.github.com. - scan: builds panic-attack pinned at ac68bba (its current HEAD) and scans. Holds no credential: the repo list is public metadata, read with the ambient github.token. Scans leave as an artifact. - publish: downloads the artifact, commits and publishes exactly as before (two squash-merged PRs). Holds the PAT, runs no fetched code, harden-runner with egress blocked to GitHub and the artifact service. A run that scans nothing is still a green no-op: no artifact, no publish. Dry runs skip preflight and publish, as before. The new `uses:` need lock entries; a one-off on this branch regenerates actions.lock with the tool (as in #958) and is removed before merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfStHgMdZGTeqaBgM1 --- .github/workflows/estate-rescan.yml | 166 ++++++++++++------ .../zz-one-off-lock-regen-estate-rescan.yml | 70 ++++++++ 2 files changed, 185 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/zz-one-off-lock-regen-estate-rescan.yml diff --git a/.github/workflows/estate-rescan.yml b/.github/workflows/estate-rescan.yml index 55b97534..5c2c6c81 100644 --- a/.github/workflows/estate-rescan.yml +++ b/.github/workflows/estate-rescan.yml @@ -64,25 +64,41 @@ concurrency: cancel-in-progress: false jobs: - rescan: - name: Rescan estate with panic-attack - runs-on: ubuntu-latest - timeout-minutes: 300 + # The credential and the scanner never share a job. + # + # `scan` builds panic-attack and runs it over ~300 repositories' worth of + # third-party content. Until this split it ran in the same job as + # HYPATIA_DISPATCH_PAT, and `Checkout verisimdb-data` left that PAT in the + # clone's .git/config, readable by every later step, the freshly built + # scanner included. Now `scan` holds no credential and hands its output to + # `publish` as an artifact; the two jobs that hold the PAT run no fetched + # code and install harden-runner with an egress allow-list. + # Fail in seconds, not in twenty minutes. + # + # The write to verisimdb-data is the LAST thing this workflow does, after a + # ~300-repo scan. So every time the token was wrong, the run burned the + # entire scan and then died at the final step. That is how a hard + # permission wall stayed unnoticed for 26 days: the failure was + # expensive to reach and looked like a flaky tail-end push. + # + # This probe is the same differential test that finally identified the + # fault: create a ref and delete it again. It touches no content, makes + # no commit, and takes about a second. + preflight: + name: Preflight — can we actually write to verisimdb-data? + if: inputs.dry_run != 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 steps: - # Fail in seconds, not in twenty minutes. - # - # The write to verisimdb-data is the LAST thing this job does, after a - # ~300-repo scan. So every time the token was wrong, the run burned the - # entire scan and then died at the final step. That is how a hard - # permission wall stayed unnoticed for 26 days: the failure was - # expensive to reach and looked like a flaky tail-end push. - # - # This probe is the same differential test that finally identified the - # fault: create a ref and delete it again. It touches no content, makes - # no commit, and takes about a second. - - name: Preflight — can we actually write to verisimdb-data? - if: inputs.dry_run != 'true' + - name: Harden the runner + uses: step-security/harden-runner@v2.22.2 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + + - name: Create and delete a probe branch env: GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} run: | @@ -99,10 +115,21 @@ jobs: gh api -X DELETE "repos/${R}/git/refs/heads/${PROBE}" >/dev/null echo "verisimdb-data is writable — proceeding with the scan." - - name: Resolve panic-attack version - id: pa_ref - run: echo "sha=$(git ls-remote https://github.com/hyperpolymath/panic-attack.git HEAD | cut -f1)" >> "$GITHUB_OUTPUT" + scan: + name: Rescan estate with panic-attack + needs: preflight + # A dry run skips the preflight; anything else must pass it first. + if: ${{ !cancelled() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped') }} + runs-on: ubuntu-latest + timeout-minutes: 300 + outputs: + scanned: ${{ steps.scan.outputs.scanned }} + env: + # Pinned, not HEAD: this job runs whatever it builds over the whole + # estate. Bump deliberately, after reviewing panic-attack's changes. + PA_SHA: ac68bbaeaeca515700bb400f1a0703a37b59a48f + steps: - name: Cache built panic-attack id: pa_cache uses: actions/cache@v6.1.0 @@ -110,12 +137,10 @@ jobs: path: ~/panic-attack-bin # Exact-SHA key, no restore-keys: a partial hit would scan with # stale rules (same reasoning as the hypatia-scan build cache). - key: panic-attack-${{ runner.os }}-${{ steps.pa_ref.outputs.sha }} + key: panic-attack-${{ runner.os }}-${{ env.PA_SHA }} - name: Build panic-attack (if needed) if: steps.pa_cache.outputs.cache-hit != 'true' - env: - PA_SHA: ${{ steps.pa_ref.outputs.sha }} run: | set -euo pipefail git clone --depth 1 https://github.com/hyperpolymath/panic-attack.git "$RUNNER_TEMP/panic-attack" @@ -128,7 +153,9 @@ jobs: - name: Resolve repo list id: repos env: - GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + # Public metadata only: the ambient token is enough, and this job + # must hold no credential (see the header of `scan`). + GH_TOKEN: ${{ github.token }} INPUT_REPOS: ${{ inputs.repos }} PAYLOAD_REPOS: ${{ github.event.client_payload.repos }} run: | @@ -164,19 +191,13 @@ jobs: echo "count=$COUNT" >> "$GITHUB_OUTPUT" echo "Rescanning $COUNT repos" - - name: Checkout verisimdb-data - env: - GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - run: | - set -euo pipefail - git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/verisimdb-data.git" "$RUNNER_TEMP/verisimdb-data" - - name: Scan repos id: scan run: | set -uo pipefail PA="$HOME/panic-attack-bin/panic-attack" - DATA="$RUNNER_TEMP/verisimdb-data" + OUT="$RUNNER_TEMP/scans" + mkdir -p "$OUT" FAILED=0 SCANNED=0 while IFS= read -r name; do @@ -192,7 +213,7 @@ jobs: # meaningless to consumers and leaked local paths were one of # the defects of the manual-scan era (`/var$REPOS_DIR/...`). jq --arg name "$name" '.program_path = $name' \ - "$RUNNER_TEMP/scan-raw.json" > "$DATA/scans/$name.json" + "$RUNNER_TEMP/scan-raw.json" > "$OUT/$name.json" SCANNED=$((SCANNED+1)) else echo "::warning::scan failed: $name" @@ -204,17 +225,75 @@ jobs: echo "failed=$FAILED" >> "$GITHUB_OUTPUT" echo "Scanned $SCANNED repos ($FAILED failures)" + # Nothing scanned (e.g. a targeted request naming only ineligible + # repos) is a no-op, as it was before the split: no artifact, no publish. + - name: Hand the scans to the publish job + if: steps.scan.outputs.scanned != '0' + uses: actions/upload-artifact@v7.0.2 + with: + name: estate-scans + path: ${{ runner.temp }}/scans/ + if-no-files-found: error + retention-days: 1 + + - name: Job summary + env: + COUNT: ${{ steps.repos.outputs.count }} + SCANNED: ${{ steps.scan.outputs.scanned }} + FAILED: ${{ steps.scan.outputs.failed }} + DRY: ${{ inputs.dry_run }} + run: | + { + echo "## Estate Rescan" + echo "- Repos targeted: $COUNT" + echo "- Scanned OK: $SCANNED" + echo "- Failures: $FAILED" + echo "- Dry run: ${DRY:-false}" + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish scans to verisimdb-data + needs: scan + if: inputs.dry_run != 'true' && needs.scan.outputs.scanned != '0' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Harden the runner + uses: step-security/harden-runner@v2.22.2 + with: + egress-policy: block + # git and gh against GitHub, plus the artifact service and its + # blob storage for the download. + allowed-endpoints: > + github.com:443 + api.github.com:443 + results-receiver.actions.githubusercontent.com:443 + *.blob.core.windows.net:443 + + - name: Fetch the scans + uses: actions/download-artifact@v8.0.2 + with: + name: estate-scans + path: ${{ runner.temp }}/scans + + - name: Checkout verisimdb-data + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + run: | + set -euo pipefail + git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/verisimdb-data.git" "$RUNNER_TEMP/verisimdb-data" + - name: Commit refreshed scans id: commit_scans - if: inputs.dry_run != 'true' env: - SCANNED: ${{ steps.scan.outputs.scanned }} + SCANNED: ${{ needs.scan.outputs.scanned }} run: | set -euo pipefail DATA="$RUNNER_TEMP/verisimdb-data" cd "$DATA" git config user.name "hypatia-estate-rescan" git config user.email "hypatia-bot@users.noreply.github.com" + cp "$RUNNER_TEMP/scans/"*.json scans/ git add scans/ if git diff --cached --quiet; then echo "No scan changes to push." @@ -225,7 +304,7 @@ jobs: git commit -m "scan: estate rescan ($SCANNED repos, panic-attack refresh)" - name: Publish to verisimdb-data (two signed, squash-merged PRs) - if: inputs.dry_run != 'true' && steps.commit_scans.outputs.changed == 'true' + if: steps.commit_scans.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} RUN_ID: ${{ github.run_id }} @@ -320,18 +399,3 @@ jobs: # ---- PR 2: index only, touching no file under scans/ -------------- publish "index" "index: regen after estate rescan (run ${RUN_ID})" - - - name: Job summary - env: - COUNT: ${{ steps.repos.outputs.count }} - SCANNED: ${{ steps.scan.outputs.scanned }} - FAILED: ${{ steps.scan.outputs.failed }} - DRY: ${{ inputs.dry_run }} - run: | - { - echo "## Estate Rescan" - echo "- Repos targeted: $COUNT" - echo "- Scanned OK: $SCANNED" - echo "- Failures: $FAILED" - echo "- Dry run: ${DRY:-false}" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml b/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml new file mode 100644 index 00000000..0850338e --- /dev/null +++ b/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: MPL-2.0 +# ONE-OFF, this PR's branch only; deleted before the PR merges. +# +# The estate-rescan split adds harden-runner, upload-artifact and +# download-artifact to estate-rescan.yml, which actions.lock does not yet +# record for that workflow, so GitHub would refuse to start it. actions.lock +# is tool-owned, so this job regenerates it with gh actions-lock v0.2.0 and +# the estate repair chain (regen_lock in standards +# scripts/regen-dependabot-locks.sh, lock-only, workflow YAML restored), +# checks it with the governance gate's own verifier, and prints ONLY the lock +# (base64 + sha256). GITHUB_TOKEN cannot push under .github/workflows/, so +# the PR commits these exact bytes and checks the sha256 matches. Same +# mechanism as #958. +# +# It has no `uses:` steps, so it needs no lock entry to start, and it is +# removed from the tree before the tool runs so the lock never records it. +name: one-off actions.lock regeneration (estate-rescan split) +on: + push: + branches: [claude/estate-rescan-split] +permissions: + contents: read +concurrency: + group: lock-regen-estate-rescan + cancel-in-progress: false +jobs: + regen: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + STANDARDS_SHA: 333b06a01db1874ffedb2de1bfabb195640927c7 + LOCK_PATH: .github/workflows/actions.lock + steps: + - name: Regenerate actions.lock, verify it, and print it + run: | + set -euo pipefail + git clone --quiet --branch "$BRANCH" "https://github.com/$REPO.git" r + git clone --quiet https://github.com/metadatastician/standards.git std + git -C std checkout --quiet --detach "$STANDARDS_SHA" + sudo apt-get install -y -qq gawk >/dev/null + gh extension install github/gh-actions-lock --pin v0.2.0 + gh actions-lock --version + tools="$PWD/std/scripts" + cd r + git rm --quiet --cached .github/workflows/zz-one-off-lock-regen-estate-rescan.yml + rm .github/workflows/zz-one-off-lock-regen-estate-rescan.yml + # The tool may exit non-zero yet rewrite the lock; the verifier decides. + gh actions-lock || echo "gh actions-lock exited $?; the verifier decides" + git checkout --quiet -- . ":(exclude)$LOCK_PATH" + git clean -fdq + for s in relock-sha-keys complete-job-refs close-lock prune-stale; do + GH_BIN=gh bash "$tools/$s.sh" + done + if grep -Fq '$/' "$LOCK_PATH"; then echo "::error::lock carries an invalid \$/ path"; exit 1; fi + GH_BIN=gh bash "$tools/update-actions-lock.sh" --verify-local + bash "$tools/check-lock-sync.sh" + ACTIONS_LOCK_VERIFIER="$tools/update-actions-lock.sh" bash "$tools/check-actions-lock-gate.sh" .github/workflows + git diff --stat -- "$LOCK_PATH" + if git diff --quiet -- "$LOCK_PATH"; then + echo "::error::the lock did not change" + exit 1 + fi + git diff -- "$LOCK_PATH" + echo "LOCK-SHA256 $(sha256sum < "$LOCK_PATH" | cut -d' ' -f1)" + echo "LOCK-BASE64-BEGIN" + base64 -w0 < "$LOCK_PATH"; echo + echo "LOCK-BASE64-END" From cce6872c7200369f2457270928c791517e0ca761 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:38:41 +0000 Subject: [PATCH 2/2] ci: regenerate actions.lock for the estate-rescan split; drop the one-off These are the exact bytes `gh actions-lock` v0.2.0 plus the standards 333b06a repair chain produced in run 40433509902, which also passed update-actions-lock.sh --verify-local, check-lock-sync.sh and the governance gate's check-actions-lock-gate.sh. sha256 42e5e20846fcadb1bf6c3f987b57bb647f4ce246b3e40ec45ec403244623a286, matching the LOCK-SHA256 that run printed. Besides the three estate-rescan entries (harden-runner, upload-artifact, download-artifact), the tool re-keys the five standards callers from hyperpolymath/standards to metadatastician/standards: main's lock still records the old name, because #958 merged without its lock commit. It also records the leftover zz-one-off-lock-regen.yml from #936, which is still in the tree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfStHgMdZGTeqaBgM1 --- .github/workflows/actions.lock | 23 ++++-- .../zz-one-off-lock-regen-estate-rescan.yml | 70 ------------------- 2 files changed, 16 insertions(+), 77 deletions(-) delete mode 100644 .github/workflows/zz-one-off-lock-regen-estate-rescan.yml diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 1c252b85..8519497d 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -43,12 +43,15 @@ workflows: '.github/workflows/escript-soundness.yml': [] '.github/workflows/estate-rescan.yml': - 'actions/cache@v6.1.0' + - 'actions/download-artifact@v8.0.2' + - 'actions/upload-artifact@v7.0.2' + - 'step-security/harden-runner@v2.22.2' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/hypatia-remediation-sweep.yml': - 'actions/upload-artifact@v7.0.2' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/inbox-steward-intake.yml': - 'actions/checkout@v7.0.1' '.github/workflows/label-triage.yml': [] @@ -61,7 +64,7 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'erlef/setup-beam@v1.24.1' '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -99,9 +102,9 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/security-policy.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' @@ -131,6 +134,7 @@ workflows: '.github/workflows/zig.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' + '.github/workflows/zz-one-off-lock-regen.yml': [] dependencies: 'actions/attest-build-provenance@v4.2.2': ref: 'v4.2.2' @@ -357,10 +361,10 @@ dependencies: commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7': + 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7': ref: '333b06a01db1874ffedb2de1bfabb195640927c7' commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' - owner_id: 6759885 + owner_id: 297579217 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' @@ -394,6 +398,11 @@ dependencies: commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 + 'step-security/harden-runner@v2.22.2': + ref: 'v2.22.2' + commit: 'sha1-dc2769abcc0fcfbadfc1df54081c1608579ef6b2' + owner_id: 88700172 + repo_id: 422287306 'swatinem/rust-cache@v2.9.3': ref: 'v2.9.3' commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' diff --git a/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml b/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml deleted file mode 100644 index 0850338e..00000000 --- a/.github/workflows/zz-one-off-lock-regen-estate-rescan.yml +++ /dev/null @@ -1,70 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# ONE-OFF, this PR's branch only; deleted before the PR merges. -# -# The estate-rescan split adds harden-runner, upload-artifact and -# download-artifact to estate-rescan.yml, which actions.lock does not yet -# record for that workflow, so GitHub would refuse to start it. actions.lock -# is tool-owned, so this job regenerates it with gh actions-lock v0.2.0 and -# the estate repair chain (regen_lock in standards -# scripts/regen-dependabot-locks.sh, lock-only, workflow YAML restored), -# checks it with the governance gate's own verifier, and prints ONLY the lock -# (base64 + sha256). GITHUB_TOKEN cannot push under .github/workflows/, so -# the PR commits these exact bytes and checks the sha256 matches. Same -# mechanism as #958. -# -# It has no `uses:` steps, so it needs no lock entry to start, and it is -# removed from the tree before the tool runs so the lock never records it. -name: one-off actions.lock regeneration (estate-rescan split) -on: - push: - branches: [claude/estate-rescan-split] -permissions: - contents: read -concurrency: - group: lock-regen-estate-rescan - cancel-in-progress: false -jobs: - regen: - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.ref_name }} - STANDARDS_SHA: 333b06a01db1874ffedb2de1bfabb195640927c7 - LOCK_PATH: .github/workflows/actions.lock - steps: - - name: Regenerate actions.lock, verify it, and print it - run: | - set -euo pipefail - git clone --quiet --branch "$BRANCH" "https://github.com/$REPO.git" r - git clone --quiet https://github.com/metadatastician/standards.git std - git -C std checkout --quiet --detach "$STANDARDS_SHA" - sudo apt-get install -y -qq gawk >/dev/null - gh extension install github/gh-actions-lock --pin v0.2.0 - gh actions-lock --version - tools="$PWD/std/scripts" - cd r - git rm --quiet --cached .github/workflows/zz-one-off-lock-regen-estate-rescan.yml - rm .github/workflows/zz-one-off-lock-regen-estate-rescan.yml - # The tool may exit non-zero yet rewrite the lock; the verifier decides. - gh actions-lock || echo "gh actions-lock exited $?; the verifier decides" - git checkout --quiet -- . ":(exclude)$LOCK_PATH" - git clean -fdq - for s in relock-sha-keys complete-job-refs close-lock prune-stale; do - GH_BIN=gh bash "$tools/$s.sh" - done - if grep -Fq '$/' "$LOCK_PATH"; then echo "::error::lock carries an invalid \$/ path"; exit 1; fi - GH_BIN=gh bash "$tools/update-actions-lock.sh" --verify-local - bash "$tools/check-lock-sync.sh" - ACTIONS_LOCK_VERIFIER="$tools/update-actions-lock.sh" bash "$tools/check-actions-lock-gate.sh" .github/workflows - git diff --stat -- "$LOCK_PATH" - if git diff --quiet -- "$LOCK_PATH"; then - echo "::error::the lock did not change" - exit 1 - fi - git diff -- "$LOCK_PATH" - echo "LOCK-SHA256 $(sha256sum < "$LOCK_PATH" | cut -d' ' -f1)" - echo "LOCK-BASE64-BEGIN" - base64 -w0 < "$LOCK_PATH"; echo - echo "LOCK-BASE64-END"