chore(security): add hardened .npmrc without breaking executables (PER-9672) - #2396
Conversation
…R-9672) Re-lands the supply-chain hardened root .npmrc (PER-8367), this time with the executable build made immune to `ignore-scripts=true`. npm 6 — the version bundled with the Node 14 toolchain scripts/executable.sh pins — applies `ignore-scripts` to an explicit `npm run` and exits 0 without running the script, so `npm run build_cjs` became a silent no-op, `build/` was never produced, and the build failed later on `cp: ./build/*: No such file or directory`. That broke the executables for v1.32.0-beta.8 .. v1.32.1 and led to the .npmrc being reverted (#2303). The build_cjs step now opts out explicitly via npm_config_ignore_scripts=false, and an assertion on ./build fails the build loudly if the transpile ever emits nothing again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semgrep blocks on package_managers.npm.npm-missing-minimum-release-age: an .npmrc without a minimum release age lets a freshly published (possibly compromised) version be resolved immediately. npm honours this from v11.10; npm 6 and Yarn 1 — the toolchains this repo builds and installs with — parse it and ignore it, so nothing in the build changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second way the .npmrc broke the release pipeline. `engine-strict=true`
turns an engines mismatch from a warning into a hard failure, and the Sign Win
Executable job installs resedit@3.0.2 (engines: node >=20) on a Node 14 runner:
npm ERR! code ENOTSUP
npm ERR! notsup Unsupported engine for resedit@3.0.2: wanted: {"node":">=20"}
(current: {"node":"14.21.3","npm":"6.14.18"})
That is what failed run 27766959536 (v1.32.1) — the one release in the window
where Build Executables passed and signing did not.
Scoped to the one step, via step-level env rather than a shell prefix because
that job runs on windows-2022 under PowerShell. Bumping the job's Node instead
would swap npm 6 for npm 10 in a job that runs `npm install` at the monorepo
root, changing workspace, lockfile and peer-resolution behaviour in a
release-only job that no PR can exercise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewPR: #2396 • Head: SummaryAdds a root Review Table
Findings
Verdict: PASS |
With ignore-scripts=true in .npmrc, `npm install` at the repo root exits 0 without running @percy/core's post-install, so a contributor who uses npm out of habit ends up with no Chromium and no indication why Percy later fails to launch a browser. Yarn was already the documented path and does not read the directive; this says so explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| @@ -0,0 +1,7 @@ | |||
| ignore-scripts=true | |||
There was a problem hiding this comment.
we were going to keep it false right?
There was a problem hiding this comment.
Yes but got a method to keep it active with current scenario. Else it will be raised again by security
What
Re-lands the supply-chain hardened root
.npmrcrequired by the Enigma npmrc audit (PER-9672) — this time with the executable build made immune to it.min-release-ageis not in the audit's list — it is required by the org's blocking Semgrep rulepackage_managers.npm.npm-missing-minimum-release-age, which fires on any committed.npmrcwithout it (a freshly published, possibly compromised version would otherwise be resolvable the moment it lands). npm honours it from v11.10; npm 6 and Yarn 1 parse and ignore it, verified below.access=restrictedis intentionally not included — the audit scopes it to private repos, and these packages publish publicly.Why the last attempt broke the release pipeline
It broke it in two independent places — the CJS build and the Windows signing job. Only the first was ever diagnosed at the time, which is why the file was reverted rather than fixed.
1.
ignore-scripts=truekilled the CJS buildPER-8367 (#2266) added the same file on 4 Jun. It was reverted on 18 Jun (#2303) after the executable build failed for three releases.
Root cause, reproduced locally on Node 14.18.3 / npm 6.14.15 (the toolchain
scripts/executable.shpins):ignore-scriptsto an explicitnpm run <script>, not just install-time lifecycle scripts.set -enever trips.npm run build_cjstherefore produced nothing, and the build died two lines later on an unrelated-looking error:That is the exact log line from the first failed release run (27254109081, v1.32.0-beta.8), and Build Executables failed on every run between the
.npmrcmerge and its revert:2.
engine-strict=truekilled the Windows signing jobSign Win Executableinstallsresediton a Node 14 runner.resedit@3.0.2declaresengines: {node: ">=20"}— normally a warning, butengine-strictmakes it fatal:That is run 27766959536 (v1.32.1) — the one release in the window where
Build Executablespassed (theignore-scripts=falsepatch had landed) andSign Win Executablefailed instead. Per-job breakdown of the window:cp: ./build/*)Install resedit, ENOTSUP)No PR can catch this one: the signing job only runs on
release: published, so it was verified locally instead (below).The fix
scripts/executable.shopts the one first-party build step out explicitly:The env var overrides project
.npmrcin every npm version, so it is not tied to npm 6 behaviour.And the signing job opts out of the engines check for its one install:
Step-level
env:rather than aVAR=valueprefix because that job runs onwindows-2022under PowerShell, where the prefix form is a syntax error. Bumping the job's Node to satisfy>=20was the alternative and was rejected: it swaps npm 6 for npm 10 in a job that runsnpm installat the monorepo root, which changes workspace, lockfile and peer-resolution behaviour in a release-only job no PR can exercise. Running resedit on Node 14 is the status quo and is proven output-identical below.Plus a guard, because the CJS failure mode was silent:
npm runexits 0 even when it skips the script, so the script now asserts the transpile actually emitted something instead of failing later on a confusingcperror.Verification
Run on Node 14.18.3 / npm 6.14.15, in this repo, with this
.npmrcin place:npm run build_cjs(old command — regression repro)build/never creatednpm_config_ignore_scripts=false npm run build_cjs(new command)Successfully compiled 767 files with Babel,build/with 18 package dirs / 767.jsfilesbuild/populatedbuild/missing or empty::error::yarn config get ignore-scriptsfalseyarn installwithignore-scripts=truein.npmrc(isolated fixture)postinstallstill runbash -n scripts/executable.shmin-release-age=7on npm 6.14.15npm config get→7,npm runandnpm installunaffectedmin-release-age=7on npm 10.8.2 / Yarn 1.22.22Windows signing job, simulated locally
The job can't run on a PR, so it was reproduced step for step on Node 14.18.3 / npm 6.14.15 in a full clone of this PR's head, against the real
percy.exeartifact from the v1.32.8-beta.0 release build:npm install reseditat PR head (no fix)npm install reseditwithnpm_config_engine_strict=falsereseditimports on Node 14node ./scripts/win-metadata-update.json the realpercy.exePE32+ executable (console) x86-6499a92410…) — the engines mismatch is declaration-onlyThe last check is the one that matters: resedit's
>=20declaration is not a real incompatibility for this script, so overriding the check restores exactly today's behaviour rather than papering over a genuine break.Rest of the release path
npm install -g pkg(macOS build, Node 14)pkg@5.8.1declares noengines; full tree re-verified today underengine-strict— 125 packages, no ENOTSUPlerna publish from-package(release.yml)prepack/prepublishOnly/prepare/install— nothing forignore-scriptsto suppressversion-bump.yml,windows.yml,lint,typecheck,testdraft-release.ymlThe last two rows of the table above matter for the normal dev/CI path: Yarn 1 does not read this directive, so
yarn install→@percy/corepostinstall→ Chromium download is unchanged, andyarn build(lerna + nx) never went throughnpm runin the first place.Blast radius of the other directives, checked against every npm call in the repo (
npm install -g pkg,npm install resedit,npx percy --version,lerna publish from-package):prepare/prepack/prepublishOnly/preinstall/install, soignore-scriptshas nothing else to suppress.@percy/core'spostinstallis the only lifecycle script, and it runs via Yarn.npm install -g pkgalready ran fine under this exact directive set in June (added 125 packagesin the failed run's log, which then failed atcp).engine-strict=trueis satisfied — every package declaresnode >= 14.save-exact/audit-level/strict-sslaffect no CI path.@percy/cliare unaffected.CI on this PR includes Verify Executable, the per-PR gate added in #2307 after the last breakage — it builds and smoke-tests the binaries exactly as the release pipeline does, so this lands proven rather than discovered at release time.
Note
Supersedes #2358, which adds
.npmrc.sampleonly. The sample satisfies the audit but hardens nothing, since npm never reads it. Suggest closing that one in favour of this.Jira: PER-9672
🤖 Generated with Claude Code