A pull request that changes only a JavaScript build script can run attacker-controlled code before your tests begin if a GitHub Actions workflow checks out that pull request with a write-capable token. In the small repository below, that one design choice can turn a routine documentation PR into a path to altered releases, leaked credentials, or both.
The useful way to apply the OWASP CI/CD Top 10 is not to score a pipeline from a distance. Put one workflow on screen, follow its inputs and privileges job by job, and write a finding whenever untrusted data can reach a privileged action. GitHub Actions is especially suited to this exercise because workflow YAML, repository settings, action references, logs, artifacts, and token permissions are all visible review surfaces.
Start with a repository small enough to inspect in 20 minutes
Create a sample repository called widget-api with a Node.js service, a test command, a release script, and one workflow. The application code is not the point; the trust boundaries are. A repository containing package.json, package-lock.json, scripts/release.js, and .github/workflows/ci.yml is enough.
name: CI and release
on:
pull_request_target:
push:
branches: [main]
jobs:
test-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: node scripts/release.js
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
This YAML combines three different trust levels in one job: code supplied by a pull request, a repository token allowed to write contents, and a package-publishing credential. That is the review smell to look for. A job should normally do one of two things: evaluate untrusted changes with no secrets and read-only permissions, or publish trusted changes after they merge.
Before mapping OWASP categories, write down four facts for every job:
- Trigger: Which event starts it:
pull_request,pull_request_target,push, tag push, manual dispatch, or schedule? - Code source: Does it run the default branch, the proposed pull request head, generated files, downloaded artifacts, or a remote script?
- Authority: What can
GITHUB_TOKEN, OpenID Connect, repository secrets, environments, and runner credentials do? - Outputs: Can it publish a package, create a release, upload an artifact, alter a pull request, deploy, or call an external service?
Trace the dangerous path, not just the YAML keywords
The critical path in the example is short. pull_request_target runs in the context of the base repository, while the checkout step explicitly switches to the pull request’s head commit. Then npm ci and npm test execute package lifecycle and test-related code from that checked-out revision. Finally, the same job has a write-capable repository token and an npm credential.
That maps directly to CICD-SEC-4: Poisoned Pipeline Execution. The poisoned input is not limited to an obvious shell command in YAML. An attacker can change package.json, a test helper, a build configuration file, or scripts/release.js. If the job executes that file, the attacker’s code has the job’s authority.
The same workflow also creates a credential problem. CICD-SEC-5: Insufficient Credential Hygiene applies because a long-lived NPM_TOKEN is injected into a job that processes untrusted code. CICD-SEC-6: Insufficient Credential Access Management applies because the job grants contents: write and id-token: write even though running tests needs neither.
A practical decision rule is simple: if a job checks out code from a fork or pull request head, it must not receive write permissions, deployment credentials, package-publishing credentials, or an identity token. Do not try to compensate with an approval comment, a branch-name convention, or a warning in contributor documentation. Separate the work instead.
Turn the OWASP CI/CD Top 10 into a workflow review worksheet
Use the worksheet below during a pull request review or a quarterly CI review. It is deliberately framed as evidence and action rather than a generic compliance score. A “yes” answer should point to a line of YAML, a repository setting, an environment rule, or a retained workflow run.
| OWASP risk | Question for widget-api | Concrete finding | Fix |
|---|---|---|---|
| CICD-SEC-1: Insufficient Flow Control | Can an unreviewed event publish or deploy? | One job tests and releases on multiple events. | Run release only on a protected main push or protected tag. |
| CICD-SEC-2: Inadequate Identity and Access Management | Are repository and environment controls required for release? | Publishing depends only on workflow logic. | Use a protected environment for production publishing. |
| CICD-SEC-3: Dependency Chain Abuse | Can a dependency change execute during install? | npm ci executes in an untrusted checkout. |
Keep untrusted jobs credential-free; review lockfile changes. |
| CICD-SEC-4: Poisoned Pipeline Execution | Does untrusted code execute with authority? | PR code runs with write and identity-token permissions. | Split validation and release workflows. |
| CICD-SEC-5 and 6: Credentials | Which job receives each credential? | NPM_TOKEN is available during PR testing. |
Move it to the release job only. |
| CICD-SEC-7 and 8: Components and third parties | Are actions and services governed? | Actions are referenced by mutable version tags. | Pin actions to reviewed commit SHAs and inventory external calls. |
| CICD-SEC-9: Artifact Integrity | Does release consume a known artifact? | Release rebuilds directly from an event checkout. | Publish a tested artifact with provenance and verify its source. |
| CICD-SEC-10: Logging and Visibility | Can you reconstruct who published what? | Logs do not explicitly record commit, artifact digest, and release target. | Emit non-secret release evidence and retain it with the release. |
Split validation from publishing and reduce the default blast radius
The fix is not a larger conditional in the original job. Make two jobs or two workflows with different trust contracts. The pull-request workflow may test attacker-controlled code, so give it the smallest possible token. The release workflow may publish, so make it run only from trusted repository history and require the narrow permissions it needs.
name: Pull request validation
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
name: Publish package
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm test
- run: npm publish
The tradeoff is operational, not theoretical: two workflows create duplicated setup steps and force teams to decide which commit is publishable. That friction is worthwhile because it produces an auditable answer: a package came from a merged commit on main, not from an arbitrary pull request head. Where possible, promote a built artifact rather than rebuilding during publishing; otherwise, record the exact commit and dependency lockfile used by the release job.
Review actions, artifacts, and external services as supply-chain inputs
GitHub Actions lets a workflow combine reusable actions, package registries, artifact storage, cloud identity, and webhook-driven services. OWASP separates insecure dependency chains, insecure component usage, and ungoverned third-party services because each has a different owner and mitigation. Treating them all as “dependencies” hides the decision you need to make.
For every uses: line, record the action owner, repository, commit SHA, purpose, permissions required, and update owner. A version tag such as actions/checkout@v4 is readable, but it can move. A commit SHA is immutable; the practical compromise is to pin production workflows to a reviewed SHA and use automated dependency updates to propose new SHAs.
Also search workflow files for these patterns:
curl,wget, orbashfetching a remote installer;- package installation without a committed lockfile;
- artifact download followed by execution;
- cloud login, npm publishing, container registry login, or deployment webhooks;
- self-hosted runner labels, especially runners shared across repositories.
CICD-SEC-9 becomes concrete when a later job downloads an artifact named build and deploys it solely because its name matches. Bind the artifact to a workflow run, commit SHA, and digest where your build tooling supports it. The important review question is: “What proof connects this deployed file to the reviewed source revision?” A successful download is not proof.
Make logging useful without turning logs into a secret store
Insufficient logging and visibility is often discovered after a release incident, when the only available record says “publish succeeded.” Add release evidence that is safe to expose: commit SHA, workflow run URL, actor, target environment, artifact filename, artifact digest, package version, and the action commit SHAs used in the release path.
Do not print tokens, cloud credentials, package manager configuration, or entire event payloads. Secret masking helps prevent direct values appearing in logs, but logging should not be your primary secret-control mechanism. A safer pattern is to log identifiers and checksums, then use GitHub’s workflow history, repository audit records where available, package registry history, and cloud audit logs to investigate the associated identity activity.
For the sample release workflow, add an explicit evidence step after publishing:
- name: Record release evidence
run: |
echo "commit=${GITHUB_SHA}"
echo "run=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
npm view widget-api version
This will not stop an attack by itself. It reduces investigation time and makes missing controls visible. If a release cannot identify its source commit and its workflow run, it is not ready to be treated as a trustworthy software supply-chain event.
Run this review this week
Start with the repository that can publish, deploy, create releases, or assume a cloud role. Do not begin with the largest workflow; begin with the workflow carrying the most authority. Copy the worksheet into an issue and assign every finding an owner and due date.
- List every workflow trigger and identify all uses of
pull_request_target, manual dispatch, schedules, and tag pushes. - For each job, write its effective
permissions, secrets, identity tokens, runner type, and external services. - Mark every point where pull request content, dependency metadata, artifacts, or remote downloads become executable.
- Split any job that combines untrusted execution with publishing, deployment, write access, or credentials.
- Pin third-party actions to reviewed commit SHAs and document who updates them.
- Add release evidence for commit, run, artifact identity, and target environment.
The final test is straightforward: take a hypothetical malicious pull request and trace exactly what it can read, execute, modify, publish, and impersonate. If the answer includes a production credential or a write-capable token, the workflow is not merely “risky”; it has a specific, fixable trust-boundary failure.