DEV Community

Noel Himer
Noel Himer

Posted on

Two supply-chain attacks in one week — here's what to actually fix in your CI

On May 18, 2026, between 11:36 and 17:48 UTC, the TeamPCP threat group compromised 5,561 public GitHub repositories in six hours. They pushed malicious GitHub Actions workflows via stolen developer credentials — injecting new workflows or replacing existing ones with dormant workflow_dispatch backdoors. Every repository that ran those workflows handed over whatever secrets the CI environment held: AWS keys, GCP tokens, SSH keys, Docker auth configs, Kubernetes credentials. (SecurityWeek, SafeDep, CSA Labs.)

Ten days later: Microsoft published a report on 14 typosquatted npm packages, stealing cloud and CI/CD secrets via malicious postinstall scripts. Different actor, same threat category. (Microsoft Security Blog.)

Both attacks exploited structural misconfigurations that exist in most GitHub Actions setups today. Not zero-days. Configuration patterns that look completely normal to anyone who learned CI/CD from the standard tutorials.

This post covers what those patterns are and how to fix them. The five fixes are free. At the end I'll mention a starter kit that implements them — but the fixes work regardless.


The EU CRA angle: this is now a compliance problem too

The EU Cyber Resilience Act enters its reporting obligations phase in September 2026. Article 13 requires manufacturers of "products with digital elements" to document a software bill of materials and maintain verifiable build provenance. Mutable GitHub Actions tags (@v4, @main) produce builds you cannot verify after the fact — you cannot prove which code ran.

SHA-pinned actions plus SLSA provenance attestation (Fix 3 below includes this) is how you produce a build you can actually audit. If you ship software to EU customers, these fixes are not just security hygiene. They are the start of a compliance trail.


The two misconfigurations that made Megalodon work

Megalodon's payload executed because of two conditions: (1) the attacker could push a workflow file into the repository, and (2) when it ran, it had access to secrets. The stolen credentials angle is an endpoint problem CI hardening cannot solve. But the CI side has real mitigations.

Mutable action refs gave attackers a template to work from.

Most workflows look like this:

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-python@v5
  - uses: some-third-party/action@v2.1
Enter fullscreen mode Exit fullscreen mode

@v4, @v5, @v2.1 are tags. Tags are mutable. A publisher can update what a tag points to at any time. Preceding Megalodon, TeamPCP had already demonstrated the technique: force-pushing malicious commits to tag refs on popular action repos, causing downstream consumers to execute attacker code on their next CI run.

permissions: write-all gave the attacker the run of the house.

If a workflow has broad permissions and an attacker can inject a step — via a compromised action, via a pull_request_target misconfiguration, or via stolen credentials — that injected step has the same permissions as the workflow. If the workflow has permissions: write-all, the attacker can push code, create releases, and exfiltrate every secret in scope.


Fix 1: SHA-pin your actions

The only immutable ref format is a 40-character commit SHA:

steps:
  # Before:
  - uses: actions/checkout@v4

  # After:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
Enter fullscreen mode Exit fullscreen mode

The SHA cannot be retroactively changed. Your workflow runs the pinned version until you explicitly update.

Finding the correct SHA:

gh api repos/actions/checkout/git/refs/tags/v4.2.2 \
  --jq '.object.sha'
Enter fullscreen mode Exit fullscreen mode

Staying current without manual SHA hunting: add Dependabot for GitHub Actions to .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    commit-message:
      prefix: "ci(deps)"
Enter fullscreen mode Exit fullscreen mode

Dependabot opens PRs when new action versions ship. Review the diff, merge, done.


Fix 2: Scope your permissions

Set a read-only default at the workflow level, expand per-job where needed:

# Top of every workflow file:
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    # Read-only inherited — nothing to add

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      deployments: write
    steps:
      # ... deploy steps
Enter fullscreen mode Exit fullscreen mode

If an attacker injects a step into test, it has read-only access. It cannot push code or exfiltrate secrets via repository writes. Two minutes per workflow. Highest value-to-effort of the five fixes.


Fix 3: Eliminate pull_request_target + checkout by PR SHA

pull_request_target runs with the base repository's permissions and secrets, even when triggered by a fork's PR. Combined with checking out the PR's code:

# THIS IS DANGEROUS — do not use:
on: pull_request_target

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # attacker-controlled
      - run: npm install  # runs attacker code with your secrets
Enter fullscreen mode Exit fullscreen mode

Anyone who opens a PR from a fork controls what checkout retrieves. Use pull_request (not pull_request_target) for standard CI. If you need pull_request_target for auto-labeling, do not checkout the PR head code in the same job.


Fix 4: Move event context values through env: before using in run:

# Before (dangerous — script injection possible):
- name: Process PR
  run: echo "PR by: ${{ github.event.pull_request.user.login }}"

# After (safe):
- name: Process PR
  env:
    PR_USER: ${{ github.event.pull_request.user.login }}
  run: echo "PR by: $PR_USER"
Enter fullscreen mode Exit fullscreen mode

github.event.pull_request.user.login is attacker-controlled. Direct interpolation into run: allows shell metacharacter injection. The same applies to pull_request.title, pull_request.body, issue.title, head_commit.message, and any other user-controlled webhook payload value.


Fix 5: Audit npm dependencies for postinstall scripts

The May 28 Microsoft report is a reminder that npm install in CI is a supply-chain boundary. All 14 malicious packages used postinstall scripts to exfiltrate credentials — scripts that run automatically during npm install with no confirmation.

# Lockfile-exact install, no drift:
npm ci

# Disable postinstall for trusted-dependency installs:
npm install --ignore-scripts

# Audit which packages in your tree have postinstall scripts:
npm ls --parseable | xargs -I{} node -e "
  try {
    const p = require('{}/package.json');
    if (p.scripts && p.scripts.postinstall) {
      console.log('postinstall found in:', '{}');
    }
  } catch(e) {}
"
Enter fullscreen mode Exit fullscreen mode

--ignore-scripts breaks packages that need postinstall to compile native extensions. In that case, review and explicitly allow those specific packages rather than turning the flag off globally.


What these five fixes add up to

SHA-pinned actions, scoped permissions, no pull_request_target + checkout misuse, env: for event context, and postinstall awareness do not make your CI invulnerable. They remove the structural misconfigurations that make attacks like Megalodon consequential.

If an attacker pushes a malicious workflow (via stolen credentials — the endpoint problem), a SHA-pinned, permission-scoped workflow limits what it can do when it runs. That is the goal.


The starter kit (coming soon)

These five fixes — plus Trivy scanning, cosign keyless image signing, SLSA Level 1 provenance attestation, TruffleHog secret scanning on PRs, and a scheduled Dependabot config — are what I'm packaging into ready-to-paste GitHub Actions workflow templates.

What's planned for the pack:

  • ci-base-hardened.yml — minimal hardened starter for any stack
  • ci-node-hardened.yml — Node.js with npm ci, postinstall audit, npm audit gate
  • ci-python-hardened.yml — Python with pip-audit against OSV + PyPI advisory DB
  • ci-docker-hardened.yml — Docker build with Trivy scan, cosign keyless signing, SBOM attestation
  • ci-release-hardened.yml — release workflow with SLSA Level 1 provenance
  • ci-pr-gate.yml — PR gate with dependency-review, TruffleHog, mutable-ref detector
  • ci-schedule-audit.yml — weekly scheduled audit via the github-actions-audit MCP tool
  • dependabot-actions.yml — keeps SHA pins current via automated PRs
  • security-policy-template.md — SECURITY.md so researchers don't open public issues

Drop them into .github/workflows/, follow the per-file setup instructions, and you'd have a hardened baseline in under an hour. It'll be €19, one payment, no subscription — but it's not out yet. I'm gauging interest before I finish it: claim a free spot and I'll send one email the day it ships, plus early-bird pricing for list members.

Join the free early-access list →


Built by Noel @ Unbearable Labs. The newsletter covers practical homelab and agent ops — weekly, no filler.

Top comments (1)

Collapse
 
uzoma_uche_3ec83974b4a8a5 profile image
Echo

Two in a week is alarming. The most useful step teams can take is locking down post-install hooks in package.json with an explicit allow-list — most of these attacks only work because the install script is allowed to do whatever it wants.