Supply-chain security
For the controls themselves and day-to-day procedures, see Dependency management. Neighboring security topics have their own homes: the design of the audit that verifies these controls in Supply-chain audit design, workflow trigger and token privileges in CI workflows, and vulnerability reporting in the security policy.
Threat model
The August 2026 npm worm (security notice) set the current posture:
malicious versions of popular npm packages were published from compromised
maintainer accounts. The packages’ install-time lifecycle scripts executed
the payload and propagated it using harvested credentials. Several of this
repository’s PR branches were affected before containment; none reached main
or production.
The attack paths that matter for this repository:
- Version resolution: any install that resolves version ranges can pull a freshly published malicious release.
- Lifecycle scripts: install-time script execution lets a bad package compromise contributor hosts, CI runners, and build images.
- Unattended installs: CI jobs and the Netlify build image install without a human watching, as do agent sessions.
- Name resolution: a tool invocation that can reach the registry by name
(
npx) executes whichever package holds that name when the local install is stale or missing.
Design decisions
The recurring theme is fail closed: when a control can’t be enforced, the install fails rather than proceeding without it.
Each decision answers an attack path, ordered roughly by when it acts. The closing table maps decisions to their enforcement.
- Every dependency, direct or transitive, is surface an attacker can reach.
- An install that resolves version ranges can pull a freshly published
malicious release.
- Install from the lock: installs are
lock-exact, reproducing the committed, reviewed
package-lock.json. The one exception: a localnpm installcan rewrite a disagreeing lock; verification catches such rewrites. - Resolve deliberately: version resolution happens
only in deliberate dependency updates, never as an install
side effect.
- Renovate’s scheduled wholesale lock re-resolve (
lockFileMaintenance) is disabled by design: a standing tree-wide registry draw buys only routine transitive freshness, which alert-driven fixes already cover.
- Renovate’s scheduled wholesale lock re-resolve (
- Resolve only cooled-down releases: even
deliberate resolution ignores releases younger than a cooldown
period; registry-side takedowns of malicious releases need a few
days to land.
- One designed exception: Dependabot security updates ship known-vulnerability fixes immediately.
- The cooldown covers registry-resolved packages; the Node toolchain pin follows the floor policy instead, since signed project builds don’t share the registry’s takedown-lag risk.
- Install from the lock: installs are
lock-exact, reproducing the committed, reviewed
- A package’s install-time scripts run attacker code on contributor hosts and
build machines: the worm’s payload path.
- Run only reviewed lifecycle scripts: lifecycle
scripts are default-deny.
- Approvals are version-exact, so a compromised patch release can’t inherit its predecessor’s approval.
- Reviews record denials too, so silence always means unreviewed.
- Exceptions are named and re-enabled inline at the point of use, never by weakening the default posture.
- Run only reviewed lifecycle scripts: lifecycle
scripts are default-deny.
- The one re-enabled hook fetches the pinned Hugo binary; the installer honors
environment overrides that can repoint or unpin that fetch.
- Refuse Hugo installer overrides: the rebuild wrapper refuses to run while any of them is set.
- Netlify’s own npm install runs unattended, outside the
scripts this repository controls, and can’t be disabled.
- Neutralize the auto-install: the configuration neutralizes it; the build command performs the real install.
- A bin invoked by registry-resolvable name runs whoever claims the name when
the local install is stale; a squat of an unregistered bin name proved this in
June.
- Invoke bins, not names: repository wiring
never uses bare
npx; bins come from the installed dependency tree or fail loudly.
- Invoke bins, not names: repository wiring
never uses bare
- A control that silently stops being enforced is worse than none.
Enforcement at a glance:
| Decision | Enforced by |
|---|---|
| Minimize dependencies | Maintainer judgment in dependency review; no mechanical control |
| Install from the lock | npm ci in every install contract |
| Resolve deliberately | Convention and disabled lockFileMaintenance in renovate.json5 |
| Resolve only cooled-down releases | Cooldown in npm and Renovate |
| Run only reviewed lifecycle scripts | The allowlist in strict mode; unreviewed fails the install |
| Refuse Hugo installer overrides | The rebuild wrapper’s environment screen, before any rebuild attempt |
| Neutralize the auto-install | The inert auto-install control |
| Invoke bins, not names | The no bare npx rule; review discipline, no mechanical control |
| Fail closed on old npm | The npm engines floor with strict engine checking |
| Verify, don’t trust | Supply-chain audit, clean-working-tree checks, a postinstall warning on lock rewrites |
Prior art
- Default-deny lifecycle scripts is the ecosystem direction:
- Release cooldowns are established practice:
- pnpm defers releases younger than a day by default.
- Renovate’s npm
minimumReleaseAgesecurity preset sets 3 days, tracking npm’s 72-hour unpublish window; the longer value used here is in line with cooldowns adopted elsewhere in the ecosystem.
- The control set maps onto established framework guidance:
- TUF’s attack taxonomy: arbitrary software installation, mix-and-match, and extraneous-dependencies attacks.
- The OpenSSF npm guide: lock-exact CI installs.