Harden npm supply-chain posture - #381
Draft
MatiasFernandez wants to merge 10 commits into
Draft
Conversation
MatiasFernandez
force-pushed
the
mfernandez/harden-npm-supply-chain
branch
6 times, most recently
from
August 6, 2026 18:32
e3f183d to
6e71499
Compare
MatiasFernandez
force-pushed
the
mfernandez/harden-npm-supply-chain
branch
6 times, most recently
from
August 7, 2026 20:49
02751f7 to
412b103
Compare
strict-allow-scripts (needed by the next commits) is only recognized from npm 11.16.0; the repo was on 11.12.1 via .nvmrc 24.15.0, where an unknown .npmrc key is merely warned about. - .nvmrc: bump to 24.19.0, the newest Node 24 LTS release, which bundles npm 11.17.0. - package.json: add devEngines, root-scoped and distinct from engines.node — enforces the npm floor without risking engine-strict's tree-wide blast radius on a future transitive dep's engines.node. - health-check.yml: run the workflow-timeout lint script with bash rather than through `npm run`, keeping the prepare job free of npm — a bare `npm run` now fails with EBADDEVENGINES on the runner's ambient npm 10, and the job would otherwise need a setup-node step it has never had. Trim its sparse-checkout to match, and update the floor job's now-unreachable sunset comment. - acceptance/Dockerfile: pin npm to @11.17.0 before npm ci, since the playwright base image ships 11.13.0. That exact version is the npm bundled by .nvmrc's Node, so the container matches a contributor's toolchain rather than running ahead of it. - Document the floor in CONTRIBUTING.md and raising-the-version-floor.md, and scope version-pins.md to .nvmrc.
strict-allow-scripts (npm ≥11.16, satisfied by the floor just raised) makes the per-package allowScripts allowlist fail-closed: an unreviewed install script throws ESTRICTALLOWSCRIPTS before reify instead of running with a warning, which is what let SHA1-Hulud-class worms execute at install time. Approvals are version-pinned, so a compromised patch of an already-trusted package reverts to unreviewed and fails the install too. Approve the tree's 5 current install-script deps, deny fsevents (optional, macOS-only, absent from Linux CI anyway), and back the control with a CI check (lint:supply-chain) so `rm .npmrc` can't silently disable it. Seed the docs/ diátaxis split so the why (explanation), the day-to-day procedure (how-to), and the current state (reference) each have one home, without duplicating npm's own docs or hardcoding versions that already live in package.json.
min-release-age=7 guards local npm install/update, the only path that can still pull a freshly published malicious release since the lockfile already pins every version. Dependabot mirrors the same 7-day cooldown so both controls tell one story, with an install-scripts group so ESTRICTALLOWSCRIPTS bumps land in one predictable PR per cycle instead of scattering across the queue.
A release-bump find-and-replace (cd0bff1) rewrote every "version": "1.6.0" to "1.6.1", sweeping up mime and sax even though neither had moved. mime's resolved tarball still points at 1.6.0 (1.6.1 doesn't exist upstream); sax's pointed at 1.6.0 while the registry has 1.6.1. Left uncorrected, advisory scanners evaluate versions that aren't installed, and npm audit signatures errors outright on the nonexistent mime@1.6.1. Regenerated via delete-entries + npm install --package-lock-only + rm -rf node_modules && npm ci. mime is corrected back to 1.6.0 (same tarball); sax is genuinely bumped to 1.6.1 (new resolved + integrity).
Add lockfile-lint (host/HTTPS/integrity/package-name checks) and npm audit signatures to the lint job, plus a version-vs-resolved drift check in lint-supply-chain.mjs. The drift check closes a gap none of the others cover: a lockfile entry whose version disagrees with its own resolved tarball, which is exactly what let mime/sax drift unnoticed for two months.
Root .gitignore's patterns are unanchored, so `.env` and `.env.*` already apply in every workspace — client/.gitignore needs no copy of its own.
npm's allowScripts accepts a bare package name (unpinned) or name@version (pinned). An unpinned allow trusts every future version of that package forever, defeating the re-review-on-bump behavior the rest of this script's checks assume is in place. Deny entries are always written unpinned by npm approve-scripts, so only allows are gated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
npm@11 as a floating major drifts as new patches release; pin to 11.17.0 — the npm bundled by .nvmrc's Node 24.19.0, matching the Dockerfile's pin — so CI runs the same npm a contributor does rather than racing ahead of it. Note in the docs that Dependabot's github-actions ecosystem won't bump either pin, since it only reads uses:, not run: strings.
MatiasFernandez
force-pushed
the
mfernandez/harden-npm-supply-chain
branch
from
August 8, 2026 00:28
412b103 to
89da924
Compare
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.
Summary
Adds preventive supply-chain controls so a compromised dependency (the Shai-Hulud / SHA1-Hulud worm class) can't execute arbitrary code at install time — closes #246.
.nvmrc→ 24.19.0, rootdevEngines) — the minimum version that supportsstrict-allow-scripts/npm approve-scripts, with theprepareCI job andacceptance/Dockerfilefixed to match.strict-allow-scripts=true+ a version-pinnedallowScriptstable inpackage.jsonmakes an unreviewed install script fail-closed instead of silently running. Approves the tree's 5 current install-script deps, deniesfsevents, and backs the control with a newlint:supply-chainCI check sorm .npmrccan't silently disable it.min-release-age=7guards localnpm install/update(the only path that can still pull a freshly published malicious release, since the lockfile pins every version), mirrored by a 7-day Dependabot cooldown with aninstall-scriptsgroup soESTRICTALLOWSCRIPTSbumps land in one predictable PR per cycle.mime/saxlockfile entries — a prior release-bump find-and-replace left theirversionfield disagreeing with the tarball their ownresolvedURL points to, which silently breaks advisory scanning and hard-blocksnpm audit signatures.lockfile-lint(host/HTTPS/integrity),npm audit signatures, and a new version-vs-resolveddrift check that closes the exact gap that letmime/saxdrift unnoticed for two months..envgitignore gap that could leak stone credentials.docs/split (explanation/how-to/reference) for the new controls, with an index.Test plan
npm cisucceeds with noEBADDEVENGINES/ESTRICTALLOWSCRIPTSnpm run lint && npm run format:check && npm run compile && npm testnpm run package(vsce's dependency walk, ships koffi's native binary) +npm run test:gciagainst a live stone.vsixcontents exclude.npmrc/.nvmrcallowScriptsentry,lint:supply-chaincatches a missing.npmrcand a drift lockfile entry,lockfile-lintcatches a tamperedresolvedhostnpm audit signaturespasses over the full treeprepare,lint, and floor jobs green end to end