Harden npm against the Shai-Hulud worm and patch 7 high-severity advisories - #136
Merged
Conversation
An active npm worm (compromised keyv/cacheable maintainer account, 868+ packages as of 2026-08-04) ships its payload in a preinstall hook and harvests .env files, SSH keys, npm tokens, and cloud credentials on install, then self-propagates. This repo keeps .env and .l402-creds in the working directory, so a single executed install script would be an immediate credential compromise. Enforce a 7-day quarantine window on dependency resolution. The worm's model is publish-and-propagate within minutes and researchers flag compromised versions within ~24h, so a version old enough to install is old enough to have been caught. - .npmrc: min-release-age=7, applied to every npm run in the repo - Dockerfile: COPY .npmrc before npm ci, so image builds are covered too - skills-ci.yml: npm install -> npm ci --ignore-scripts. `npm install` re-resolved semver ranges every run, so a freshly-published malicious version could reach CI secrets with no commit to this repo - check-npm-hardening.js: asserts the window is actually in effect and fails CI otherwise, since deleting .npmrc would otherwise degrade installs silently - safe-npm-update.sh: installs with scripts disabled, scans for known droppers/C2 indicators, flags unexpected install hooks, shows the lockfile diff Note the unit is DAYS, not minutes as several write-ups state, and npm normalizes it into `before` rather than echoing it back, so `npm config get min-release-age` always reads null. Verified against real resolution: with the gate @aws-sdk/client-s3 resolves 3.1097.0, without it 3.1102.0 (published within the window). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
node:20-bullseye bundles npm 10.8.2, which predates min-release-age and silently ignores it — `npm config get before` reads null, no warning is emitted, and resolution proceeds unprotected. Copying .npmrc into the image therefore bought nothing: a container build resolved @aws-sdk/client-s3 3.1103.0, published the same day, while the host (npm 11.10.0) correctly held at 3.1097.0. Every Node 20 and 22 release is affected; only Node 24+ bundles npm >= 11.10. Rather than move Node majors under native deps (canvas, sharp, secp256k1), pin an npm upgrade in the image and assert the result. Verified in node:20-bullseye: with the upgrade the build resolves 3.1097.0 and the check passes; with the upgrade removed the check fails the build with "Requires npm >= 11.10 — this environment has 10.8.2" rather than silently regressing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps the direct deps, devDeps and overrides needed to clear every open high alert. All target versions predate the 7-day quarantine window, so none required bypassing it. axios 1.17.0 -> 1.18.1 GHSA-gcfj-64vw-6mp9 (+8 moderate) ip-address 10.2.0 -> 10.3.1 GHSA-mwp4-54f8-5fhr (+2 moderate) js-yaml 4.1.1 -> 4.3.0 GHSA-52cp-r559-cp3m (+1 moderate) sharp 0.33.5 -> 0.35.3 GHSA-f88m-g3jw-g9cj (libvips CVEs) form-data 4.0.5 -> 4.0.6 GHSA-hmw2-7cc7-3qxx mongoose 8.22.1 -> 8.24.2 GHSA-664h-wqgq-64gw body-parser 1.20.4 -> 1.20.6 GHSA-v422-hmwv-36x6 brace-expansion (GHSA-3jxr-9vmj-r5cp) needed care: the advisory covers three disjoint ranges and this tree carries both 1.x and 2.x, so a single override would have forced 2.x consumers down to 1.x. Uses per-major override keys instead. Dependabot only surfaced the 1.x instance; the 2.x copies under archiver were vulnerable too. The axios override had to move in lockstep with the direct dependency — npm rejects an override that conflicts with a direct dep (EOVERRIDE). Also drops prefer-offline from .npmrc. It pinned resolution to a packument cache built on 2026-06-04, hiding every version published since, and made these upgrades fail with a misleading ETARGET claiming no version existed before the quarantine cutoff. It offered no security benefit; min-release-age is what provides that. Verified: sharp 0.35.3 on libvips 8.18.3 exercises the resize/toBuffer/ composite paths used by VideoGenerator.js; all core and app modules load; zap-validator (14), reranker and relay-pool (6) suites pass. Note sharp 0.35 tightened its exports map, so sharp/package.json is no longer requirable — no app code does this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hardening check did its job and failed the build: setup-node@v4 with node-version 20 provisions npm 10.8.2, which predates min-release-age and silently ignores it. Same trap already fixed for node:20-bullseye in the Dockerfile — CI was the remaining path where `npm ci` would have resolved dependencies with no quarantine window and no visible symptom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two separate problems, addressed together because they share a blast radius.
1. Supply-chain hardening (active incident)
A live npm worm compromised the
keyv/cacheablemaintainer account on 2026-08-04 (868+ packages, 2B+ monthly installs). It ships its payload in apreinstallhook and harvests.envfiles, SSH keys, npm tokens and cloud credentials on install, then self-propagates. This repo keeps.envand.l402-credsin the working directory, so one executed install script is a credential compromise.No indicators of compromise were found — no dropper files, no C2 strings, no persistence, no forged commits.
node_moduleswas last installed 2026-06-04, two months before this wave, and none of the affected package families are in the tree.Defense is a 7-day quarantine window on dependency resolution. The worm propagates within minutes and researchers flag compromised versions within ~24h, so a version old enough to install is old enough to have been caught.
.npmrc—min-release-age=7, applied to every npm run in the repoDockerfile— copies.npmrcand upgrades npm beforenpm ciskills-ci.yml—npm install→npm ci --ignore-scripts. The old form re-resolved semver ranges every run, so a freshly-published malicious version could reach CI secrets with no commit to this reposcripts/check-npm-hardening.js— asserts the window is actually in effect; fails CI and the image build otherwisescripts/safe-npm-update.sh— installs with scripts disabled, scans for known droppers/C2 indicators, flags unexpected install hooks, shows the lockfile diffWhy the npm upgrade in the Dockerfile matters
node:20-bullseyebundles npm 10.8.2, which predatesmin-release-ageand silently ignores it — no warning, no error. Copying.npmrcinto the image alone bought nothing: a container build resolved@aws-sdk/client-s33.1103.0, published that same day, while the host correctly held at 3.1097.0. Every Node 20 and 22 release is affected; only Node 24+ bundles npm >= 11.10. Chose to pin an npm upgrade rather than move Node majors undercanvas/sharp/secp256k1.2. Dependency patches
Clears every open high-severity Dependabot alert. All target versions predate the quarantine window, so none required bypassing it.
brace-expansionneeded care: the advisory covers three disjoint ranges and this tree carries both 1.x and 2.x, so a single override would have forced 2.x consumers down to 1.x. Uses per-major override keys instead. Dependabot only surfaced the 1.x instance — the 2.x copies underarchiverwere vulnerable too.Also drops
prefer-offlinefrom.npmrc. It pinned resolution to a packument cache built on 2026-06-04, hiding every version published since — security patches included — and made these very upgrades fail with a misleadingETARGET. It offered no security benefit.Verification
sharp0.35.3 on libvips 8.18.3 exercises the resize/toBuffer/composite paths used byVideoGenerator.jsnpm view(which ignores it): gate on → 3.1097.0, gate off → 3.1102.0node:20-bullseye— passes with the npm upgrade, fails the build withRequires npm >= 11.10 — this environment has 10.8.2without itExpected impact
Closes 21 of 22 open alerts. The survivor is
elliptic(low) — no patched version exists; it arrives viabolt11 → secp256k1 → ellipticand should be dismissed as "no fix available".Follow-ups (not in this PR)
brace-expansionhas a newer advisory (GHSA-rgw5-rvv9-x895) whose patches are currently 6.1 days old and blocked by our own window; re-run installs after 2026-08-06. Only reachable viaarchiverandswagger-autogen, both build-time..github/dependabot.ymlexists, so Dependabot never opens security-update PRs. Worth adding with grouping — axios alone produced 10 advisories in one day.sharp0.35 tightened itsexportsmap;require('sharp/package.json')no longer resolves. No app code does this.🤖 Generated with Claude Code