Skip to content

fix(ci): stop handing the checkout token to untrusted workflow code - #2420

Merged
chaliy merged 2 commits into
mainfrom
eve/wonderful-hamilton-b52tk3
Sep 18, 2026
Merged

chaliy merged 2 commits into
mainfrom
eve/wonderful-hamilton-b52tk3

Conversation

@chaliy

@chaliy chaliy commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

What changed

CI jobs no longer hand a live repository credential to the code they execute.

actions/checkout writes the job's GITHUB_TOKEN into .git/config as
http.<host>.extraheader unless persist-credentials: false is set. Only ci.yml
set it. Four pull-request-triggered workflows therefore ran fork-authored code
with a usable repository token sitting on disk in the same job:

Workflow Persisting checkouts Untrusted code in the job
js.yml 2 dependency build.rs, node examples
python.yml 5 cargo, pytest, maturin
coverage.yml 3 cargo llvm-cov
site.yml 2 pnpm build

The release-time build/test jobs of publish-js.yml, publish-python.yml and
publish-wasm.yml did the same for artifact builds, as did nightly.yml and
fuzz.yml.

Every checkout that does not back an authenticated git/gh call now opts out.
The credential survives in exactly eight jobs — tag verification, source
verification, and release — all of which push or fetch with it, run from
main-reachable refs, and execute no PR-authored code.

Two smaller fixes in the same area:

  • release.yml was the only workflow inheriting the repository's default
    permissions. It now declares permissions: contents: read at the top level, so
    the job's contents: write + actions: write stay opt-in and a job added later
    starts read-only.
  • cli-binaries.yml registered ::add-mask:: for the Homebrew PAT after
    exporting it. Masking now happens first, so a set -x or error trace in between
    cannot print it.

Why

A prompt to check a GitHub Actions run for token leakage turned into an audit of
bashkit's own workflows. No secret value is leaked — the Doppler fetch pattern is
sound (fetch → ::add-mask:: → $GITHUB_OUTPUT, never $GITHUB_ENV) and there are no
pull_request_target triggers, env dumps, or untrusted ${{ github.event.* }}
interpolation into run: blocks.

What the audit did find is that TM-INF-026's documented mitigation — "checkout
credentials are not persisted"
— was enforced for ci.yml only, by a test that
hard-coded that filename. Every other workflow had drifted past it unnoticed. The
exposed token is read-only on a public repo, so impact is bounded, but it is a live
credential readable by any build.rs or npm lifecycle script in a fork PR.

Recorded as TM-INF-034 in the threat model.

Before / After

No change to any workflow's behavior — this is a credential-surface change. The
observable difference is in the invariant the test suite enforces.

Before — the rule existed for one file, by name:

def test_ci_has_no_write_permission_or_persisted_checkout_token(self):
    workflow = yaml.safe_load((ROOT / '.github/workflows/ci.yml').read_text())

After — the rule is derived from what each job actually does, so no allowlist
has to be maintained and a new workflow cannot reintroduce the gap. Four invariants,
each verified to fail on a deliberately broken workflow:

1. unflagged PR checkout        -> FAILED (failures=1)
   same, derived git rule       -> FAILED (failures=1)
2. secret value into GITHUB_ENV -> FAILED (failures=1)
3. export before mask           -> FAILED (failures=1)
4. no default permissions       -> FAILED (failures=1)
--- restored, full suite:
OK

Full suite green on the real tree:

$ python3 -m unittest discover -s scripts/tests -p 'test_*.py'
Ran 99 tests in 3.865s
OK

$ cargo test --workspace --lib --bins --tests --features http_client,ssh,sqlite
test result: ok. 3447 passed; 0 failed
test result: ok. 1800 passed; 0 failed

The second commit is that suite doing its job: TM-INF-034 was in the canonical
ledger but not yet in crates/bashkit/docs/threat-model.md, and
public_threat_model_doc_covers_every_spec_threat_id caught it.

Maintenance passes now audit workflow definitions rather than only run results —
a new Workflow Credential Audit checklist in knowledge/operations/maintenance.md,
referenced from the maintain skill.

Risk

  • Low
  • The only way this breaks CI is if a job that lost its credential turns out to need
    it. Each of the 23 opted-out checkouts was checked for git/gh usage in its job
    before the flag was added, and the derived test asserts that property continuously.
    A failure would surface immediately as an auth error on this PR's own run — all 46
    checks are green.

Checklist

  • Tests added or updated
  • Backward compatibility considered

`actions/checkout` writes the job's GITHUB_TOKEN into `.git/config` as
`http.<host>.extraheader` unless `persist-credentials: false` is set. Only
`ci.yml` set it, so four pull-request-triggered workflows (`js.yml`,
`python.yml`, `coverage.yml`, `site.yml`) handed a live repository credential
to fork-authored code running in the same job — dependency build scripts, npm
lifecycle scripts, pytest, `pnpm build` — and the release-time build/test jobs
of the three publish workflows did the same for artifact builds.

Every checkout that does not back an authenticated `git`/`gh` call now opts
out. The credential survives only in the eight tag-verification,
source-verification and release jobs that push or fetch with it, all of which
run from main-reachable refs and execute no PR-authored code.

Also: `release.yml` declares a top-level `permissions: contents: read` instead
of inheriting the repository default, and `cli-binaries.yml` registers the
`::add-mask::` for the Homebrew PAT before exporting it rather than after.

The rule is derived rather than allowlisted, so a new workflow cannot
reintroduce the gap: `test_ci_supply_chain.py` fails any job that persists the
token without running `git`/`gh`, any job at all in a pull-request-triggered
workflow that persists it, any workflow without a read-only default
permissions block, any secret value routed through `$GITHUB_ENV`, and any
Doppler-fetched value that travels before it is masked.

Recorded as TM-INF-034; maintenance passes now audit workflow definitions,
not just run results.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 3ad0eda Commit Preview URL

Branch Preview URL
Sep 18 2026, 05:16 AM

The canonical ledger and `crates/bashkit/docs/threat-model.md` must list the
same threat IDs; `public_threat_model_doc_covers_every_spec_threat_id` caught
the new entry missing from the user-facing table.
@chaliy
chaliy merged commit 41f5e07 into main Sep 18, 2026
46 checks passed
@chaliy
chaliy deleted the eve/wonderful-hamilton-b52tk3 branch September 18, 2026 05:32
@chaliy chaliy mentioned this pull request Sep 22, 2026
2 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant