The Trigger That Changed Everything: Reading a Workflow Diff for Secret Exposure
The challenge
Harbourline's CI worked fine for months. Then a maintainer noticed that the coverage comment never appeared on pull requests from forks and fixed it in one line, in revision 13. The pipeline still builds, still tests, still comments, and now it does all of that for anyone on the internet who opens a pull request. Three revisions of the workflow are here, plus the release workflow and package.json for context. Read what revision 13 changed, work out which step runs code written by the contributor, and name the secret that step hands them.
What you'll learn
- Explain the difference between pull_request and pull_request_target
- Trace which job and step executes contributor-controlled code
- Work out which secrets are reachable from a given step
- Recognise npm lifecycle scripts as an execution sink in CI
- Use job boundaries and conditions to rule secrets in or out
Skills tested
Prerequisites
- Reading YAML
- What a CI pipeline does on a pull request
How it works
A CI system that builds pull requests has to answer one question before anything else: whose code is this, and what is it allowed to reach. pull_request answers it conservatively. The job runs with the fork's context, secrets are withheld, and the automatic token is read-only, so a stranger's code runs but has nothing worth stealing.
pull_request_target answers it the other way. The job runs in the base repository's context with the full secret store and a writable token, which is the only way a workflow can label, comment on or triage an outside contribution. The safety of that trade rests entirely on one assumption: the job never executes anything the contributor wrote. It checks out the base branch by default precisely to keep that true.
The moment somebody adds ref: github.event.pull_request.head.sha to make the build actually test the change, the assumption is gone and the two halves combine into remote code execution with production credentials. In a Node project there is not even a build step to subvert: npm ci runs lifecycle scripts from the contributor's own package.json before any of your commands do.
Common mistakes
- Answering GITHUB_TOKEN. It is elevated under this trigger, but it is referenced in the
commentjob, which never checks out the contributor's code. - Answering CODECOV_TOKEN. Same job as the comment step, same reason.
- Answering SLACK_WEBHOOK. Its step is guarded by
github.event_name == 'push', and this run is a pull request event. - Answering GPG_SIGNING_KEY. That lives in
release.yml, which only triggers on a tag push. - Blaming r12. r12 already reused the publish token, which is careless, but under
pull_requestno fork could ever see it. The exposure starts at r13.
How to defend against it
Keep the privileged context and the untrusted code in different jobs.
- Build and test on
pull_request, with no secrets. If a privileged follow-up is needed, run it onworkflow_runagainst the stored artefact, never against a fresh checkout of the contributor's branch. - If
pull_request_targetis genuinely required, do not check out the head ref at all. A workflow that only labels or comments does not need the code. - Scope credentials to the job that needs them. A read-only registry token for installs and a separate publish token that exists only in the release workflow would have made this incident survivable.
- Run installs with lifecycle scripts disabled, for example
npm ci --ignore-scripts, so an untrustedpackage.jsoncannot execute before your own commands. - Require an environment with a manual approval for any job that holds a publishing credential.