Supply-chain security

Threat model and rationale behind the site’s npm dependency controls

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.
    • Minimize dependencies: unused and convenience dependencies are dropped rather than carried.
  • 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 local npm install can 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.
    • 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.
  • 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.
  • 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.
  • 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.
  • A control that silently stops being enforced is worse than none.
    • Fail closed on old npm: installs fail rather than proceed when the active npm is too old to enforce the .npmrc settings.
    • Verify, don’t trust: inert and lock-exact are verified claims, not assumptions.

Enforcement at a glance:

DecisionEnforced by
Minimize dependenciesMaintainer judgment in dependency review; no mechanical control
Install from the locknpm ci in every install contract
Resolve deliberatelyConvention and disabled lockFileMaintenance in renovate.json5
Resolve only cooled-down releasesCooldown in npm and Renovate
Run only reviewed lifecycle scriptsThe allowlist in strict mode; unreviewed fails the install
Refuse Hugo installer overridesThe rebuild wrapper’s environment screen, before any rebuild attempt
Neutralize the auto-installThe inert auto-install control
Invoke bins, not namesThe no bare npx rule; review discipline, no mechanical control
Fail closed on old npmThe npm engines floor with strict engine checking
Verify, don’t trustSupply-chain audit, clean-working-tree checks, a postinstall warning on lock rewrites

Prior art

  • Default-deny lifecycle scripts is the ecosystem direction:
    • pnpm and Yarn block dependency scripts by default.
    • npm’s accepted RFC #54 brings the same model to npm through allowScripts, version-exact entries included.
  • Release cooldowns are established practice:
    • pnpm defers releases younger than a day by default.
    • Renovate’s npm minimumReleaseAge security 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: