One pull_request_target job that checks out a contributor’s head commit can turn a documentation PR into code execution with repository-context credentials. A five-minute workflow review should catch that before the YAML reaches the default branch, not after a deployment token appears in an incident timeline.
OWASP’s CI/CD risks are useful because they cover the whole delivery path: access control, poisoned pipeline execution, credentials, configuration, third-party services, artifact integrity, and visibility. They are less useful during review when a maintainer is staring at a 90-line workflow diff and needs a binary decision: merge, request changes, or block.
Use the checklist below as a merge gate. It deliberately separates four types of authority that are often collapsed into “the workflow has access”: GITHUB_TOKEN scopes, repository or organization secrets, cloud credentials obtained through OIDC, and protected deployment environments. They are related, but they are not interchangeable and are not available on the same events.
Start Every Workflow Review With These Merge Gates
Answer every question with Yes before approving a workflow change. A “No” is not automatically a security incident, but it is a reason to block the merge until the author narrows the workflow or records an approved exception.
| Review question | Block merge if | Why and remediation |
|---|---|---|
Does every job declare the minimum permissions it needs? |
A job relies on the default token permissions, or receives contents: write, pull-requests: write, packages: write, or id-token: write without a named operation that needs it. |
Token authority is an access-control risk. Set top-level permissions: {}, then grant job-level scopes. See token and secret review. |
| Can untrusted event data reach a shell, action input, path, ref, or deployment decision? | The workflow executes PR, issue, comment, dispatch, or artifact-controlled content in a privileged job. | This is poisoned pipeline execution. Keep untrusted tests on pull_request; move privileged work to a trusted, separately verified path. |
| Are secrets, cloud credentials, and environments reviewed independently? | A job receives a repository secret, OIDC access, or an environment without documenting which one is necessary and under which trigger. | A read-only token does not make a cloud credential safe, and a secret-free job can still deploy through an environment. See the authority matrix. |
| Is every third-party action pinned to an immutable commit? | A production, release, or privileged workflow uses a mutable tag such as @v4, @main, or a branch. |
Tags can move. Pin the reviewed commit, record the intended release in a comment, and review updates as code. |
| Does a deploy job bind its artifact to an approved build? | The job downloads “latest,” selects an artifact by a mutable name alone, or trusts a checksum fetched from the same untrusted artifact location. | An attacker able to replace both archive and checksum defeats that check. Require an artifact ID, verified provenance, or a protected digest record. See artifact integrity. |
| Will a reviewer be able to reconstruct who deployed what? | The workflow suppresses useful job output, lacks deployment records, or logs secrets and then relies on masking as the control. | Insufficient visibility delays containment. Emit non-secret build IDs, commit SHAs, artifact IDs, and deployment environment names. |
The practical benefit of this format is that it converts broad CI/CD security categories into merge conditions. A reviewer does not need to prove a workflow is perfectly secure. They need to find the first unjustified authority boundary and ask for a smaller one.
Apply Trigger Rules Before Reviewing the Steps
The trigger determines whether the workflow is processing untrusted code or untrusted text. Review triggers first; a harmless-looking run: npm test means something very different on a fork PR than on a protected branch push.
| Trigger | May contain untrusted input or code? | May receive secrets or privilege? | Merge-gate decision rule |
|---|---|---|---|
pull_request |
Yes. Fork PR code, branch names, titles, and changed files are untrusted. | Treat as no-secret and read-only. Fork behavior also depends on repository and organization settings. | Allow tests, linting, and static analysis. Block cloud login, deployment, write tokens, and execution of PR-controlled scripts with credentials. |
pull_request_target |
Yes, if the job reads PR fields or checks out the contributor head commit. | Potentially yes, because it runs in the base repository context. | Block if it checks out or executes PR head code with any useful authority. Limit it to trusted-base tasks such as labeling, with carefully handled PR text. |
workflow_run |
Possibly. Upstream artifacts and metadata may originate from an untrusted workflow. | Often can be privileged. | Restrict to a named upstream workflow and trusted branch. Never execute an upstream artifact until it is cryptographically or procedurally bound to an approved build. |
workflow_dispatch |
Yes. Inputs and chosen refs are user-controlled. | Yes, depending on configuration. | Restrict who can dispatch; validate inputs; allow only protected refs for release or deployment jobs. |
issue_comment |
Yes. Comment text and commands are untrusted. | Potentially yes in repository context. | Require an explicit actor authorization check before privileged behavior. Do not interpolate comment text into shell commands. |
repository_dispatch |
Yes. External payloads are untrusted. | Potentially yes. | Authenticate the sender outside the workflow, validate event types and payload fields, and never let a payload select an arbitrary ref or environment. |
schedule |
Usually runs default-branch workflow code, but dependencies and fetched data can still be hostile. | May receive configured secrets. | Restrict scheduled jobs to maintenance work; protect the default branch and avoid unattended production deployment without environment approval. |
| Reusable workflow callers | Caller inputs, inherited secrets, and checked-out code may be untrusted. | Only explicitly passed secrets should be considered available. | Review both caller and callee. Block broad secrets: inherit for reusable workflows that can be called by less-trusted repositories or refs. |
A safe replacement for the common rejected pattern is two workflows: run untrusted tests on pull_request, then build and publish a release artifact only after code reaches a protected branch. A trusted workflow_run or manually approved deployment workflow can deploy that approved build after verifying its identity. This costs an extra workflow and requires artifact retention discipline, but it avoids mixing contributor code with deployment authority.
Set Token Permissions, Secrets, OIDC, and Environments Separately
A permissions block limits the scopes on GITHUB_TOKEN; it does not decide whether repository secrets are injected, whether a cloud role can be assumed through OIDC, or whether an environment approval is required. Review all four independently.
GITHUB_TOKEN: identify the exact API operation. Commenting on a PR may requirepull-requests: write; checking out source normally needs onlycontents: read.- Repository and organization secrets: list each secret by name and explain why this event can access it. Do not pass a secret into a test job merely because a later deployment job needs it.
- OIDC: grant
id-token: writeonly to the cloud-login job. Configure the cloud trust policy to constrain repository, branch or tag, workflow identity where supported, and environment. - Deployment environments: use environment protection for approval and environment-scoped secrets. Confirm that the selected environment cannot be supplied by an untrusted workflow input.
Also inspect GitHub’s organization and repository Actions settings. Fork pull-request token behavior, allowed action sources, default workflow token permissions, and settings that allow workflows to approve PRs or create pull requests affect the practical authority of the YAML. A precise permissions block is necessary, but it is not the whole authorization model.
name: pull-request-tests
on:
pull_request:
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out the pull request merge result
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run tests without secrets
run: npm ci && npm test
This is intentionally a low-authority workflow: no repository secret, no OIDC token, no environment, and no write scope. Pinning the action commit makes the action version immutable; the SHA above is the reviewed commit for actions/checkout.
Review Third-Party Actions as Dependencies, Not Magic YAML
OWASP calls out ungoverned third-party services because a workflow can depend on far more than your repository. Each uses: reference introduces code or a reusable workflow definition into the execution path, but execution differs by type: JavaScript actions run through the runner’s Node-based action runtime, Docker actions run in a container, and composite actions expand into their declared steps. Review the implementation appropriate to that type.
Use this operational rule: for a privileged job, every action must have an immutable commit pin, a maintainer or vendor you can identify, and a reason it is preferable to code you own. Do not replace every action with inline shell automatically. A short shell command can introduce command injection, curl-pipe-to-shell installation, or unpinned package downloads just as easily as an action can.
- Prefer a maintained action when it has a narrow purpose, reviewed source, and a pinned commit.
- Prefer inline shell for a small, auditable operation already supported by the runner, such as writing a known value to a file.
- Block actions that request broad tokens, accept untrusted strings into shell-like inputs, or download executable code at runtime without version and digest controls.
- Maintain an allowlist for release and deployment workflows, where action updates deserve the same review as production dependency updates.
The tradeoff nobody enjoys is update maintenance: immutable pins do not receive automatic tag updates. That is the point. Schedule dependency-update PRs for actions, review the commit diff and release notes, then change the pin deliberately.
Bind Artifacts to a Trusted Build, Not Just a Filename
A file named app.tar.gz and a neighboring app.tar.gz.sha256 are not an integrity boundary if the same attacker can replace both files or influence which artifact the deploy job selects. The checksum verifies accidental corruption in that situation, not provenance.
For a production deployment, require at least one trusted binding:
- Approved artifact ID: record the GitHub artifact ID from the approved build in a protected release or deployment record, then download that exact ID rather than “latest build.”
- Signed provenance or attestation: verify an attestation against the expected repository and expected build workflow identity before deployment. The verifier must reject an artifact attested by a different repository or workflow.
- Protected digest record: store the SHA-256 digest in a protected release manifest, signed release metadata, or deployment system record that the build artifact writer cannot silently rewrite after approval.
Work through the common PR scenario. A contributor’s pull_request job may build an artifact for test feedback, but that artifact is not deployable merely because it exists. After merge, a protected-branch build creates the candidate; the release process records its artifact ID or verified provenance; the deployment job consumes only that binding. A workflow_run job can be privileged only after this distinction is enforced.
This is OWASP’s artifact-integrity category in review language: ask “what exact approved build does this deployment consume, and what prevents a different one from being selected?” If the answer is an artifact name, block the merge.
Make Logs Useful Without Turning Them Into a Secret Export
Logging and visibility controls should answer four questions after a failed deployment: which workflow revision ran, which source commit it used, which artifact it deployed, and which environment approved it. Emit those identifiers as ordinary job output or deployment metadata. Do not print tokens, full environment dumps, cloud credential responses, or configuration files that may contain secrets.
GitHub masks known secret values in logs, but masking is not a reason to log credentials. It may not protect transformed values, partial values, or secrets not registered with the platform. Treat shell tracing such as set -x as a merge blocker in jobs that handle credentials.
For each release workflow, add a short non-secret audit line containing the commit SHA, artifact ID or digest, target environment, and deployment change reference. Keep it boring and searchable. If an incident responder must infer the deployed artifact from a job title and a mutable branch name, the pipeline has an insufficient-visibility problem even if the deployment technically succeeded.
Paste This Into the PR Template This Week
Add this block to the repository PR template used for changes under .github/workflows/. Require the author to complete it, then let the reviewer verify the answers against the diff and repository settings.
## GitHub Actions security review - [ ] Every job has minimum `permissions`; write and `id-token: write` scopes are justified. - [ ] I identified separately: `GITHUB_TOKEN` scopes, repository/org secrets, OIDC/cloud access, and deployment environments. - [ ] The trigger is safe for this authority level: - [ ] `pull_request` jobs run without secrets or deployment access. - [ ] `pull_request_target` does not check out or execute PR head code with privilege. - [ ] `workflow_run`, dispatch, comment, schedule, and reusable callers are restricted to trusted refs or authorized actors. - [ ] Untrusted strings from PRs, issues, comments, dispatch payloads, and artifacts do not reach shell commands or privileged action inputs unsafely. - [ ] Third-party actions are pinned to immutable commit SHAs and approved for this job’s authority level. - [ ] Deployment consumes an approved artifact ID, verified provenance/attestation, or protected digest record; it does not select “latest” by name. - [ ] Logs and deployment records include commit SHA, artifact ID/digest, and environment, and do not print secrets. - [ ] Repository/org Actions settings were checked for default token permissions, fork PR behavior, allowed actions, and workflow PR-approval/creation permissions. Security exception or not applicable:
Start with workflow files that can deploy, publish packages, assume cloud roles, or comment with write authority. Those are the small number of YAML files where a one-line trigger, permission, or artifact-selection change can cross the largest trust boundary.