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
@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
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'
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)"
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
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
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"
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) {}
"
--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 withnpm ci, postinstall audit,npm auditgate -
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)
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.