|
| 1 | +# Plan: npm Supply-Chain Security — Lockfile Lint |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Add a PR check that validates `yarn.lock` against trusted registries, |
| 6 | +preventing lockfile injection attacks (modified `resolved` URLs pointing |
| 7 | +to attacker-controlled hosts). |
| 8 | + |
| 9 | +## Scope decisions |
| 10 | + |
| 11 | +| Decision | Choice | Rationale | |
| 12 | +|----------|--------|-----------| |
| 13 | +| Scope | Lockfile-lint only | See "Cooldown abandoned" below | |
| 14 | +| Dependabot | Skip — handled at org level | Already configured organization-wide | |
| 15 | +| Enforcement | PR check (GitHub Actions) | Cannot be bypassed; no local friction | |
| 16 | +| Workflow style | Dedicated `pr-lockfile-lint.yml` | Separate concern, separate trigger path | |
| 17 | +| lockfile-lint install | `npx` with pinned version | Zero new dependencies in `package.json` | |
| 18 | +| Pinned version | `lockfile-lint@5.0.0` | Latest stable, published with provenance via GitHub Actions OIDC | |
| 19 | + |
| 20 | +## Cooldown abandoned (originally Step 1) |
| 21 | + |
| 22 | +The original plan included adding `.npmrc` with `min-release-age=3`. |
| 23 | +Dropped after verification revealed two independent reasons it provides |
| 24 | +zero runtime protection to this repo today: |
| 25 | + |
| 26 | +1. **Yarn Classic v1 ignores the setting.** Yarn Classic was frozen in |
| 27 | + early 2022; npm CLI 11.10.0 added `min-release-age` in late 2025. |
| 28 | + Yarn Classic reads `.npmrc` only for registry URL and auth tokens, |
| 29 | + not arbitrary npm resolution config. Verified empirically — `yarn |
| 30 | + install --frozen-lockfile` runs cleanly with `.npmrc` present and |
| 31 | + silently ignores the key. |
| 32 | + |
| 33 | +2. **Node 22.x bundles npm 10.9.7** (verified from `deps/npm/package.json` |
| 34 | + on the `v22.x` branch of `nodejs/node`). The one workflow in this repo |
| 35 | + that uses `npm install` (`auto-label.yml`) pins `node-version: '22'`, |
| 36 | + so even if it did use an `.npmrc`, the bundled npm is too old to |
| 37 | + understand `min-release-age`. Node 24.x ships npm 11.11.0 and would |
| 38 | + work, but upgrading `auto-label.yml` is out of scope for this PR. |
| 39 | + |
| 40 | +Real cooldown protection for this repo requires migrating away from |
| 41 | +Yarn Classic. Tracked separately — see "Follow-up: pnpm migration". |
| 42 | + |
| 43 | +## Steps |
| 44 | + |
| 45 | +### Step 1: Create `pr-lockfile-lint.yml` workflow |
| 46 | + |
| 47 | +Create `.github/workflows/pr-lockfile-lint.yml` that: |
| 48 | + |
| 49 | +- Triggers on PRs that modify `yarn.lock` |
| 50 | +- Checks out the repo (no auth, shallow) |
| 51 | +- Runs `npx --yes lockfile-lint@5.0.0` against `yarn.lock` |
| 52 | +- Validates: all resolved URLs use HTTPS and resolve to |
| 53 | + `registry.yarnpkg.com` (the `yarn` alias) |
| 54 | +- Fails the check if any URL is non-HTTPS or points to an untrusted host |
| 55 | + |
| 56 | +### Step 2: Verify |
| 57 | + |
| 58 | +- Baseline check: `grep 'resolved ' yarn.lock | grep -v 'https://registry.yarnpkg.com/'` |
| 59 | + currently returns zero matches (802 entries, all clean). The first |
| 60 | + run of the check must pass. |
| 61 | +- `actionlint` validates workflow syntax. |
| 62 | +- Open this PR; confirm the `Lockfile Lint` check appears and passes. |
| 63 | + |
| 64 | +## Follow-up: pnpm migration (separate PR) |
| 65 | + |
| 66 | +Real cooldown protection — plus several additional security benefits — |
| 67 | +requires migrating from Yarn Classic to pnpm. Tracked separately. |
| 68 | +Summary of what pnpm provides that Yarn Classic does not: |
| 69 | + |
| 70 | +- **First-class `minimumReleaseAge` cooldown** with exclude list support |
| 71 | +- **Post-install scripts disabled by default** (explicit allowlist via |
| 72 | + `onlyBuiltDependencies`) |
| 73 | +- **`trustPolicy: no-downgrade`** — detects when a package's publish-time |
| 74 | + trust level decreases (e.g., previously via GitHub Actions OIDC, |
| 75 | + now without provenance) |
| 76 | +- **Stricter peer dependency resolution** (catches latent issues) |
| 77 | +- **Faster installs** via content-addressable store |
| 78 | +- **Drop-in compatible** with this PR's `lockfile-lint` check |
| 79 | + |
| 80 | +Migration scope for docs-v2 (single-package repo, no workspaces): |
| 81 | + |
| 82 | +- 14 workflow files: `yarn install --frozen-lockfile` → `pnpm install --frozen-lockfile` |
| 83 | +- Lockfile: `yarn.lock` → `pnpm-lock.yaml` (regenerated fresh) |
| 84 | +- New config: `pnpm-workspace.yaml` (optional but needed for cooldown/trust config) |
| 85 | +- Developer onboarding: `corepack enable` (built into Node 16+) |
| 86 | +- Risk: stricter peer deps may surface latent issues; mitigated by |
| 87 | + running full test suite before merge |
| 88 | + |
| 89 | +## Out of scope (future work) |
| 90 | + |
| 91 | +### Medium priority |
| 92 | + |
| 93 | +- **Add `lint:lockfile` to lefthook pre-push** — local enforcement as |
| 94 | + belt-and-suspenders alongside the PR check. Skippable with |
| 95 | + `LEFTHOOK=0`, so lower priority than CI. |
| 96 | +- **Pin version ranges** — replace `^` and `>=` with exact versions for |
| 97 | + production dependencies in `package.json`. High churn, moderate risk |
| 98 | + reduction. |
| 99 | + |
| 100 | +### Low priority |
| 101 | + |
| 102 | +- **Install npq or sfw globally** — document recommended developer |
| 103 | + machine hardening for ad-hoc `npm install` commands. |
| 104 | +- **Document the postinstall exemption** — explain in CONTRIBUTING or |
| 105 | + CLAUDE.md why `--ignore-scripts` cannot be used globally (the repo's |
| 106 | + `postinstall` script sets up the `docs` CLI). |
0 commit comments