diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index 8005eda..34dc7b2 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 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 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 @@ -181,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." + 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: @@ -344,22 +352,54 @@ 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 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 + # 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, + # 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 + # 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 — 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 +415,27 @@ 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 }} + # 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 + 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/* (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 - 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 +468,16 @@ 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 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" + 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 +488,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 +532,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 +614,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..bcee90c 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,17 @@ on: required: false 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 (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 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. 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: "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: @@ -162,35 +172,78 @@ 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 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 + # 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, + # 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 + # 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. + # # 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 }} + # 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 + 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/* (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 - 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 +251,26 @@ 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 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" + 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 +278,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..0fc9b18 100644 --- a/selftest/test_workflow_guards.py +++ b/selftest/test_workflow_guards.py @@ -6,11 +6,13 @@ """ import pathlib +import re import subprocess 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 +34,102 @@ 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 the default test + invocation (the test_command override path scrubs afterward and accepts + the wider window). + """ + 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 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 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 + # 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 + 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 + # 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 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 the default test invocation; test_command scrubs afterward)" + ) + 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. + 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" + )