From b329771897b86f5e853211fdccfb92bd3a1733b7 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 19:55:22 -0700 Subject: [PATCH 1/8] fix(security): scope cross-org PAT git credential, gate PR-event use behind opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests-runner.yml and coverage-floor.yml wrote AUTOMERGE_PAT into a GLOBAL git insteadOf rewrite before running uv sync + pytest — both execute PR-controlled code on same-repo pull_request events, so any PR could read the cross-org PAT via git's global url config and exfiltrate it (found by codex review on wxa-secrets#28, 2026-07-16). Changes to both reusables' python paths: - Credential now lives in a scoped throwaway config file under $RUNNER_TEMP (exposed via GIT_CONFIG_GLOBAL), never in global config, and is deleted before the test invocation in every install branch. - Test invocation becomes `uv run --no-sync pytest` so tests can never trigger a credential-needing re-resolve. - New `use_pat_for_git_deps` input (default false): pull_request runs no longer materialize the PAT at all unless the caller explicitly opts in (repos with private git-URL deps, e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) keep materializing it whenever the secret is forwarded, so coverage-floor's seed-PR flow is unaffected. - coverage-floor gets an always() scrub step so no later step (including the third-party sticky-comment action) can read the file on early-exit paths. Residual, documented: repos that opt in still expose the PAT to PR- controlled build hooks DURING dependency resolution — unavoidable while uv needs the credential to clone. Opt-in scopes that exposure to the two repos that need it instead of the whole fleet. selftest: test_workflow_guards.py gains a repo-wide "no global git config writes" guard and per-workflow gating/scrub/ordering assertions. Auto-merge rationale: manual merge — PRs to ci-workflows are always manual per policy, and this is secrets-handling + workflow surface. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 87 ++++++++++++++++++++++------ .github/workflows/tests-runner.yml | 82 +++++++++++++++++++++----- selftest/test_workflow_guards.py | 59 +++++++++++++++++++ 3 files changed, 193 insertions(+), 35 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index 8005eda..98c060f 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -87,6 +87,11 @@ on: required: false type: string default: "" + use_pat_for_git_deps: + description: "Materialize AUTOMERGE_PAT as a git credential on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when the secret is forwarded, without this input. Either way the credential lives in a scoped throwaway config file that is deleted before the test invocation, never in global git config. Unrelated to the seed-PR flow, which uses the secret only on push:main." + required: false + type: boolean + default: false services_postgres: description: "Spin up a postgres container on port 5432 and export DATABASE_URL + TEST_DATABASE_URL to the test step. Default false." required: false @@ -182,7 +187,7 @@ on: default: "" secrets: AUTOMERGE_PAT: - description: "PAT used to push the seed branch and open the seed PR so that pull_request workflows actually fire (GITHUB_TOKEN doesn't trigger them — recursion prevention). Already deployed fleet-wide for `claude-author-automerge.yml`. Caller must explicitly forward this secret (see callers/coverage-floor.yml in topcoder1/dotclaude for the syntax). Required for the post-merge seed flow to land without manual unblock." + description: "PAT used to push the seed branch and open the seed PR so that pull_request workflows actually fire (GITHUB_TOKEN doesn't trigger them — recursion prevention). Already deployed fleet-wide for `claude-author-automerge.yml`. Caller must explicitly forward this secret (see callers/coverage-floor.yml in topcoder1/dotclaude for the syntax). Required for the post-merge seed flow to land without manual unblock — keep forwarding it for that flow regardless of git-dep needs. Also usable as a scoped git credential for git-URL dependency clones during install: on push:main whenever forwarded, on pull_request only when the caller sets `use_pat_for_git_deps: true` (PR runs execute PR-controlled code, so materializing the credential there is an explicit opt-in)." required: false # absence falls back to GITHUB_TOKEN + warning permissions: @@ -344,22 +349,37 @@ jobs: done <<< "$EXTRA_ENV" fi - - name: Configure git for cross-org clones (Python only) + - name: Configure scoped git credential for cross-org clones (Python only) # `uv sync` invokes `git clone` for git-URL dependencies (e.g. # `webcrawl @ git+https://github.com/topcoder1/webcrawl.git`). The # checkout step authenticated git with `github.token`, which is # scoped to the calling repo only — cross-org clones to a different - # org return 404 (or appear empty in workflows that swallow stderr). - # When the caller forwards `AUTOMERGE_PAT` (already deployed fleet- - # wide for the seed-PR flow), reuse it here as a read credential. + # org return 404 (or appear empty in workflows that swallow stderr), + # and same-org private git URLs 404 too. # + # SECURITY MODEL (2026-07-16, wxa-secrets#28 codex review): the + # measure step executes PR-controlled code on pull_request events + # (uv sync build hooks, pytest), so the PAT is handled as follows: + # - Never written to global git config (the previous global + # insteadOf rewrite was readable by test code via + # `git config --get-regexp --global url`). It goes into a + # throwaway config file under $RUNNER_TEMP, exposed to the + # measure step via GIT_CONFIG_GLOBAL, deleted before the test + # invocation runs. + # - On pull_request runs it is materialized ONLY when the caller + # sets `use_pat_for_git_deps: true` — repos that actually have + # private git-URL deps and accept that a same-repo PR can still + # read the credential during dependency resolution (build hooks + # run arbitrary code). + # - push:main runs main's already-reviewed code, so the credential + # materializes whenever the secret is forwarded (no input + # needed). The seed-PR flow below is unaffected: it runs on + # push:main only and reads the secret directly. # Rewrites are limited to the two fleet orgs, topcoder1 and - # whois-api-llc — not a global insteadOf — so the PAT never leaks - # to clones of third-party orgs. If AUTOMERGE_PAT isn't forwarded, - # this step no-ops and uv sync uses the github.token configured by - # actions/checkout (which works for same-org public git URLs but - # not cross-org, nor same-org private git URLs). Same fallback - # shape as the seed-PR step below. + # whois-api-llc — so the PAT never applies to clones of third-party + # orgs. If the credential isn't materialized, uv sync uses the + # github.token configured by actions/checkout (which works for + # same-repo URLs but not cross-org, nor same-org private git URLs). # # Burn 2026-05-13: wxa_webcat PRs failing coverage-floor with # `failed to fetch commit 27970847... topcoder1/webcrawl.git` — @@ -375,19 +395,23 @@ jobs: # whois-api-llc/wxa-secrets) 404s without this rewrite. if: steps.detect.outputs.language == 'python' && inputs.pre_measured_coverage_artifact == '' env: - CROSS_ORG_PAT: ${{ secrets.AUTOMERGE_PAT }} + CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && secrets.AUTOMERGE_PAT || '' }} + PAT_FORWARDED: ${{ secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail + CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" if [[ -n "${CROSS_ORG_PAT:-}" ]]; then - git config --global \ + git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/topcoder1/".insteadOf \ "https://github.com/topcoder1/" - git config --global \ + git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/whois-api-llc/".insteadOf \ "https://github.com/whois-api-llc/" - echo "==> Configured git insteadOf for topcoder1/* and whois-api-llc/* using AUTOMERGE_PAT" + echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (deleted before tests run)" + elif [[ "$PAT_FORWARDED" == "true" ]]; then + echo "::notice::AUTOMERGE_PAT is forwarded but not materialized on pull_request runs unless the caller sets use_pat_for_git_deps: true. Repos with private git-URL deps need that input (keep forwarding the secret either way — the seed-PR flow uses it on push:main)." else - echo "::warning::AUTOMERGE_PAT not forwarded by caller. Cross-org git-URL dependencies (e.g. webcrawl from topcoder1/*) will fail with 404 on uv sync. Forward the secret in the caller's job spec — see callers/coverage-floor.yml." + echo "::warning::AUTOMERGE_PAT not forwarded by caller. Cross-org git-URL dependencies (e.g. webcrawl from topcoder1/*) will fail with 404 on uv sync, and the seed-PR flow falls back to GITHUB_TOKEN. Forward the secret in the caller's job spec — see callers/coverage-floor.yml." fi # Parse pre-measured artifact when caller opted in. @@ -420,6 +444,14 @@ jobs: INPUT_TEST_COMMAND: ${{ inputs.test_command || '' }} run: | set -euo pipefail + # Scoped git credential (written by the configure step for python + # repos when allowed) is exposed to dependency installation ONLY, + # then deleted before the test invocation — tests execute + # PR-controlled code and must not be able to read it. + CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" + if [[ -f "$CROSS_ORG_GITCONFIG" ]]; then + export GIT_CONFIG_GLOBAL="$CROSS_ORG_GITCONFIG" + fi # When the caller provides test_command, it replaces the language-specific # install + pytest/go-test/npm-test invocation. The coverage-file existence # check + MEASURED extraction at the end of each case branch still runs, @@ -430,8 +462,13 @@ jobs: case "$LANG_TYPE" in python) if [[ -n "$INPUT_TEST_COMMAND" ]]; then + # test_command installs and tests in one opaque command, so + # the credential (when materialized) stays available + # throughout it. Repos combining test_command with + # use_pat_for_git_deps accept that wider window. echo "==> caller test_command override (python)" eval "$INPUT_TEST_COMMAND" + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL elif [[ -f pyproject.toml ]]; then # extras controls which [project.optional-dependencies] extras get # installed before pytest-cov runs. Default ("all") preserves the @@ -469,17 +506,21 @@ jobs: # Some repos have pyproject.toml as config-only (no [project] # deps section) and keep actual deps in requirements.txt # (inbox_superpilot/backend pattern). Install those on top so - # `uv run pytest` can import everything. + # the test invocation below can import everything. [[ -f requirements.txt ]] && uv pip install --quiet -r requirements.txt [[ -f requirements-dev.txt ]] && uv pip install --quiet -r requirements-dev.txt uv pip install --quiet pytest-cov + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL # `|| true` lets us still parse coverage.json even when tests fail # (tests-runner.yml is the gate for test pass/fail; we just need - # the coverage number here). - uv run pytest --cov --cov-report=json --quiet || true + # the coverage number here). --no-sync: the env was fully + # synced above; the test invocation must never trigger a + # re-resolve (the credential is already deleted). + uv run --no-sync pytest --cov --cov-report=json --quiet || true else python -m venv .venv .venv/bin/pip install -q -r requirements.txt pytest pytest-cov + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL .venv/bin/pytest --cov --cov-report=json --quiet || true fi if [[ ! -f coverage.json ]]; then @@ -547,6 +588,14 @@ jobs: echo "measured=$MEASURED" >> "$GITHUB_OUTPUT" echo "measured coverage: $MEASURED%" + - name: Scrub scoped git credential + # Belt-and-suspenders: the measure step deletes the credential file + # before running tests; this catches early-exit paths (e.g. uv sync + # failure with `set -e`) so no later step — including third-party + # actions like the sticky-comment poster — can read it. + if: always() + run: rm -f "$RUNNER_TEMP/cross-org-gitconfig" + - name: Seed-not-yet on PR — pass-through with comment # Install PR ships current=0. Don't self-commit (would create a new # HEAD SHA that GITHUB_TOKEN can't re-trigger workflows on). Just diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 2427898..290cde2 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -26,8 +26,10 @@ name: Tests # # Defaults chosen for "drop into an existing repo and surface its current # test suite": -# - Python with pyproject.toml → `uv sync && uv run pytest -q` (uv is the -# fastest sustainable choice and handles lockfiles). +# - Python with pyproject.toml → `uv sync && uv run --no-sync pytest -q` +# (uv is the fastest sustainable choice and handles lockfiles; --no-sync +# because the test invocation runs after the scoped git credential is +# scrubbed and must never re-resolve deps). # - Python with only requirements.txt → `pip install -r requirements.txt # && pip install pytest && pytest -q`. No coverage gating here; that's # coverage-floor.yml's job. @@ -76,9 +78,14 @@ on: required: false type: string default: "" + use_pat_for_git_deps: + description: "Materialize AUTOMERGE_PAT as a git credential on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets) — and forward AUTOMERGE_PAT alongside it. push:main runs (already-reviewed code) always materialize the credential when the secret is forwarded, without this input. Either way the credential lives in a scoped throwaway config file that is deleted before the test invocation, never in global git config." + required: false + type: boolean + default: false secrets: AUTOMERGE_PAT: - description: "PAT with cross-org `repo` read scope. When forwarded, `uv sync` can resolve git-URL dependencies that point to repos in a different org (e.g. `whois-api-llc/*` repos depending on `topcoder1/webcrawl`). Same secret already deployed fleet-wide for `claude-author-automerge.yml` and `coverage-floor.yml` seed-PR flow. Absence falls back to GITHUB_TOKEN with a warning." + description: "PAT with cross-org `repo` read scope, used ONLY as a git credential for git-URL dependency clones during install. Forward it ONLY when the repo has private/cross-org git-URL deps, and pair it with `use_pat_for_git_deps: true` so pull_request runs can use it (push:main runs use it whenever forwarded). Repos without git-URL deps must not forward this secret — every forwarded copy is readable by PR-controlled code during dependency resolution. Absence falls back to GITHUB_TOKEN with a warning." required: false permissions: @@ -162,35 +169,57 @@ jobs: run: | curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Configure git for cross-org clones + - name: Configure scoped git credential for cross-org clones # `uv sync` invokes `git clone` for git-URL dependencies (e.g. # `webcrawl @ git+https://github.com/topcoder1/webcrawl.git`). The # checkout step authenticated git with `github.token`, which is - # scoped to the calling repo only — cross-org clones return 404. - # When the caller forwards `AUTOMERGE_PAT` (already deployed fleet- - # wide), use it as a read credential. Rewrites are limited to the - # two fleet orgs, topcoder1 and whois-api-llc, so the PAT never - # leaks to clones of third-party orgs. Mirrors coverage-floor.yml's + # scoped to the calling repo only — cross-org (and same-org private) + # clones 404 without a real credential. Mirrors coverage-floor.yml's # same step. # + # SECURITY MODEL (2026-07-16, wxa-secrets#28 codex review): this job + # executes PR-controlled code on pull_request events (uv sync build + # hooks, pytest), so the PAT is handled as follows: + # - Never written to global git config (the previous global + # insteadOf rewrite was readable by test code via + # `git config --get-regexp --global url`). It goes into a + # throwaway config file under $RUNNER_TEMP, exposed to the + # install step via GIT_CONFIG_GLOBAL, deleted before the test + # invocation runs. + # - On pull_request runs it is materialized ONLY when the caller + # sets `use_pat_for_git_deps: true` — repos that actually have + # private git-URL deps and accept that a same-repo PR can still + # read the credential during dependency resolution (build hooks + # run arbitrary code). Repos without git-URL deps should not + # forward the secret at all. + # - push:main runs main's already-reviewed code, so the credential + # materializes whenever the secret is forwarded (no input needed). + # Rewrites are limited to the two fleet orgs, topcoder1 and + # whois-api-llc, so the PAT never applies to clones of third-party + # orgs. + # # Burn 2026-05-13 -> 2026-07-06: wxa_sanctions' tests/coverage-floor # lanes were red since install because github.token is repo-scoped, # so even a same-org private git dep (whois-api-llc/wxa_sanctions -> # whois-api-llc/wxa-secrets) 404s without this rewrite. env: - CROSS_ORG_PAT: ${{ secrets.AUTOMERGE_PAT }} + CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && secrets.AUTOMERGE_PAT || '' }} + PAT_FORWARDED: ${{ secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail + CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" if [[ -n "${CROSS_ORG_PAT:-}" ]]; then - git config --global \ + git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/topcoder1/".insteadOf \ "https://github.com/topcoder1/" - git config --global \ + git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/whois-api-llc/".insteadOf \ "https://github.com/whois-api-llc/" - echo "==> Configured git insteadOf for topcoder1/* and whois-api-llc/* using AUTOMERGE_PAT" + echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (deleted before tests run)" + elif [[ "$PAT_FORWARDED" == "true" ]]; then + echo "::notice::AUTOMERGE_PAT is forwarded but not materialized on pull_request runs unless the caller sets use_pat_for_git_deps: true. Repos with private git-URL deps need that input; repos without them should stop forwarding the secret." else - echo "::warning::AUTOMERGE_PAT not forwarded by caller. Cross-org git-URL dependencies will fail with 404 on uv sync." + echo "::warning::AUTOMERGE_PAT not forwarded by caller. Private/cross-org git-URL dependencies will fail with 404 on uv sync." fi - name: Install deps and run tests working-directory: ${{ inputs.working_directory || '.' }} @@ -198,9 +227,24 @@ jobs: INPUT_TEST_COMMAND: ${{ inputs.test_command || '' }} run: | set -euo pipefail + # Scoped credential (if the previous step wrote one) is exposed to + # dependency installation ONLY, then deleted before the test + # invocation — tests execute PR-controlled code and must not be + # able to read it. GIT_CONFIG_GLOBAL redirects git's "global" + # config lookup to the throwaway file; unsetting it restores the + # default (credential-free) global config for everything after. + CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" + if [[ -f "$CROSS_ORG_GITCONFIG" ]]; then + export GIT_CONFIG_GLOBAL="$CROSS_ORG_GITCONFIG" + fi if [[ -n "$INPUT_TEST_COMMAND" ]]; then + # test_command installs and tests in one opaque command, so the + # credential (when materialized) stays available throughout it. + # Repos combining test_command with use_pat_for_git_deps accept + # that wider window. echo "Running override: $INPUT_TEST_COMMAND" eval "$INPUT_TEST_COMMAND" + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL elif [[ -f pyproject.toml ]]; then # --all-extras matches coverage-floor.yml — install every # [project.optional-dependencies] extra so test deps in old-style @@ -208,14 +252,20 @@ jobs: uv sync --all-extras # Some repos have pyproject.toml as config-only with deps in # requirements.txt (inbox_superpilot/backend pattern). Install - # those on top so `uv run pytest` finds them. + # those on top so the test invocation below finds them. [[ -f requirements.txt ]] && uv pip install -r requirements.txt [[ -f requirements-dev.txt ]] && uv pip install -r requirements-dev.txt - uv run pytest -q + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL + # --no-sync: the env was fully synced above; the test invocation + # must never trigger a re-resolve (it would need the already- + # deleted credential, and silently re-fetching during tests is + # exactly the exposure this layout removes). + uv run --no-sync pytest -q elif [[ -f requirements.txt ]]; then python -m venv .venv .venv/bin/pip install -q -r requirements.txt .venv/bin/pip install -q pytest + rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL .venv/bin/pytest -q else echo "::error::python language declared but no pyproject.toml or requirements.txt found" diff --git a/selftest/test_workflow_guards.py b/selftest/test_workflow_guards.py index a3a9d91..ca7e9c1 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -11,6 +11,7 @@ import pytest REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" # test_bb_automerge_risk_patterns.sh is deliberately absent: it resolves @@ -32,3 +33,61 @@ def test_shell_selftest(script): ["bash", script], cwd=REPO_ROOT, capture_output=True, text=True ) assert proc.returncode == 0, f"{script} failed:\n{proc.stdout}\n{proc.stderr}" + + +def test_no_global_git_config_writes_in_workflows(): + """No reusable may write to global git config. + + 2026-07-16 (wxa-secrets#28 codex review): tests-runner.yml and + coverage-floor.yml wrote AUTOMERGE_PAT into a global insteadOf rewrite + before running PR-controlled code (uv sync build hooks, pytest) on + pull_request events — any same-repo PR could read the cross-org PAT + via `git config --global --get-regexp url` and exfiltrate it. + Credentials must go into a scoped throwaway file (GIT_CONFIG_GLOBAL + pointed at $RUNNER_TEMP) that is deleted before test code runs. + """ + offenders = [ + wf.name + for wf in sorted(WORKFLOWS_DIR.glob("*.yml")) + if "git config --global" in wf.read_text() + ] + assert not offenders, ( + f"global git config writes in {offenders} — use a scoped " + "GIT_CONFIG_GLOBAL temp file deleted before PR-controlled code runs" + ) + + +@pytest.mark.parametrize("workflow", ["tests-runner.yml", "coverage-floor.yml"]) +def test_scoped_git_credential_gated_and_scrubbed(workflow): + """The cross-org git credential must be (a) opt-in on pull_request + events, (b) written to a scoped file, and (c) deleted before the test + invocation, which executes PR-controlled code and must not be able to + re-resolve dependencies.""" + text = (WORKFLOWS_DIR / workflow).read_text() + + # (a) PR-event runs must not materialize the PAT without the explicit + # caller opt-in input; push:main (trusted, post-review code) may. + assert "inputs.use_pat_for_git_deps" in text, workflow + assert "github.event_name != 'pull_request'" in text, workflow + + # (b) The credential lives in a scoped throwaway file, not ~/.gitconfig. + assert 'CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig"' in text, workflow + + # (c) Every install branch (test_command / uv / pip fallback) scrubs the + # credential, and the scrub precedes the credential-free test invocation. + scrub = 'rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL' + assert text.count(scrub) >= 3, ( + f"{workflow}: every install branch must scrub the scoped credential " + "before test code runs" + ) + assert "uv run --no-sync pytest" in text, workflow + # rindex: the actual invocation is the last occurrence — header comments + # may mention the command earlier. + assert text.index(scrub) < text.rindex("uv run --no-sync pytest"), workflow + + # Regression trip-wire: a plain `uv run pytest` implicitly re-syncs the + # environment, which would re-fetch git deps mid-test-invocation. + assert "uv run pytest" not in text, ( + f"{workflow}: test invocation must be `uv run --no-sync pytest` so " + "tests can never trigger a credential-needing re-resolve" + ) From f8441ba277e69ad7683f8f138a2424c2bbfce1b8 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:01:20 -0700 Subject: [PATCH 2/8] fix(security): honest boundary docs + least-privilege GIT_DEPS_PAT preference (codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-1 P1: on same-repo PRs the CALLER workflow is itself PR-controlled, so use_pat_for_git_deps is not a boundary against actors with push access — they can flip the input (or add any workflow) and read any repo-visible secret. That is GitHub's baseline for same-repo pull_request runs; no workflow input can prevent it. Response: - Reframe input/step docs: the gate defends the no-workflow-change surface (poisoned lockfiles / dep build hooks / test-code changes riding otherwise-legitimate PRs), not push-access actors. - New optional GIT_DEPS_PAT secret, preferred over AUTOMERGE_PAT for the insteadOf rewrite: a fine-grained read-only PAT scoped to the dep repos makes theft in the residual window nearly worthless. Seed-PR flow keeps using AUTOMERGE_PAT (needs write scope). - Selftest asserts the preference expression. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 32 ++++++++++++++++++++-------- .github/workflows/tests-runner.yml | 32 ++++++++++++++++++++-------- selftest/test_workflow_guards.py | 5 ++++- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index 98c060f..ab40772 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -88,7 +88,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize AUTOMERGE_PAT as a git credential on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when the secret is forwarded, without this input. Either way the credential lives in a scoped throwaway config file that is deleted before the test invocation, never in global git config. Unrelated to the seed-PR flow, which uses the secret only on push:main." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when a secret is forwarded, without this input. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." required: false type: boolean default: false @@ -186,8 +186,11 @@ on: type: string default: "" secrets: + GIT_DEPS_PAT: + description: "Preferred, least-privilege git-deps credential: a fine-grained READ-ONLY PAT scoped to just the dependency repos (e.g. whois-api-llc/wxa-secrets, topcoder1/webcrawl). When forwarded, it is used for the insteadOf rewrite INSTEAD of AUTOMERGE_PAT, so theft during the residual dependency-resolution window yields read access to those few repos — not the fleet-wide automerge credential. Same materialization rules as AUTOMERGE_PAT (push:main whenever forwarded; pull_request only with use_pat_for_git_deps: true). NOT used by the seed-PR flow — that needs AUTOMERGE_PAT's write scope." + required: false AUTOMERGE_PAT: - description: "PAT used to push the seed branch and open the seed PR so that pull_request workflows actually fire (GITHUB_TOKEN doesn't trigger them — recursion prevention). Already deployed fleet-wide for `claude-author-automerge.yml`. Caller must explicitly forward this secret (see callers/coverage-floor.yml in topcoder1/dotclaude for the syntax). Required for the post-merge seed flow to land without manual unblock — keep forwarding it for that flow regardless of git-dep needs. Also usable as a scoped git credential for git-URL dependency clones during install: on push:main whenever forwarded, on pull_request only when the caller sets `use_pat_for_git_deps: true` (PR runs execute PR-controlled code, so materializing the credential there is an explicit opt-in)." + description: "PAT used to push the seed branch and open the seed PR so that pull_request workflows actually fire (GITHUB_TOKEN doesn't trigger them — recursion prevention). Already deployed fleet-wide for `claude-author-automerge.yml`. Caller must explicitly forward this secret (see callers/coverage-floor.yml in topcoder1/dotclaude for the syntax). Required for the post-merge seed flow to land without manual unblock — keep forwarding it for that flow regardless of git-dep needs. Also the fallback git credential for git-URL dependency clones during install (GIT_DEPS_PAT wins when forwarded): on push:main whenever forwarded, on pull_request only when the caller sets `use_pat_for_git_deps: true` (PR runs execute PR-controlled code, so materializing the credential there is an explicit opt-in)." required: false # absence falls back to GITHUB_TOKEN + warning permissions: @@ -367,14 +370,25 @@ jobs: # measure step via GIT_CONFIG_GLOBAL, deleted before the test # invocation runs. # - On pull_request runs it is materialized ONLY when the caller - # sets `use_pat_for_git_deps: true` — repos that actually have - # private git-URL deps and accept that a same-repo PR can still - # read the credential during dependency resolution (build hooks - # run arbitrary code). + # sets `use_pat_for_git_deps: true`. Honest boundary (codex P1, + # 2026-07-16): an actor with PUSH access can edit the caller — + # or add any workflow — on their branch and read any repo- + # visible secret; that is GitHub's baseline for same-repo + # pull_request runs and no workflow input can prevent it. The + # gate defends the no-workflow-change surface instead: poisoned + # lockfiles / dependency build hooks and test-code changes + # riding otherwise-legitimate PRs (dependabot bumps, generated + # PRs), which execute during install/tests without touching + # workflow files. # - push:main runs main's already-reviewed code, so the credential # materializes whenever the secret is forwarded (no input # needed). The seed-PR flow below is unaffected: it runs on - # push:main only and reads the secret directly. + # push:main only and reads AUTOMERGE_PAT directly. + # - Least privilege: when the caller forwards GIT_DEPS_PAT (fine- + # grained read-only PAT scoped to just the dep repos), it wins + # over AUTOMERGE_PAT for the rewrite, so theft in the residual + # window yields read-only access to a couple of repos rather + # than the fleet automerge credential. # Rewrites are limited to the two fleet orgs, topcoder1 and # whois-api-llc — so the PAT never applies to clones of third-party # orgs. If the credential isn't materialized, uv sync uses the @@ -395,8 +409,8 @@ jobs: # whois-api-llc/wxa-secrets) 404s without this rewrite. if: steps.detect.outputs.language == 'python' && inputs.pre_measured_coverage_artifact == '' env: - CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && secrets.AUTOMERGE_PAT || '' }} - PAT_FORWARDED: ${{ secrets.AUTOMERGE_PAT != '' }} + CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} + PAT_FORWARDED: ${{ secrets.GIT_DEPS_PAT != '' || secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 290cde2..99e0a46 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -79,13 +79,16 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize AUTOMERGE_PAT as a git credential on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets) — and forward AUTOMERGE_PAT alongside it. push:main runs (already-reviewed code) always materialize the credential when the secret is forwarded, without this input. Either way the credential lives in a scoped throwaway config file that is deleted before the test invocation, never in global git config." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when a secret is forwarded, without this input. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs." required: false type: boolean default: false secrets: + GIT_DEPS_PAT: + description: "Preferred, least-privilege git-deps credential: a fine-grained READ-ONLY PAT scoped to just the dependency repos (e.g. whois-api-llc/wxa-secrets, topcoder1/webcrawl). When forwarded, it is used for the insteadOf rewrite INSTEAD of AUTOMERGE_PAT, so theft during the residual dependency-resolution window yields read access to those few repos — not the fleet-wide automerge credential. Same materialization rules as AUTOMERGE_PAT (push:main whenever forwarded; pull_request only with use_pat_for_git_deps: true)." + required: false AUTOMERGE_PAT: - description: "PAT with cross-org `repo` read scope, used ONLY as a git credential for git-URL dependency clones during install. Forward it ONLY when the repo has private/cross-org git-URL deps, and pair it with `use_pat_for_git_deps: true` so pull_request runs can use it (push:main runs use it whenever forwarded). Repos without git-URL deps must not forward this secret — every forwarded copy is readable by PR-controlled code during dependency resolution. Absence falls back to GITHUB_TOKEN with a warning." + description: "Fallback git-deps credential (cross-org `repo` scope, deployed fleet-wide). Used for git-URL dependency clones during install only when GIT_DEPS_PAT is not forwarded. Forward it ONLY when the repo has private/cross-org git-URL deps, and pair it with `use_pat_for_git_deps: true` so pull_request runs can use it (push:main runs use it whenever forwarded). Repos without git-URL deps must not forward this secret. Prefer provisioning GIT_DEPS_PAT instead. Absence falls back to GITHUB_TOKEN with a warning." required: false permissions: @@ -187,13 +190,24 @@ jobs: # install step via GIT_CONFIG_GLOBAL, deleted before the test # invocation runs. # - On pull_request runs it is materialized ONLY when the caller - # sets `use_pat_for_git_deps: true` — repos that actually have - # private git-URL deps and accept that a same-repo PR can still - # read the credential during dependency resolution (build hooks - # run arbitrary code). Repos without git-URL deps should not - # forward the secret at all. + # sets `use_pat_for_git_deps: true`. Honest boundary (codex P1, + # 2026-07-16): an actor with PUSH access can edit the caller — + # or add any workflow — on their branch and read any repo- + # visible secret; that is GitHub's baseline for same-repo + # pull_request runs and no workflow input can prevent it. The + # gate defends the no-workflow-change surface instead: poisoned + # lockfiles / dependency build hooks and test-code changes + # riding otherwise-legitimate PRs (dependabot bumps, generated + # PRs), which execute during install/tests without touching + # workflow files. Repos without git-URL deps should not forward + # the secret at all. # - push:main runs main's already-reviewed code, so the credential # materializes whenever the secret is forwarded (no input needed). + # - Least privilege: when the caller forwards GIT_DEPS_PAT (fine- + # grained read-only PAT scoped to just the dep repos), it wins + # over AUTOMERGE_PAT, so theft in the residual window yields + # read-only access to a couple of repos rather than the fleet + # automerge credential. # Rewrites are limited to the two fleet orgs, topcoder1 and # whois-api-llc, so the PAT never applies to clones of third-party # orgs. @@ -203,8 +217,8 @@ jobs: # so even a same-org private git dep (whois-api-llc/wxa_sanctions -> # whois-api-llc/wxa-secrets) 404s without this rewrite. env: - CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && secrets.AUTOMERGE_PAT || '' }} - PAT_FORWARDED: ${{ secrets.AUTOMERGE_PAT != '' }} + CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} + PAT_FORWARDED: ${{ secrets.GIT_DEPS_PAT != '' || secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" diff --git a/selftest/test_workflow_guards.py b/selftest/test_workflow_guards.py index ca7e9c1..790b332 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -70,8 +70,11 @@ def test_scoped_git_credential_gated_and_scrubbed(workflow): assert "inputs.use_pat_for_git_deps" in text, workflow assert "github.event_name != 'pull_request'" in text, workflow - # (b) The credential lives in a scoped throwaway file, not ~/.gitconfig. + # (b) The credential lives in a scoped throwaway file, not ~/.gitconfig, + # and a least-privilege GIT_DEPS_PAT (fine-grained read-only) wins over + # the fleet-wide AUTOMERGE_PAT when forwarded. assert 'CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig"' in text, workflow + assert "secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT" in text, workflow # (c) Every install branch (test_command / uv / pip fallback) scrubs the # credential, and the scrub precedes the credential-free test invocation. From 9c906a952f4500993596bc8bcf433709246f3fc0 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:04:32 -0700 Subject: [PATCH 3/8] fix(security): allowlist-gate the git-deps credential (codex round-2 P1) "Not a pull_request" treated schedule / workflow_dispatch / feature- branch pushes as trusted and auto-materialized the PAT on unreviewed code. Flip the gate to an allowlist: auto-trust ONLY a push to the default branch; every other event requires use_pat_for_git_deps. Selftest now asserts the allowlist expression verbatim so a denylist regression trips CI. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 18 ++++++++++++------ .github/workflows/tests-runner.yml | 14 ++++++++++---- selftest/test_workflow_guards.py | 13 ++++++++++--- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index ab40772..1d45a97 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -88,7 +88,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when a secret is forwarded, without this input. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." required: false type: boolean default: false @@ -380,10 +380,12 @@ jobs: # riding otherwise-legitimate PRs (dependabot bumps, generated # PRs), which execute during install/tests without touching # workflow files. - # - push:main runs main's already-reviewed code, so the credential - # materializes whenever the secret is forwarded (no input - # needed). The seed-PR flow below is unaffected: it runs on - # push:main only and reads AUTOMERGE_PAT directly. + # - Only a push to the default branch runs already-reviewed code, + # so only that event materializes the credential without the + # input. Every other event — branch pushes, schedule, + # workflow_dispatch — is treated like a PR and needs the input. + # The seed-PR flow below is unaffected: it runs on push:main + # only and reads AUTOMERGE_PAT directly. # - Least privilege: when the caller forwards GIT_DEPS_PAT (fine- # grained read-only PAT scoped to just the dep repos), it wins # over AUTOMERGE_PAT for the rewrite, so theft in the residual @@ -409,7 +411,11 @@ jobs: # whois-api-llc/wxa-secrets) 404s without this rewrite. if: steps.detect.outputs.language == 'python' && inputs.pre_measured_coverage_artifact == '' env: - CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} + # Allowlist, not denylist (codex round-2 P1): auto-trust ONLY a + # push to the default branch — schedule / workflow_dispatch / + # feature-branch pushes run unreviewed code and need the explicit + # input just like PRs. "Not a PR" is not the same as "trusted". + CROSS_ORG_PAT: ${{ ((github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} PAT_FORWARDED: ${{ secrets.GIT_DEPS_PAT != '' || secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 99e0a46..42438eb 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -79,7 +79,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). push:main runs (already-reviewed code) always materialize the credential when a secret is forwarded, without this input. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs." required: false type: boolean default: false @@ -201,8 +201,10 @@ jobs: # PRs), which execute during install/tests without touching # workflow files. Repos without git-URL deps should not forward # the secret at all. - # - push:main runs main's already-reviewed code, so the credential - # materializes whenever the secret is forwarded (no input needed). + # - Only a push to the default branch runs already-reviewed code, + # so only that event materializes the credential without the + # input. Every other event — branch pushes, schedule, + # workflow_dispatch — is treated like a PR and needs the input. # - Least privilege: when the caller forwards GIT_DEPS_PAT (fine- # grained read-only PAT scoped to just the dep repos), it wins # over AUTOMERGE_PAT, so theft in the residual window yields @@ -217,7 +219,11 @@ jobs: # so even a same-org private git dep (whois-api-llc/wxa_sanctions -> # whois-api-llc/wxa-secrets) 404s without this rewrite. env: - CROSS_ORG_PAT: ${{ ((github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} + # Allowlist, not denylist (codex round-2 P1): auto-trust ONLY a + # push to the default branch — schedule / workflow_dispatch / + # feature-branch pushes run unreviewed code and need the explicit + # input just like PRs. "Not a PR" is not the same as "trusted". + CROSS_ORG_PAT: ${{ ((github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) || inputs.use_pat_for_git_deps) && (secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT) || '' }} PAT_FORWARDED: ${{ secrets.GIT_DEPS_PAT != '' || secrets.AUTOMERGE_PAT != '' }} run: | set -euo pipefail diff --git a/selftest/test_workflow_guards.py b/selftest/test_workflow_guards.py index 790b332..ad3f383 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -65,10 +65,17 @@ def test_scoped_git_credential_gated_and_scrubbed(workflow): re-resolve dependencies.""" text = (WORKFLOWS_DIR / workflow).read_text() - # (a) PR-event runs must not materialize the PAT without the explicit - # caller opt-in input; push:main (trusted, post-review code) may. + # (a) The credential is ALLOWLIST-gated: auto-materialized only on a + # push to the default branch (post-review code); every other event — + # PRs, branch pushes, schedule, workflow_dispatch — needs the explicit + # caller opt-in input. Denylist forms ("not a PR") regressed this once + # (codex round-2 P1). assert "inputs.use_pat_for_git_deps" in text, workflow - assert "github.event_name != 'pull_request'" in text, workflow + allowlist = ( + "github.event_name == 'push' && github.ref == " + "format('refs/heads/{0}', github.event.repository.default_branch)" + ) + assert allowlist in text, workflow # (b) The credential lives in a scoped throwaway file, not ~/.gitconfig, # and a least-privilege GIT_DEPS_PAT (fine-grained read-only) wins over From 7d86180994908a9a046ec2bb0bef5b4f93e14cc9 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:09:09 -0700 Subject: [PATCH 4/8] test(selftest): per-invocation local scrub-ordering guard (codex round-4 P2) The global first-scrub < last-invocation comparison stayed green if a single branch's scrub moved below its own pytest. Each test invocation (line-anchored, so comments don't count) must now have a scrub AFTER the last install command preceding it. Co-Authored-By: Claude Fable 5 --- selftest/test_workflow_guards.py | 37 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/selftest/test_workflow_guards.py b/selftest/test_workflow_guards.py index ad3f383..2556c47 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -6,6 +6,7 @@ """ import pathlib +import re import subprocess import pytest @@ -84,16 +85,42 @@ def test_scoped_git_credential_gated_and_scrubbed(workflow): assert "secrets.GIT_DEPS_PAT || secrets.AUTOMERGE_PAT" in text, workflow # (c) Every install branch (test_command / uv / pip fallback) scrubs the - # credential, and the scrub precedes the credential-free test invocation. + # credential, and each test invocation is LOCALLY preceded by a scrub + # with no install command in between. A global first-scrub-vs-last- + # invocation comparison would still pass if one branch's scrub moved + # below its own pytest (codex round-4 P2), so check per invocation. scrub = 'rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL' assert text.count(scrub) >= 3, ( f"{workflow}: every install branch must scrub the scoped credential " "before test code runs" ) - assert "uv run --no-sync pytest" in text, workflow - # rindex: the actual invocation is the last occurrence — header comments - # may mention the command earlier. - assert text.index(scrub) < text.rindex("uv run --no-sync pytest"), workflow + invocations = { + "tests-runner.yml": ["uv run --no-sync pytest -q", ".venv/bin/pytest -q"], + "coverage-floor.yml": [ + "uv run --no-sync pytest --cov", + ".venv/bin/pytest --cov", + ], + }[workflow] + install_markers = ("uv sync", "pip install", 'eval "$INPUT_TEST_COMMAND"') + for marker in invocations: + # Line-anchored so header/comment mentions of the command don't + # count as invocations. + sites = [ + m.start() + for m in re.finditer(rf"^\s*{re.escape(marker)}", text, flags=re.M) + ] + assert sites, f"{workflow}: expected test invocation {marker!r} not found" + for pos in sites: + last_scrub = text.rfind(scrub, 0, pos) + assert last_scrub != -1, ( + f"{workflow}: no credential scrub precedes {marker!r}" + ) + last_install = max(text.rfind(i, 0, pos) for i in install_markers) + assert last_scrub > last_install, ( + f"{workflow}: an install command sits between the scrub and " + f"{marker!r} — credential would be reachable by PR-controlled " + "test code" + ) # Regression trip-wire: a plain `uv run pytest` implicitly re-syncs the # environment, which would re-fetch git deps mid-test-invocation. From 5b696a2b287ee7809392ab57a09054c9dfb7be43 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:33:35 -0700 Subject: [PATCH 5/8] =?UTF-8?q?docs(security):=20correct=20the=20opt-in=20?= =?UTF-8?q?boundary=20=E2=80=94=20build=20hooks=20are=20inside=20the=20win?= =?UTF-8?q?dow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior wording said the opt-in "defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks". That is backwards for an opted-in repo: build hooks and poisoned-lockfile installs run DURING `uv sync`, while the credential is present, and can read it. Only the post-install test phase is protected by the scrub. Reword the input descriptions + step comments: the real fleet protection is the default of forwarding nothing; opting in accepts a residual install-phase window bounded by allowlist gating + the least-privilege GIT_DEPS_PAT. Docs-only; selftest guards unaffected (11/11 pass). Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 24 +++++++++++++----------- .github/workflows/tests-runner.yml | 26 ++++++++++++++------------ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index 1d45a97..c20d2ab 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -88,7 +88,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub. This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." required: false type: boolean default: false @@ -370,16 +370,18 @@ jobs: # measure step via GIT_CONFIG_GLOBAL, deleted before the test # invocation runs. # - On pull_request runs it is materialized ONLY when the caller - # sets `use_pat_for_git_deps: true`. Honest boundary (codex P1, - # 2026-07-16): an actor with PUSH access can edit the caller — - # or add any workflow — on their branch and read any repo- - # visible secret; that is GitHub's baseline for same-repo - # pull_request runs and no workflow input can prevent it. The - # gate defends the no-workflow-change surface instead: poisoned - # lockfiles / dependency build hooks and test-code changes - # riding otherwise-legitimate PRs (dependabot bumps, generated - # PRs), which execute during install/tests without touching - # workflow files. + # sets `use_pat_for_git_deps: true`. Honest boundary (codex, + # 2026-07-16): the real fleet protection is the DEFAULT of + # forwarding nothing for git-deps — non-opted-in repos never + # expose the credential to the measure step on a PR. Opting in + # accepts a residual install-phase window: PR-controlled code + # that `uv sync` runs (package build hooks, or whatever a + # poisoned lockfile pulls in) executes while the credential is + # present and CAN read it; only the post-install test phase is + # protected by the scrub below. The input is also NOT a boundary + # against push-access actors — they can edit the caller or add + # any workflow and read any repo-visible secret (GitHub's same- + # repo pull_request baseline; fork PRs get no secrets). # - Only a push to the default branch runs already-reviewed code, # so only that event materializes the credential without the # input. Every other event — branch pushes, schedule, diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 42438eb..0ac9ab8 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -79,7 +79,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: this input is NOT a defense against actors with push access — they can edit the caller or add a workflow on their branch and read any repo-visible secret (GitHub's same-repo pull_request baseline). It defends the no-workflow-change surface: poisoned lockfiles / dependency build hooks and test-code changes riding otherwise-legitimate PRs." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub. This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius." required: false type: boolean default: false @@ -190,17 +190,19 @@ jobs: # install step via GIT_CONFIG_GLOBAL, deleted before the test # invocation runs. # - On pull_request runs it is materialized ONLY when the caller - # sets `use_pat_for_git_deps: true`. Honest boundary (codex P1, - # 2026-07-16): an actor with PUSH access can edit the caller — - # or add any workflow — on their branch and read any repo- - # visible secret; that is GitHub's baseline for same-repo - # pull_request runs and no workflow input can prevent it. The - # gate defends the no-workflow-change surface instead: poisoned - # lockfiles / dependency build hooks and test-code changes - # riding otherwise-legitimate PRs (dependabot bumps, generated - # PRs), which execute during install/tests without touching - # workflow files. Repos without git-URL deps should not forward - # the secret at all. + # sets `use_pat_for_git_deps: true`. Honest boundary (codex, + # 2026-07-16): the real fleet protection is the DEFAULT of + # forwarding nothing — non-opted-in repos never expose the + # credential on a PR. Opting in accepts a residual install-phase + # window: PR-controlled code that `uv sync` runs (package build + # hooks, or whatever a poisoned lockfile pulls in) executes + # while the credential is present and CAN read it; only the + # post-install test phase is protected by the scrub below. + # The input is also NOT a boundary against push-access actors — + # they can edit the caller or add any workflow and read any + # repo-visible secret (GitHub's same-repo pull_request baseline; + # fork PRs get no secrets). Repos without git-URL deps should + # not forward the secret at all. # - Only a push to the default branch runs already-reviewed code, # so only that event materializes the credential without the # input. Every other event — branch pushes, schedule, From 459a8649ca8536f7cc5e03019c63d550feaa3c80 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:37:01 -0700 Subject: [PATCH 6/8] docs(security): note the test_command exception to the pre-test scrub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input descriptions claimed "only the post-install test phase is protected by the scrub" — true for the default path, but a caller test_command runs install+tests as one command BEFORE the scrub, so the credential is present during those tests too (the code comments already say this; the user-facing input descriptions now do too). Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 2 +- .github/workflows/tests-runner.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index c20d2ab..561762a 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -88,7 +88,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub. This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub (exception: a caller `test_command` runs install and tests as one command before the scrub, so the credential is present during those tests too). This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius. Unrelated to the seed-PR flow, which uses AUTOMERGE_PAT only on push:main." required: false type: boolean default: false diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 0ac9ab8..827f215 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -79,7 +79,7 @@ on: type: string default: "" use_pat_for_git_deps: - description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub. This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius." + description: "Materialize the git-deps credential (GIT_DEPS_PAT, falling back to AUTOMERGE_PAT) on pull_request runs so `uv sync` can clone private/cross-org git-URL dependencies. Default false: PR runs execute PR-controlled code during dependency install, so a readable credential there is exfiltratable (caught 2026-07-16 via wxa-secrets#28 codex review). Set true ONLY on repos that genuinely have git-URL deps (e.g. wxa_sanctions -> wxa-secrets). Only a push to the default branch (already-reviewed code) materializes the credential without this input; schedule / workflow_dispatch / branch-push runs need it too. NOTE the honest boundary: the real fleet protection is the DEFAULT of forwarding nothing — non-opted-in repos never expose the credential on a PR. Opting in accepts a residual install-phase window: PR-controlled code that `uv sync` runs (package build hooks, or whatever a poisoned lockfile pulls in) executes while the credential is present and CAN read it; only the post-install test phase is protected by the pre-test scrub (exception: a caller `test_command` runs install and tests as one command before the scrub, so the credential is present during those tests too). This input is also NOT a defense against actors with push access — they can edit the caller or add a workflow and read any repo-visible secret (GitHub's same-repo pull_request baseline; fork PRs get no secrets). Allowlist gating + preferring the least-privilege GIT_DEPS_PAT bound the blast radius." required: false type: boolean default: false From 81e505ca14762929e6addacb15ae56d197e0d030 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:39:19 -0700 Subject: [PATCH 7/8] docs(security): qualify the pre-test scrub comment for test_command too The security-model step comments promised deletion "before the test invocation" unconditionally, contradicting the input description + code branch that acknowledge a caller test_command runs install+tests together (credential persists through it). Qualify both step comments to match. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 6 ++++-- .github/workflows/tests-runner.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index 561762a..f5e56d4 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -367,8 +367,10 @@ jobs: # insteadOf rewrite was readable by test code via # `git config --get-regexp --global url`). It goes into a # throwaway config file under $RUNNER_TEMP, exposed to the - # measure step via GIT_CONFIG_GLOBAL, deleted before the test - # invocation runs. + # measure step via GIT_CONFIG_GLOBAL, deleted before the default + # test invocation runs. (A caller `test_command` runs install + + # tests as one command, so the credential persists through it — + # that config accepts the wider window; see the measure step.) # - On pull_request runs it is materialized ONLY when the caller # sets `use_pat_for_git_deps: true`. Honest boundary (codex, # 2026-07-16): the real fleet protection is the DEFAULT of diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 827f215..9d674d8 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -187,8 +187,10 @@ jobs: # insteadOf rewrite was readable by test code via # `git config --get-regexp --global url`). It goes into a # throwaway config file under $RUNNER_TEMP, exposed to the - # install step via GIT_CONFIG_GLOBAL, deleted before the test - # invocation runs. + # install step via GIT_CONFIG_GLOBAL, deleted before the default + # test invocation runs. (A caller `test_command` runs install + + # tests as one command, so the credential persists through it — + # that config accepts the wider window; see the install step.) # - On pull_request runs it is materialized ONLY when the caller # sets `use_pat_for_git_deps: true`. Honest boundary (codex, # 2026-07-16): the real fleet protection is the DEFAULT of From 8677c70a5b2a6aae46258edee3d50c0841d2349d Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 20:43:16 -0700 Subject: [PATCH 8/8] docs(security): qualify every remaining pre-test-scrub claim for test_command Comprehensive sweep of the last comment/message locations that still promised an unconditional pre-test scrub (install-step comments, the "configured insteadOf" echo lines, and the selftest docstrings/messages). All now say "before the DEFAULT test invocation" and note that a caller test_command runs install+tests as one command and is scrubbed only afterward. No logic change; 11/11 selftests pass. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-floor.yml | 8 +++++--- .github/workflows/tests-runner.yml | 14 ++++++++------ selftest/test_workflow_guards.py | 17 +++++++++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index f5e56d4..34dc7b2 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -431,7 +431,7 @@ jobs: git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/whois-api-llc/".insteadOf \ "https://github.com/whois-api-llc/" - echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (deleted before tests run)" + echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (scrubbed before the default test run)" elif [[ "$PAT_FORWARDED" == "true" ]]; then echo "::notice::AUTOMERGE_PAT is forwarded but not materialized on pull_request runs unless the caller sets use_pat_for_git_deps: true. Repos with private git-URL deps need that input (keep forwarding the secret either way — the seed-PR flow uses it on push:main)." else @@ -470,8 +470,10 @@ jobs: set -euo pipefail # Scoped git credential (written by the configure step for python # repos when allowed) is exposed to dependency installation ONLY, - # then deleted before the test invocation — tests execute - # PR-controlled code and must not be able to read it. + # then deleted before the DEFAULT test invocation — which executes + # PR-controlled code and must not be able to read it. (A caller + # test_command runs install+tests as one command, so it is scrubbed + # only afterward; that branch accepts the wider window.) CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" if [[ -f "$CROSS_ORG_GITCONFIG" ]]; then export GIT_CONFIG_GLOBAL="$CROSS_ORG_GITCONFIG" diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml index 9d674d8..bcee90c 100644 --- a/.github/workflows/tests-runner.yml +++ b/.github/workflows/tests-runner.yml @@ -239,7 +239,7 @@ jobs: git config --file "$CROSS_ORG_GITCONFIG" \ url."https://x-access-token:${CROSS_ORG_PAT}@github.com/whois-api-llc/".insteadOf \ "https://github.com/whois-api-llc/" - echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (deleted before tests run)" + echo "==> Wrote scoped insteadOf config for topcoder1/* and whois-api-llc/* (scrubbed before the default test run)" elif [[ "$PAT_FORWARDED" == "true" ]]; then echo "::notice::AUTOMERGE_PAT is forwarded but not materialized on pull_request runs unless the caller sets use_pat_for_git_deps: true. Repos with private git-URL deps need that input; repos without them should stop forwarding the secret." else @@ -252,11 +252,13 @@ jobs: run: | set -euo pipefail # Scoped credential (if the previous step wrote one) is exposed to - # dependency installation ONLY, then deleted before the test - # invocation — tests execute PR-controlled code and must not be - # able to read it. GIT_CONFIG_GLOBAL redirects git's "global" - # config lookup to the throwaway file; unsetting it restores the - # default (credential-free) global config for everything after. + # dependency installation ONLY, then deleted before the DEFAULT + # test invocation — which executes PR-controlled code and must not + # be able to read it. (A caller test_command runs install+tests as + # one command, so it is scrubbed only afterward; that branch + # accepts the wider window.) GIT_CONFIG_GLOBAL redirects git's + # "global" config lookup to the throwaway file; unsetting it + # restores the default (credential-free) global config after. CROSS_ORG_GITCONFIG="$RUNNER_TEMP/cross-org-gitconfig" if [[ -f "$CROSS_ORG_GITCONFIG" ]]; then export GIT_CONFIG_GLOBAL="$CROSS_ORG_GITCONFIG" diff --git a/selftest/test_workflow_guards.py b/selftest/test_workflow_guards.py index 2556c47..0fc9b18 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -45,7 +45,9 @@ def test_no_global_git_config_writes_in_workflows(): pull_request events — any same-repo PR could read the cross-org PAT via `git config --global --get-regexp url` and exfiltrate it. Credentials must go into a scoped throwaway file (GIT_CONFIG_GLOBAL - pointed at $RUNNER_TEMP) that is deleted before test code runs. + pointed at $RUNNER_TEMP) that is deleted before the default test + invocation (the test_command override path scrubs afterward and accepts + the wider window). """ offenders = [ wf.name @@ -54,16 +56,19 @@ def test_no_global_git_config_writes_in_workflows(): ] assert not offenders, ( f"global git config writes in {offenders} — use a scoped " - "GIT_CONFIG_GLOBAL temp file deleted before PR-controlled code runs" + "GIT_CONFIG_GLOBAL temp file scrubbed before the default test run" ) @pytest.mark.parametrize("workflow", ["tests-runner.yml", "coverage-floor.yml"]) def test_scoped_git_credential_gated_and_scrubbed(workflow): """The cross-org git credential must be (a) opt-in on pull_request - events, (b) written to a scoped file, and (c) deleted before the test - invocation, which executes PR-controlled code and must not be able to - re-resolve dependencies.""" + events, (b) written to a scoped file, and (c) deleted before the DEFAULT + test invocation, which executes PR-controlled code and must not be able + to re-resolve dependencies. (A caller `test_command` runs install+tests + as one command and is scrubbed only afterward — the wider window is + accepted for that path, so this guard only checks the default + invocations.)""" text = (WORKFLOWS_DIR / workflow).read_text() # (a) The credential is ALLOWLIST-gated: auto-materialized only on a @@ -92,7 +97,7 @@ def test_scoped_git_credential_gated_and_scrubbed(workflow): scrub = 'rm -f "$CROSS_ORG_GITCONFIG"; unset GIT_CONFIG_GLOBAL' assert text.count(scrub) >= 3, ( f"{workflow}: every install branch must scrub the scoped credential " - "before test code runs" + "(before the default test invocation; test_command scrubs afterward)" ) invocations = { "tests-runner.yml": ["uv run --no-sync pytest -q", ".venv/bin/pytest -q"],