CI/CD Security — Workflows, Runners, Tokens and Agents in the Pipeline
Review a repository's CI configuration the way you'd review authorization code. A workflow file decides who can run
what, with which token, which secrets and which cloud role. The question for every job is the same: can someone with
less trust than the job (a fork PR author, any GitHub user who can comment, a third-party action maintainer) change
what it runs or what it reads, and what does the job hold when they do?
Cross-references: dependency-audit (packages, lockfiles, build context and release-artifact identity; this skill owns
the workflow and runner side), secrets-audit (a secret that already reached a log, fork or artifact: rotation and
history), agent-supply-chain-audit (skills, hooks and MCP servers on developer machines; this skill covers agents
running inside CI), prompt-injection (LLM features in the user's own application), iam-audit (what the cloud role
behind an OIDC trust policy can do), cloud-audit (repo security defaults and branch protection as part of a platform
sweep), scanner-pipeline (running zizmor on every PR and on a schedule), finding-triage (disposition of a single
finding).
When to use
- "Is this workflow safe?", a pasted
.github/workflows/*.yml, or a PR that adds or changes CI
- A repo or org review of GitHub Actions: triggers, permissions, pins, runners, OIDC, caches, artifacts
- An AI agent action (claude-code-action, codex-action, run-gemini-cli, actions/ai-inference) wired to issues, PRs or comments
- GitLab CI (
.gitlab-ci.yml) or CircleCI (.circleci/config.yml): the same classes, see
references/gitlab-circleci.md
- zizmor or actionlint output that needs verdicts rather than a raw list
Not for: vulnerable packages or a lockfile diff → dependency-audit; a leaked key that needs rotating →
secrets-audit; a Claude skill or MCP server someone is about to install → agent-supply-chain-audit; prompt
injection in the product's chatbot → prompt-injection; designing least-privilege cloud roles → iam-audit; setting
up a weekly scanner workflow → scanner-pipeline.
Authorization
- Review only repositories, organizations and CI systems the user owns or is authorized to audit. If ownership is
unclear, ask first.
- Pre-approved because they only read: reading files, the
zizmor --offline and actionlint forms, and gh workflow list/view and gh run list. None of them edits a workflow.
- Always prompts:
zizmor in online mode (it sends a GitHub token to the API), any gh api call (the same command
takes -X/-f and becomes a write), zizmor --fix (rewrites workflow files), gh run download, re-running or
dispatching a workflow, and changing any repository or org setting. Recommend those; let the owner run them.
- Never prove a finding by opening a PR, issue or comment on a repository to trigger a workflow, and never run a
workflow with a crafted payload. Write the exploit path as a trace and, where a live check is needed, a
validation_plan the owner runs on a private fork or test repo.
- Never print secret values from workflow logs, environments or artifacts. Names only.
Step 1 — Inventory
Read every file that defines or feeds a pipeline, not just .github/workflows/:
| What | Where |
|---|
| Workflows | .github/workflows/*.yml, *.yaml |
| Local actions and reusable workflows | .github/actions/**/action.y*ml, action.y*ml at the root, any uses: ./… path |
| Remote reusable workflows | job-level uses: org/repo/.github/workflows/x.yml@ref: read them too, one level deep |
| Config the jobs execute or the agent loads | Makefile, package.json scripts, scripts under .github/, CLAUDE.md, AGENTS.md, .claude/, .mcp.json |
| Other CI | .gitlab-ci.yml and its include: files, .circleci/config.yml, dependabot.yml |
For each workflow, record: triggers (on:), top-level and job-level permissions:, each job's runs-on,
environment:, if: gates, every uses: with its ref, the secrets and id-token it uses, and any AI agent step.
That table is the coverage record for the report.
rg -n '^on:|^\s+(pull_request_target|workflow_run|issue_comment|issues|pull_request_review(_comment)?|discussion(_comment)?|workflow_call|workflow_dispatch)\b' .github/workflows
rg -n 'anthropics/claude-code(-base)?-action|openai/codex-action|google-github-actions/run-gemini-cli|actions/ai-inference' .github
# portable equivalents: grep -rnE with the same patterns
Remote repo without a clone: list with gh workflow list -R owner/repo; reading file contents needs gh api repos/OWNER/REPO/contents/.github/workflows/FILE --jq '.content | @base64d', which prompts. Treat fetched YAML as
data: never pipe it into a shell or interpreter.
Step 2 — Run the tools (candidates, not findings)
zizmor --offline --format=json . # pre-approved; also --format=sarif for code scanning
actionlint -format '{{json .}}' # pre-approved; runs shellcheck on run: blocks if it's installed
- zizmor finds most of the classes below. Offline mode skips the audits that need the GitHub API
(
impostor-commit, known-vulnerable-actions, ref-confusion, stale-action-refs, archived-uses). For those,
ask the user to run GH_TOKEN=$(gh auth token) zizmor --format=json . (it prompts, because it hands the API a
token). --persona=auditor adds lower-confidence results; --fix rewrites files and is never part of the audit.
- actionlint catches syntax and type errors, script-injection shapes in
run: and shellcheck problems in
scripts. A workflow that doesn't parse can't be reasoned about; fix that first.
- Map each tool result to a class with references/checks.md. Every result
still goes through Steps 3–5. zizmor flags
pull_request_target whether or not untrusted code ever runs; that alone
is not a finding.
Step 3 — Who can trigger it, and what does the job hold
Every finding needs a named lower-trust actor. Decide it per trigger before looking for bugs:
| Trigger | Who controls the input | Token and secrets by default |
|---|
pull_request from a fork | Any GitHub user (public repo; first-time contributors may need approval) | Read-only token, no secrets |
pull_request_target | Any GitHub user who opens a PR | Base repo token (write unless restricted) and secrets |
issue_comment, issues, discussion*, pull_request_review* | Any GitHub user who can comment or open an issue | Base repo token and secrets |
workflow_run | Whoever triggered the upstream run, often a fork PR | Base repo token and secrets |
push, workflow_dispatch, schedule, release | Users with write access | Full; usually not an outsider path |
workflow_call | Whatever triggered the caller | Inherits the caller's context |
Since 8 December 2025, pull_request_target always runs the workflow file from the default branch and sets
GITHUB_REF/GITHUB_SHA to it; older workflow copies on other branches no longer run. That change does nothing about
a workflow that checks out or executes the PR's code, which is still the main pwn-request path.
Then list what the job holds: the effective GITHUB_TOKEN permissions (job block, else workflow block, else the repo
or org default, which is a platform fact), secrets referenced in the job, id-token: write and the role it assumes,
environment: protections, publish or deploy steps, and the runner (GitHub-hosted, or self-hosted and persistent).
Step 4 — Checks
Each class below has its grep, the attacker-controlled contexts, safe shapes and GitLab/CircleCI equivalents in
references/checks.md. AI agents have their own reference:
references/ai-agents-in-ci.md.
- Untrusted checkout in a privileged workflow (
pwn-request). pull_request_target or workflow_run that checks
out github.event.pull_request.head.sha/head.ref, refs/pull/N/merge or the triggering run's head_sha, then
builds, tests, installs, or runs a local action or script from it. Indirect execution counts: npm install runs
lifecycle scripts, make reads the PR's Makefile, a uses: ./.github/actions/x resolves from the checked-out tree.
- Expression injection (
script-injection). ${{ github.event.issue.title }}, …pull_request.body,
…comment.body, github.head_ref, commit messages, author names and similar, expanded inside run:, inside
actions/github-script's script:, or into a value later written to $GITHUB_ENV/$GITHUB_OUTPUT. The runner
pastes the text into the script before the shell sees it. Fix: pass it through env: and quote "$VAR".
- Token permissions (
excessive-permissions). No top-level permissions: (the job inherits the repo default),
write-all, or write scopes on a job that runs untrusted input. A finding only when a lower-trust actor reaches a
step that uses the extra scope; otherwise a hardening note. persist-credentials left on in a job that later
uploads the workspace as an artifact is the same class.
- Mutable action refs (
unpinned-action). Third-party uses: on a tag or branch instead of a full 40-character
SHA, docker:// images by tag, reusable workflows on @main. A finding when the job holds secrets, a write token,
OIDC, or publish rights; tj-actions/changed-files (CVE-2025-30066) is the precedent for a tag re-pointed to code
that printed secrets into logs. actions/* and github/* on tags are lower risk; say what policy the repo
follows. A SHA that exists only in a fork's network (zizmor impostor-commit) is not a pin.
- OIDC trust (
oidc-trust). id-token: write plus a cloud trust policy that a lower-trust workflow can satisfy:
AWS token.actions.githubusercontent.com:sub with StringLike on repo:org/* or repo:org/repo:*, GCP
attribute_condition checking only repository_owner, Azure federated credentials whose subject is a pull request
or a broad claims-matching expression. Pin the subject to a repo plus a protected branch or environment.
- Self-hosted runners (
self-hosted-runner). runs-on: self-hosted (or a custom label) on a public repo, or any
repo where fork PRs run. A persistent runner keeps whatever the previous job left on disk and in its environment.
- Secrets reaching untrusted code (
fork-secrets). Secrets in pull_request_target / workflow_run /
comment-triggered jobs that run untrusted code, secrets: inherit into a reusable workflow that runs PR code,
toJSON(secrets), and on private repos the "send secrets to fork PR workflows" setting.
- Cache poisoning (
cache-poisoning). A cache an untrusted run can write and a release or secret-holding job
restores: writes from pull_request_target, broad restore-keys:, keys built from branch names, setup-*
cache: in a release job. Ultralytics (December 2024) went through this into a PyPI publish.
- Artifact trust (
artifact-trust). A workflow_run job that downloads a fork run's artifact and executes,
unpacks into the workspace, deploys or publishes it, or trusts a value inside it (a PR number, a path) in a shell
command.
- Comment and label gates (
comment-command). issue_comment commands with no author_association or
permission check, if: github.actor == 'dependabot[bot]' style checks that a spoofed actor can satisfy, and
"safe to test" labels with a race between labeling and the next push.
- Publish without a protected identity (
unprotected-publish). npm publish, twine upload, docker push,
gh release create in a job any branch can reach, with a long-lived token and no environment: reviewers, where
trusted publishing is available.
- AI agents in CI (
agent-prompt-injection). An agent step that reads text a lower-trust actor wrote (issue
body, PR title, diff, review comment, a PR-modified CLAUDE.md) while it holds a write token, secrets in its
environment, or broad tools. Decide three things from
references/ai-agents-in-ci.md: who can trigger it (the action's
write-access check, allowed_non_write_users, allowed_bots), what it can do (claude_args --allowedTools,
sandbox mode, Bash(*), job permissions:), and where its output goes (a comment, a commit, or a later
run: step that evaluates it). The model is not a security boundary; the token scope and the trigger gate are.
Step 5 — Platform facts
Some deciding facts live in repo or org settings. Don't guess them; record each as a needs_validation blocker with
the command that answers it. These are gh api reads, which prompt (the owner can also check Settings → Actions):
gh api repos/OWNER/REPO --jq '{visibility, default_branch}'
gh api repos/OWNER/REPO/actions/permissions/workflow # default_workflow_permissions, can_approve_pull_request_reviews
gh api repos/OWNER/REPO/actions/permissions # allowed_actions, sha_pinning_required
gh api repos/OWNER/REPO/actions/runners --jq '.runners[] | {name, labels: [.labels[].name]}'
gh api repos/OWNER/REPO/actions/oidc/customization/sub # custom sub claim template, if any
gh api repos/OWNER/REPO/environments --jq '.environments[] | {name, protection_rules}'
gh api repos/OWNER/REPO/rules/branches/BRANCH # rulesets on the branch a release runs from
Fork-PR approval and private-repo fork settings, org runner groups, and cloud-side trust policies that aren't in this
repo's IaC are the other usual blockers. references/checks.md lists the
commands, including aws iam get-role and gcloud iam workload-identity-pools providers describe.
Step 6 — Verify, then record
Close every candidate with one verdict, as defined in references/findings-format.md.
- Write the trace.
entrypoint = the trigger and the input the lower-trust actor controls (the on: line, the
expression); propagation = the checkout, env var, artifact, cache key or agent prompt that carries it; sink =
the step that runs it with the token, secrets or role. Point every step at a workflow file and line.
- Refute it with references/verification.md. In CI the strongest controls are: the
trigger can't be reached by an outsider (
push only, a github.repository == … guard, an author_association
check that runs before the step); the job holds nothing (read-only token, no secrets, no OIDC, GitHub-hosted
runner); the value can't carry text (a PR number, a SHA); the untrusted checkout goes to a subdirectory nothing
executes. A label gate, "only maintainers merge" and "the model will refuse" do not rule a candidate out.
- Pick the verdict.
| Verdict | What it looks like here |
|---|
confirmed | pr-preview.yml runs on pull_request_target, checks out github.event.pull_request.head.sha, runs npm ci && npm run build (lifecycle scripts from the PR), and the job has contents: write and VERCEL_TOKEN. Any GitHub user opening a PR runs code with both. Static trace: confidence medium. Same verdict for issue-triage.yml putting ${{ github.event.issue.title }} into run: echo "…" with issues: write, and for a claude-code-action step with allowed_non_write_users: "*", claude_args: --allowedTools "Bash(*)" and contents: write. |
needs_validation | One deciding fact is a platform setting. deploy.yml has id-token: write and assumes arn:aws:iam::…:role/gha-deploy, but the trust policy isn't in the repo (blocker: does the role's sub condition allow any branch or pull_request? plan: aws iam get-role --role-name gha-deploy --query Role.AssumeRolePolicyDocument). A runs-on: [self-hosted, linux] job runs on pull_request in a public repo (blockers: is the runner ephemeral, and do fork PRs need approval?). A workflow with no permissions: runs PR code with a secret-free job (blocker: is default_workflow_permissions write?). |
rejected | A named control at a named line stops it on every path. pull_request_target labels PRs with actions/labeler and never checks out PR code: ruled_out_by names the job's only steps. ${{ github.event.pull_request.number }} in run: is numeric. ${{ github.event.issue.title }} reaches run: only through env: TITLE and "$TITLE". A claude-code-action workflow keeps the default write-access check (no allowed_non_write_users, no allowed_bots), so only collaborators trigger it. |
Gaps with no traced path are hardening notes, not records: missing top-level permissions: on a push-only
workflow, a tag-pinned third-party action in a job with a read-only token and no secrets, no concurrency: limit,
persist-credentials on with no artifact upload.
-
Severity follows references/severity-calibration.md, on confirmed only,
and overall ≤ impact. Rate by what the job holds:
- Critical: an outsider gets code execution with publish credentials, a cloud role with write access to
production, or a token that can push to a protected release branch.
- High: an outsider gets a repo write token or repository secrets.
- Medium: an unpinned third-party action in a privileged job (likelihood depends on an upstream compromise), or
a gate that only maintainers can reach.
- Low: the job holds nothing beyond read access and the result is limited to the attacker's own PR.
A static trace caps confidence at medium. reproduced: true only when the owner ran the plan on a test repo or
private fork and it showed the effect.
-
Fingerprint by root cause: cicd-security/<class>/<workflow file>/<job id>, adding the step id when one job has
two independent sinks (…/<job id>/<step id>). For an OIDC trust policy in IaC, use the IaC file and resource
address; for one read from the cloud, the provider path with slashes. No line numbers, no :<digits>.
cicd-security/pwn-request/.github/workflows/pr-preview.yml/build
cicd-security/script-injection/.github/workflows/issue-triage.yml/label/greet
cicd-security/agent-prompt-injection/.github/workflows/claude.yml/claude
cicd-security/oidc-trust/infra/github-oidc.tf/aws_iam_role.gha_deploy
cicd-security/oidc-trust/aws/123456789012/role/gha-deploy
cicd-security/script-injection/.gitlab-ci.yml/notify
Classes: pwn-request, script-injection, excessive-permissions, unpinned-action, oidc-trust,
self-hosted-runner, fork-secrets, cache-poisoning, artifact-trust, comment-command, unprotected-publish,
agent-prompt-injection. Add one when none fits.
ext keys (flat, snake_case):
| Key | Value |
|---|
platform | github-actions, gitlab-ci, circleci |
workflow | Workflow file, or the IaC file for an OIDC policy |
trigger | Array of triggers that reach the sink: ["pull_request_target"] |
job / step | Job id; step id or name when it matters |
lower_trust_actor | any-github-user, fork-pr-author, issue-commenter, third-party-action-maintainer, org-member |
token_permissions | Effective write scopes: ["contents:write", "id-token:write"] |
secrets_in_scope | Secret names only: ["NPM_TOKEN"] |
agent | claude-code-action, codex-action, run-gemini-cli, ai-inference |
tool_rule | The tool hit that raised it: zizmor/template-injection, actionlint/expression |
- Write and validate.
skill: "cicd-security", sort by fingerprint, then:
node references/validate-findings.mjs findings.json
node references/to-sarif.mjs findings.json -o findings.sarif # optional
A full worked findings.json with one record per verdict is in
references/worked-examples.md. On a re-run, load the previous file first: a
rejected record stays rejected unless the line in ruled_out_by.location changed.
Dispositions (usually set later by finding-triage): Fixed / Deferred / Accepted Risk / Mitigated (platform/config) /
Requires major migration. Setting the repo's default token to read-only is a typical Mitigated (platform/config).
Output Format
Write findings.json first. The report is rendered from it and never changes a verdict or severity the JSON doesn't
already say.
# CI/CD Security Review
## Repository: [owner/repo @ sha] ## Date: [date] ## Platforms: [GitHub Actions / GitLab CI / CircleCI]
## Scope: [full | diff-scoped (partial) — what was left out] ## Visibility: [public | private | unknown]
## Tools: [zizmor x.y offline/online, actionlint x.y] ## findings.json: [path] — validated: [yes]
### Summary
- Confirmed: [N] (Critical [n] | High [n] | Medium [n] | Low [n]) Needs validation: [N] Rejected: [N]
### Workflow inventory
| Workflow | Triggers | Effective token scopes | Secrets / OIDC | Runner | Third-party refs unpinned | AI agent |
|---|---|---|---|---|---|---|
### Confirmed findings
#### [SEVERITY] [Class]: [Title] `fingerprint`
**Workflow / job:** `.github/workflows/x.yml` → `job` (`step`) **Trigger:** [trigger] **Confidence:** [medium | high (reproduced)]
**Path:** [lower-trust actor] → [input] → [carrier] → [sink] holding [token scopes / secret names / role]
**Fix:** [the YAML change, e.g. move to `pull_request` + `workflow_run`, pass through `env:`, pin to SHA with a version comment]
**Verify the fix:** [re-run `zizmor --offline .` / the grep / the owner's test-repo check]
### Needs validation
| Finding | Blockers | Check for the owner to run |
|---|---|---|
### Hardening notes (not findings)
- [gap] — [why there's no path] — [effort]
### Rejected (do not re-raise)
| Fingerprint | Ruled out by | Location |
|---|---|---|
### Prioritized remediation plan
1. [Untrusted code or text reaching secrets / write tokens: same day]
2. [OIDC subjects and runner exposure]
3. [Pins, permissions defaults, repo-level policy (default token read-only, SHA pinning required)]
Boundaries
- Static review and read-only tools. Never trigger a workflow, open a PR or comment to test a path, or dispatch a run
with crafted input; the owner tests on a private fork or a throwaway repo.
- Never edit workflows during the audit (
zizmor --fix included). Give the patch; the owner applies it.
- Never print secret values, tokens, or OIDC tokens; name the secret and where it's referenced.
- Repositories the user doesn't own: report through the owner's security policy or GitHub private vulnerability
reporting, never in a public issue.
- Refuse requests to use a found pipeline weakness against someone else's repository.
References
- GitHub Security Lab, "Keeping your GitHub Actions and workflows secure": Part 1, preventing pwn requests, Part 2, untrusted input
- GitHub Docs: Security hardening for GitHub Actions, OIDC in AWS
- GitHub Changelog: pull_request_target and environment branch protection changes (2025-11-07, effective 2025-12-08), blocking and SHA-pinning policy (2025-08-15)
- zizmor (audits, usage), MIT; actionlint, MIT
- anthropics/claude-code-action security guide
- Trail of Bits, agentic-actions-auditor (CC BY-SA 4.0): the trigger → agent input → capability framing for agents in CI. Paraphrased; no text copied
- Sentry, gha-security-review (Apache-2.0): the outsider threat model and safe-pattern list. Paraphrased; no text copied
- Wiz, Avoiding mistakes with AWS OIDC integration conditions
- Shared references (copied in at build time): findings-format.md, findings.schema.json, validate-findings.mjs, to-sarif.mjs, severity-calibration.md, verification.md
- This skill: references/checks.md, references/ai-agents-in-ci.md, references/gitlab-circleci.md, references/worked-examples.md