Skip to content

Harden npm against the Shai-Hulud worm and patch 7 high-severity advisories - #136

Merged
uncleJim21 merged 4 commits into
masterfrom
jc/npm-supply-chain-hardening
Aug 5, 2026
Merged

Harden npm against the Shai-Hulud worm and patch 7 high-severity advisories#136
uncleJim21 merged 4 commits into
masterfrom
jc/npm-supply-chain-hardening

Conversation

@uncleJim21

Copy link
Copy Markdown
Owner

Two separate problems, addressed together because they share a blast radius.

1. Supply-chain hardening (active incident)

A live npm worm compromised the keyv/cacheable maintainer account on 2026-08-04 (868+ packages, 2B+ monthly installs). It 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 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_modules was 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.

  • .npmrcmin-release-age=7, applied to every npm run in the repo
  • Dockerfile — copies .npmrc and upgrades npm before npm ci
  • skills-ci.ymlnpm installnpm 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 repo
  • scripts/check-npm-hardening.js — asserts the window is actually in effect; fails CI and the image build otherwise
  • scripts/safe-npm-update.sh — installs with scripts disabled, scans for known droppers/C2 indicators, flags unexpected install hooks, shows the lockfile diff

Why the npm upgrade in the Dockerfile matters

node:20-bullseye bundles npm 10.8.2, which predates min-release-age and silently ignores it — no warning, no error. Copying .npmrc into the image alone bought nothing: a container build resolved @aws-sdk/client-s3 3.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 under canvas/sharp/secp256k1.

2. Dependency patches

Clears every open high-severity Dependabot alert. All target versions predate the quarantine window, so none required bypassing it.

Package Advisory
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 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.

Also drops prefer-offline from .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 misleading ETARGET. It offered no security benefit.

Verification

  • 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, relay-pool (6) suites pass
  • Quarantine window verified against real resolution, not npm view (which ignores it): gate on → 3.1097.0, gate off → 3.1102.0
  • Hardening check verified in both directions in node:20-bullseye — passes with the npm upgrade, fails the build with Requires npm >= 11.10 — this environment has 10.8.2 without it

Expected impact

Closes 21 of 22 open alerts. The survivor is elliptic (low) — no patched version exists; it arrives via bolt11 → secp256k1 → elliptic and should be dismissed as "no fix available".

Follow-ups (not in this PR)

  • brace-expansion has 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 via archiver and swagger-autogen, both build-time.
  • No .github/dependabot.yml exists, so Dependabot never opens security-update PRs. Worth adding with grouping — axios alone produced 10 advisories in one day.
  • sharp 0.35 tightened its exports map; require('sharp/package.json') no longer resolves. No app code does this.

🤖 Generated with Claude Code

uncleJim21 and others added 4 commits August 4, 2026 14:37
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>
@uncleJim21
uncleJim21 merged commit 8257495 into master Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant