Audit CI/CD pipelines, GitHub Actions first and GitLab CI and CircleCI briefly, for the paths that give an outsider code execution, secrets or a write token: pull_request_target and workflow_run running untrusted code, expression injection into run steps, missing or broad workflow permissions, third-party actions not pinned to a commit SHA, OIDC trust policies with wildcard subjects, self-hosted runners on public repos, secrets reaching forks, cache and artifact trust between workflows, and AI agents in CI that read issue or PR text while holding a write token. Runs zizmor and actionlint read-only and writes findings.json. Use when the user mentions 'pull_request_target,' 'pwn request,' 'workflow injection,' 'zizmor,' 'actionlint,' 'pin actions to SHA,' 'GITHUB_TOKEN permissions,' 'self-hosted runner,' 'claude-code-action,' 'AI agent in CI,' 'GitLab CI security,' 'CircleCI security,' 'is this workflow safe,' or needs to secure a build pipeline.
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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).
.github/workflows/*.yml, or a PR that adds or changes CI.gitlab-ci.yml) or CircleCI (.circleci/config.yml): the same classes, see
references/gitlab-circleci.mdNot 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.
zizmor --offline and actionlint forms, and gh workflow list/view and gh run list. None of them edits a workflow.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.validation_plan the owner runs on a private fork or test repo.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 patternsRemote 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.
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 installedimpostor-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.run: and shellcheck problems in
scripts. A workflow that doesn't parse can't be reasoned about; fix that first.pull_request_target whether or not untrusted code ever runs; that alone
is not a finding.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).
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.
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.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".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.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). 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-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.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). 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). 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-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.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.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.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 fromFork-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.
Close every candidate with one verdict, as defined in references/findings-format.md.
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.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.| 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:
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/notifyClasses: 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 |
skill: "cicd-security", sort by fingerprint, then:node references/validate-findings.mjs findings.json
node references/to-sarif.mjs findings.json -o findings.sarif # optionalA 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).
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)]zizmor --fix included). Give the patch; the owner applies it.briiirussell/cybersecurity-skills
c79bb8b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.