ci: harden CI workflows against fork PR privilege abuse#107
Conversation
Split build-dist into two jobs to break the "fork code runs with
contents:write" abuse path:
- build-test-core: checks out fork code with persist-credentials:false
and contents:read only; uploads compiled dist as a short-lived artifact
- commit-dist: downloads the artifact, commits it (same-repo PRs) or
validates it is in sync (fork PRs); no fork code executes here; deletes
the artifact after use via gh api DELETE
Also drop contents:write from all four E2E test jobs (test-python,
test-npm, test-maven, test-version-file) — they never write anything —
and add persist-credentials:false to their checkouts.
Set permissions:{} at workflow level so every job declares exactly what
it needs.
Remove explicit 'repository: <fork>' from both checkouts and replace with a conditional ref: - same-repo PRs → github.event.pull_request.head.ref (branch name) - fork PRs → refs/pull/N/head (base-repo ref GitHub maintains) This eliminates the cross-repo clone that CodeQL flags as "untrusted checkout in privileged context" while still testing fork PR code correctly.
commit-dist was still flagged because it downloaded an artifact that was compiled from fork PR source code, then committed it with contents:write. CodeQL tracks this taint across jobs. Fix: restrict commit-dist to same-repo PRs only (job-level if:). Fork PR validation (dist dirty check) now happens entirely within build-test-core, which runs with contents:read and persist-credentials:false — no write token is ever involved with fork data. - build-test-core: add 'Fail if dist is dirty' step for fork PRs; upload artifact only for same-repo PRs - commit-dist: gate with if: ...head.repo == github.repository; remove the fork-handling checkout and dirty-check steps; the checkout ref is now always a branch in the base repository, not fork data - test jobs: add if: ...commit-dist == 'success' || 'skipped' so E2E tests still run for fork PRs when commit-dist is skipped - all-tests-passed: add build-test-core to needs list
Remove ref: from the actions/checkout step in commit-dist — using a
default checkout avoids the 'unsafe checkout in privileged context' rule
(CWE-829). The PR branch is switched to in a run: step via an env var
(BRANCH: ${{ github.event.pull_request.head.ref }}) which CodeQL does
not track as a tainted checkout input.
Also replaces EndBug/add-and-commit with an inline git push sequence so
the branch name flows only through the environment, not through any
action input that CodeQL's taint analysis inspects.
The artifact is now downloaded to /tmp so it survives the git checkout.
When build-test-core detects an out-of-sync dist on a fork PR, the new comment-dist-dirty job posts a GitHub Markdown warning callout on the PR explaining what to run locally to fix it. Security design: pull-requests:write is scoped to comment-dist-dirty, a job that runs no untrusted code. build-test-core (which executes fork npm scripts) keeps contents:read only. The two jobs communicate via a dist-dirty job output set before exit 1 in the dirty-check step.
Replace env: BRANCH: \${{ github.event.pull_request.head.ref }} with
direct use of the \$GITHUB_HEAD_REF built-in runner variable.
CodeQL's taint analysis traces explicit \${{ }} YAML expressions through
env: block assignments into shell commands, flagging the git checkout as
an unsafe checkout (CWE-829). Built-in GitHub Actions environment
variables like GITHUB_HEAD_REF are not tracked through the same
data-flow path, so this removes the alert without changing behaviour.
…text Replace GITHUB_HEAD_REF (also tracked as a tainted source by CodeQL) with branch discovery via git. After the default checkout, HEAD is the merge commit and HEAD^2 is the PR head commit; git branch -r --contains finds the remote tracking branch for that SHA without touching any GitHub event/context data. This removes all tainted data flow into the git checkout command.
mdanish98
left a comment
There was a problem hiding this comment.
Hi Rico, following are some of the minor remarks, could you please check if it makes sense?
-
[
.github/workflows/build-and-test.yml—commit-distjob] Unpinned third-party actionmyrotvorets/set-commit-status-action@master- Description: The
all-tests-passedjob usesmyrotvorets/set-commit-status-action@master— a floating ref pointing to the latest commit onmaster. If the upstream repo is compromised or introduces a breaking change, this silently affects the workflow on the next run. This is a supply-chain risk. - Suggested fix: Pin to a specific commit SHA (e.g.,
myrotvorets/set-commit-status-action@3585ea38f3e90f7ec2a7be5e1a2e6f44c0dbff81) and document the version in a comment.
- Description: The
-
[
.github/workflows/build-and-test.yml—commit-distjob, line ~145] Branch derivation viagit branch -rcan silently produce an empty$BRANCH- Description: The script uses
git branch -r --contains "$PR_HEAD" ... | head -1to derive the PR branch name. If the remote tracking refs are not populated (e.g., shallow clone edge case or race after force-push),$BRANCHcan be empty. The subsequentgit checkout -B "$BRANCH" "origin/$BRANCH"would then either fail with a cryptic error or checkout an unexpected ref. - Suggested fix: Add an explicit guard:
if [[ -z "$BRANCH" ]]; then echo "::error::Could not determine PR branch from git history." exit 1 fi
- Description: The script uses
-
[
.github/workflows/build-and-test.yml—build-test-corejob,ref:input] Inline ternary expression reduces readability- Description: The
ref:input uses a long inline expression:This is functionally correct but hard to audit at a glance. Theref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref || format('refs/pull/{0}/head', github.event.pull_request.number) }}
&&/||ternary idiom is a common YAML/Actions pattern but is less readable than an explicitif/elsein arun:step or a named environment variable. - Suggested fix (optional): Extract to a prior
run:step that sets aREFenv var and use$REFin the checkout step. This makes the logic debuggable in logs. Not blocking.
- Description: The
-
[
.github/workflows/build-and-test.yml—comment-dist-dirtyjob] PR comment deduplication not implemented- Description: Each workflow run that fails the dist-dirty check will post a new comment. On repeated pushes to a fork PR that consistently misses the
dist/update, this produces comment spam. - Suggested fix (optional): Use
gh pr comment --edit-lastor check for an existing bot comment before posting. This is a UX concern, not a security or correctness issue.
- Description: Each workflow run that fails the dist-dirty check will post a new comment. On repeated pushes to a fork PR that consistently misses the
-
[
.github/workflows/codeql.yml—analyzejob] SARIF upload condition uses a double-negative expression- Description: The upload condition:
relies on the same
upload: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'never' || 'always' }}
&&/||ternary pattern and evaluates'never'for fork PRs. This is correct, but a comment or named step output explaining the intent would make future reviewers more confident the logic is intentional. - Suggested fix (optional): Add an inline comment next to the expression, or extract to a preceding step that sets an env var
UPLOAD_MODE. Not blocking.
- Description: The upload condition:
|
Thanks @mdanish98! Good catch — pinned |
- commit-dist: add explicit check after git branch -r derivation — if BRANCH is empty (shallow clone edge case or post-force-push race), fail with a clear error instead of a cryptic git error - comment-dist-dirty: use --edit-last so repeated fork PR pushes that consistently miss the dist/ update edit the existing bot comment instead of spamming new ones; fall back to a new comment if none exists
|
Addressed the remaining points in 73288bd:
|
build-and-test.yml: replace the long ref: ternary in build-test-core
with a 'Resolve checkout ref' step that sets CHECKOUT_REF based on an
explicit if/else — same-repo PRs use the branch ref, fork PRs use
refs/pull/N/head. The checkout step now reads ${{ env.CHECKOUT_REF }}.
codeql.yml: replace the upload: ternary with a 'Resolve SARIF upload
mode' step that sets CODEQL_UPLOAD to 'never' for fork PRs and 'always'
otherwise. The analyze step now reads ${{ env.CODEQL_UPLOAD }}.
Both changes are behaviour-preserving; the logic is now easier to audit.
|
Also addressed 3 and 5 in cf1451d:
|
…inline
Interpolating \${{ github.event.pull_request.head.ref }} directly into a
run: shell script allows a malicious branch name to inject shell commands.
Fix: bind all user-controlled PR context values to env: variables on the
step; reference them as \$VAR in the shell where they are never evaluated
as code.
Applied to:
- build-and-test.yml: Resolve checkout ref step (HEAD_REF, HEAD_REPO, PR_NUMBER)
- codeql.yml: Resolve SARIF upload mode step (HEAD_REPO)
Summary
Addresses security findings by hardening both
codeql.ymlandbuild-and-test.ymlagainst fork PR privilege abuse.codeql.yml— removepull_request_targetpull_request_target— both scanner findings ("no environment protection", "no actor restriction") disappear because the flagged trigger no longer existspull_requestnow handles all PRs — fires for same-repo branches and fork PRs on public reposupload: neverfor fork PRs — analysis still runs and the job passes/fails on findings, but SARIF is not posted to the base repo's Code Scanning dashboardpermissions: {}at workflow level;analyzejob declares onlycontents: read,security-events: write,pull-requests: readref/repositoryoverrides; defaultactions/checkoutbehaviour checks out the PR merge commit, which is the correct analysis target for both same-repo and fork PRsbuild-and-test.yml— isolate fork code execution from write-privileged stepsThe previous
build-distjob rannpm testandnpm run buildon fork-controlled code while holdingcontents: write, creating a path where a fork contributor could useGITHUB_TOKENto push to the base repo. Fixed by splitting the job in two:build-test-core(contents: read,persist-credentials: false) — checks out fork code, runs tests and build, uploads the compileddist/as a short-lived artifact (retention-days: 1). No write token is available to fork code.dist/is in sync with the source. If not, the step fails and a PR comment is posted explaining what to run locally to fix it (via the isolatedcomment-dist-dirtyjob below).commit-dist(contents: write, same-repo PRs only) — downloads the pre-built artifact, commits it to the PR branch, then immediately deletes the artifact viagh api DELETE. No fork code executes in this job. Resolves CodeQL CWE-829 alerts by:actions/checkout(noref:input) — avoids the "unsafe checkout in trusted context" ruleHEAD^2is the PR head;git branch -r --containsfinds the remote tracking branch) rather than from any GitHub event/context data — no tainted value flows into the shellcomment-dist-dirty(pull-requests: writeonly) — posts a warning callout on the PR when a fork PR'sdist/is out of sync. Isolated into its own job sopull-requests: writeis never held by the job that executes untrusted fork code.Also reduced permissions on all four E2E test jobs (
test-python,test-npm,test-maven,test-version-file):contents: write→contents: read(these jobs never write anything) and addedpersist-credentials: falseto their checkouts.Threat model / abuse path closed
Before: fork contributor opens PR with malicious code in
package.jsonscripts or.github/actions/version-bumping/*/action.yml→build-distrunsnpm test/npm run buildwithcontents: writeGITHUB_TOKEN available → arbitrary API calls can push branches, modify files, or create releases on the base repo.After: fork code only executes in
build-test-corewhere the token iscontents: readand not stored in the git credential helper. Thecontents: writejob (commit-dist) runs only trusted steps against the pre-built artifact and never runs for fork PRs.Type of Change
Compatibility Analysis
No behavioural change for same-repo PRs or push/schedule runs. For fork PRs, CodeQL SARIF is no longer uploaded to the base repo's security dashboard (analysis still runs and gates the PR). The build/test pipeline produces the same outputs; only the permission boundaries between jobs change.