Hardening a release pipeline after npm's worst year
I maintain Cuewise, a new-tab Chrome extension. That makes it a high-value target: it auto-updates silently to every browser that installed it, so the most valuable thing I own isn’t the code. It’s the release pipeline and the Chrome Web Store tokens at the end of it. Compromise those and you ship malware to everyone, signed by me.
2025 made that worry concrete, so I spent a review tightening the whole path from a git push to the store. I wrote the lighter, product-facing version on the Cuewise blog, Securing Cuewise releases. This is the engineer’s-eyes version: the threats, the layers, the YAML, and the gotchas the shorter post leaves out.
A rough year for the registry
The attacks stopped being theoretical:
- tj-actions/changed-files (CVE-2025-30066, March 2025). An attacker stole a bot’s access token and re-pointed nearly every version tag (
v1throughv45, including the popularv35) at a single malicious commit. The payload dumped the Actions runner’s memory, regex’d out secrets, double-base64-encoded them to dodge GitHub’s log masking, and printed them into publicly readable build logs. Around 23,000 repos referenced the action; ~218 actually leaked secrets. The lesson is simple: pinning to a tag would not have saved you; pinning to a commit SHA would have. StepSecurity caught it via one anomalous outbound connection togist.githubusercontent.com. - chalk / debug (September 2025). The maintainer behind ~18 tiny-but-everywhere packages (
chalk,debug,ansi-styles,strip-ansi) was phished by a fakenpmjs.help“update your 2FA” email. Combined weekly downloads in the billions. The injected payload was a browser crypto-clipper that rewrote wallet addresses in flight. It was live for about two hours. - Shai-Hulud (September 2025, resurgent in November). The first true self-replicating npm worm: using a stolen npm token, it enumerated every other package the victim maintained, injected itself, and republished them. Then it ran TruffleHog to scrape more secrets and pushed the loot to public GitHub repos. The November wave hit ~796 packages and added a destructive fallback that wiped home directories on failure.
- Nx “s1ngularity” (August 2025). A vulnerable
pull_request_targetworkflow let a malicious PR title run code and steal the npm publishing token. The post-install payload harvested tokens, SSH keys and.envfiles. In a first I hadn’t seen before, it invoked locally-installed AI CLIs with their permission prompts disabled to help hunt for secrets.
Four different entry points: a stolen CI token, a phished maintainer, a stolen npm token, an injectable workflow. All converge on the same prize: the credential that publishes, and the code that runs next to it.
My threat model
Strip it down and a release pipeline has three things worth attacking:
- A compromised build-time dependency: something in
node_modulesthat exfiltrates secrets, tampers with the artifact, or pulls a second stage. - Stolen publishing credentials: the Chrome Web Store tokens that push to every user.
- Unreviewed code reaching the released branch: the human and process gap.
Everything below is defense-in-depth against those three, with each layer enforced by GitHub rather than by my good intentions. The Cuewise post walks the same layers with a friendly diagram; here I’ll dwell on the mechanics.
Only trusted code reaches the branch that ships
Releases ship from main, so main has to be hard to corrupt. Branch protection now requires a PR and a green Build and Test check before merge, and blocks force-pushes and deletions. As a solo maintainer I set zero required approvals (a one-approval rule would lock me out of merging my own work), but kept an admin escape hatch for emergencies. Security that locks the only maintainer out gets disabled within a week.
The Chrome Web Store tokens live in a dedicated GitHub Environment with a required reviewer and a 15-minute wait timer. The change that mattered was a deployment-branch policy pinning that environment to main. Before, only the workflow’s if: logic kept the secrets off other branches, and that logic is mutable on any branch and bypassable via workflow_dispatch. Now GitHub enforces it at the environment layer, not in a file an attacker could edit. In the workflow it is one line on the publish job:
publish:
needs: build
environment: chrome-web-store # reviewer + 15-min wait + main-only, enforced by GitHub
if: github.ref == 'refs/heads/main'
Isolate the secrets from third-party code
This is the highest-value property of the whole pipeline, and it maps directly to SLSA build level 3: secret material must not be accessible to user-defined build steps.
The publish job checks out a single file, the publish script, and runs it on bare Node with no install step:
# publish job: one file checked out, nothing installed, run on bare Node
- uses: actions/checkout@<sha> # v4
with:
sparse-checkout: scripts/publish-chrome.mts
sparse-checkout-cone-mode: false
- uses: actions/setup-node@<sha> # v4
with:
node-version: 24 # native TS type-stripping: no tsx, no deps
- run: node scripts/publish-chrome.mts "$ZIP"
env:
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
No pnpm install, no tsx, no dependencies. So in the one job that can read the store tokens, zero lines of third-party code ever execute. A Shai-Hulud-style postinstall has nothing to run in the job that holds the credential.
Lock down what the build can talk to
Everything else runs under StepSecurity’s harden-runner in block mode: egress is denied unless the destination is on a per-job allowlist. A tampered dependency can’t phone home or fetch a second stage if its server isn’t on the list.
Sizing that allowlist is the interesting part. You can’t guess it; you measure it. Run once in audit mode, read the actual egress from the run’s insights, then block to exactly that. Every job opens with the same step, tuned to its measured minimum:
# build job: deny all egress except the measured allowlist
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2
with:
disable-sudo: true
egress-policy: block
allowed-endpoints: >
github.com:443
registry.npmjs.org:443
fulcio.sigstore.dev:443
rekor.sigstore.dev:443
tuf-repo-cdn.sigstore.dev:443
The release and publish jobs carry even shorter lists:
release: github.com:443 api.github.com:443 uploads.github.com:443
publish: github.com:443 oauth2.googleapis.com:443 www.googleapis.com:443
audit data you turn into the block allowlist.The surprise: GitHub’s own Actions infrastructure, the artifact and cache backends on rotating *.blob.core.windows.net subdomains, doesn’t belong on your list at all. harden-runner baseline-allows the runner’s traffic to the Actions service, so you only allowlist what your own steps reach. That let me delete a *.blob.core.windows.net wildcard that would otherwise have permitted egress to any Azure tenant. Every job also runs with disable-sudo.
If a block allowlist is ever incomplete, the job simply fails. Because publish and release depend on a green build, nothing ships. The failure mode is safe.
block: every connection tagged to the step that opened it, scored against a 180-run baseline. Zero blocked, zero anomalous.Pin actions to SHAs, and keep them fresh
Every third-party action is pinned to a full commit SHA, the one thing that would have stopped tj-actions. Pinning has an obvious objection (pins go stale), and Dependabot answers it: it bumps the SHA and the human-readable tag in the trailing comment together, so uses: foo@<sha> # v4.2.0 stays current through reviewed PRs.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions # bumps the SHA and its "# vX" comment together
directory: /
schedule: { interval: weekly }
- package-ecosystem: npm
directory: /
schedule: { interval: weekly }
groups:
npm-minor-patch: # majors still arrive as individual PRs
update-types: [minor, patch]
I run npm through Dependabot with alerts and automated security fixes too; enabling them surfaced six existing vulnerabilities on main immediately.
Prove what shipped
Each release carries a signed SLSA provenance attestation, generated right after the version step so the attested digest is exactly the zip that ships. It’s keyless: Sigstore’s Fulcio issues a short-lived signing certificate bound to the workflow’s OIDC identity, and the signature lands in the Rekor transparency log. The build job grants the two scopes Sigstore needs and signs the zip it just built:
# build job
permissions:
contents: read
id-token: write # OIDC identity for keyless signing
attestations: write # upload the attestation
steps:
# ...after the zip is built:
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4
with:
subject-path: build/cuewise-extension-${{ steps.version.outputs.version }}.zip
Anyone can then verify a release came from this repo and commit:
gh attestation verify cuewise-extension-<version>.zip --repo kYem/cuewise
One gotcha worth knowing before a locked-down build mysteriously fails to sign: public repos attest against the public-good Sigstore, so the build job’s egress allowlist has to include fulcio, rekor and tuf-repo-cdn.sigstore.dev, which is why they’re in the harden-runner step above. Private repos use GitHub’s own Sigstore instance, with different endpoints.
Wait out the danger window
The chalk/debug versions were pulled within hours. So the cheapest defense is patience: pnpm’s minimumReleaseAge refuses to install any version published too recently.
# pnpm-workspace.yaml
minimumReleaseAge: 20160 # minutes (14 days); skip versions younger than this
Most malicious releases are caught and unpublished long before that window closes. The ecosystem agrees so strongly that pnpm 11 made the cooldown a default.
Shrink the exfiltration surface
The extension’s CSP connect-src went from a wide-open * to a short allowlist of the handful of hosts the code actually calls. A wildcard connect-src is a quiet exfiltration channel if an extension page is ever XSS’d or a dependency is compromised. It’s the same principle as the egress allowlist, applied at the client.
Where the ecosystem is heading
The encouraging part is that a lot of this is becoming the default rather than the exception. npm trusted publishing via OIDC went GA in July 2025: publish from CI with a short-lived token and there’s no npm secret left to steal. npm is retiring long-lived classic tokens entirely and pushing phishing-resistant passkeys. GitHub can now enforce SHA-pinned actions at the org level.
The lesson the whole industry took from 2025 is the same one that shaped this pipeline: don’t store a secret you can avoid storing, and never let untrusted code run next to the secrets you can’t.
The checklist
If you maintain anything that auto-updates its users, the whole post collapses to this:
- Pin every third-party action to a full commit SHA, and let Dependabot keep the pins current.
- Keep
GITHUB_TOKENread-only by default; escalate per job, never globally. - Hold publishing credentials in a protected environment: required reviewer, wait timer, release-branch only.
- Keep third-party code out of the one job that can read those credentials.
- Run CI under egress
blockwith a measured allowlist, plusdisable-sudo. - Sign releases with build provenance, then verify them with
gh attestation verify. - Add an install cooldown (
minimumReleaseAge) and move to OIDC trusted publishing where you can.
If you want the shorter, friendlier version of all this, it’s on the Cuewise blog. And if you ship anything that publishes, go verify your own releases. The command’s right there.
Further reading
The incidents, from the people who dissected them:
- Wiz on the tj-actions/changed-files breakdown (CVE-2025-30066)
- StepSecurity on the chalk / debug maintainer phish
- Unit 42’s Shai-Hulud worm tracker, and Datadog on Shai-Hulud 2.0
- Wiz on the Nx “s1ngularity” compromise
And the defenses, from the source:
- GitHub: npm trusted publishing with OIDC and strengthening npm auth & tokens
- GitHub Docs: security hardening for GitHub Actions (SHA-pinning, least-privilege tokens, OIDC)
- SLSA build levels and npm package provenance
- StepSecurity’s harden-runner