Skip to content

chore(security): add hardened .npmrc without breaking executables (PER-9672) - #2396

Merged
pranavz28 merged 4 commits into
masterfrom
PER-9672_npmrc-hardening
Aug 25, 2026
Merged

chore(security): add hardened .npmrc without breaking executables (PER-9672)#2396
pranavz28 merged 4 commits into
masterfrom
PER-9672_npmrc-hardening

Conversation

@pranavz28

@pranavz28 pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What

Re-lands the supply-chain hardened root .npmrc required by the Enigma npmrc audit (PER-9672) — this time with the executable build made immune to it.

ignore-scripts=true
strict-ssl=true
save-exact=true
audit-level=high
engine-strict=true
legacy-peer-deps=false
min-release-age=7

min-release-age is not in the audit's list — it is required by the org's blocking Semgrep rule package_managers.npm.npm-missing-minimum-release-age, which fires on any committed .npmrc without 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=restricted is 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=true killed the CJS build

PER-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.sh pins):

  • npm 6 applies ignore-scripts to an explicit npm run <script>, not just install-time lifecycle scripts.
  • It skips the script and still exits 0, so set -e never trips.
  • npm run build_cjs therefore produced nothing, and the build died two lines later on an unrelated-looking error:
cp: ./build/*: No such file or directory

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 .npmrc merge and its revert:

run date result
1.32.0-beta.7 2 Jun (pre-.npmrc) success
v1.32.0-beta.8 10 Jun failure
v1.32.0-beta.9 15 Jun failure
v1.32.0 17 Jun failure
v1.32.1 18 Jun failure
v1.32.2 18 Jun (post-revert) success

2. engine-strict=true killed the Windows signing job

Sign Win Executable installs resedit on a Node 14 runner. resedit@3.0.2 declares engines: {node: ">=20"} — normally a warning, but engine-strict makes it fatal:

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 run 27766959536 (v1.32.1) — the one release in the window where Build Executables passed (the ignore-scripts=false patch had landed) and Sign Win Executable failed instead. Per-job breakdown of the window:

run Build Executables Sign Win Executable
v1.32.0-beta.8 / beta.9 / v1.32.0 fail (cp: ./build/*) skipped
v1.32.1 pass fail (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.sh opts the one first-party build step out explicitly:

-npm run build_cjs
+npm_config_ignore_scripts=false npm run build_cjs

The env var overrides project .npmrc in 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:

       - name: Install resedit
+        env:
+          npm_config_engine_strict: 'false'
         run: npm install resedit

Step-level env: rather than a VAR=value prefix because that job runs on windows-2022 under PowerShell, where the prefix form is a syntax error. Bumping the job's Node to satisfy >=20 was the alternative and was rejected: it swaps npm 6 for npm 10 in a job that runs npm install at 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 run exits 0 even when it skips the script, so the script now asserts the transpile actually emitted something instead of failing later on a confusing cp error.

Verification

Run on Node 14.18.3 / npm 6.14.15, in this repo, with this .npmrc in place:

check result
npm run build_cjs (old command — regression repro) exit 0, build/ never created
npm_config_ignore_scripts=false npm run build_cjs (new command) Successfully compiled 767 files with Babel, build/ with 18 package dirs / 767 .js files
guard with build/ populated passes
guard with build/ missing or empty aborts with ::error::
yarn config get ignore-scripts false
yarn install with ignore-scripts=true in .npmrc (isolated fixture) root and dependency postinstall still run
bash -n scripts/executable.sh OK
min-release-age=7 on npm 6.14.15 npm config get7, npm run and npm install unaffected
min-release-age=7 on npm 10.8.2 / Yarn 1.22.22 install and run unaffected, no warning

Windows 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.exe artifact from the v1.32.8-beta.0 release build:

check result
npm install resedit at PR head (no fix) ENOTSUP — reproduces the v1.32.1 release failure exactly
npm install resedit with npm_config_engine_strict=false installs the full root tree, exit 0, resedit imports on Node 14
node ./scripts/win-metadata-update.js on the real percy.exe exit 0, output still PE32+ executable (console) x86-64
patched binary vs. same patch on Node 22 byte-identical (99a92410…) — the engines mismatch is declaration-only

The last check is the one that matters: resedit's >=20 declaration 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

path exposure
npm install -g pkg (macOS build, Node 14) pkg@5.8.1 declares no engines; full tree re-verified today under engine-strict — 125 packages, no ENOTSUP
lerna publish from-package (release.yml) no package declares prepack / prepublishOnly / prepare / install — nothing for ignore-scripts to suppress
version-bump.yml, windows.yml, lint, typecheck, test Yarn-driven; Yarn 1 does not read these directives
draft-release.yml no Node or npm step at all

The 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/core postinstall → Chromium download is unchanged, and yarn build (lerna + nx) never went through npm run in 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):

  • No package declares prepare / prepack / prepublishOnly / preinstall / install, so ignore-scripts has nothing else to suppress. @percy/core's postinstall is the only lifecycle script, and it runs via Yarn.
  • npm install -g pkg already ran fine under this exact directive set in June (added 125 packages in the failed run's log, which then failed at cp).
  • engine-strict=true is satisfied — every package declares node >= 14.
  • save-exact / audit-level / strict-ssl affect no CI path.
  • The file is not published in any tarball, so consumers installing @percy/cli are 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.sample only. 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

…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>
@pranavz28
pranavz28 requested a review from a team as a code owner August 24, 2026 20:58
Comment thread .npmrc Fixed
pranavz28 and others added 2 commits August 25, 2026 02:31
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>
@pranavz28

pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2396Head: 7cb9566 (reviewed at a499359; delta is docs-only) • Reviewers: stack:pr-review (manual diff analysis), code-review (built-in fallback)

Summary

Adds a root .npmrc for supply-chain hardening (PER-9672), plus two narrow opt-outs — npm_config_ignore_scripts=false for build_cjs, and step-level npm_config_engine_strict=false for the Windows resedit install — that keep the release pipeline working after the previous attempt (#2266) broke it and was reverted (#2303).

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass No secret-touching code changed
High Security Auth / input validation / IDOR / SQLi N/A Not applicable to this diff
High Correctness Logic correct, handles edge cases Pass scripts/executable.sh verified green on "Verify Executable" (Node 14) — the exact scenario that broke #2266
High Correctness Error handling explicit, nothing swallowed Pass New empty-./build guard replaces a silent failure with ::error:: + exit 1
High Correctness No race conditions N/A Sequential shell script, single scoped env var
Medium Testing New code has corresponding tests Partial build_cjs fix is CI-exercised; the Windows resedit fix has no PR-triggered job (see Findings)
Medium Testing Existing tests still pass Pass Lint, Typecheck, Test, Windows, Verify Executable all green
Medium Performance N/A Not applicable
Medium Quality Follows existing patterns; focused Pass Scoped to npmrc hardening plus the two required opt-outs
Low Quality Naming, dead code, dependencies Pass No dependency changes

Findings

.npmrc:1 — Medium — RESOLVED in 7cb9566
ignore-scripts=true applies repo-wide, so npm install at the root would exit 0 while silently skipping @percy/core's Chromium post-install. yarn was already the documented path and does not read the directive, but nothing said so. Addressed by documenting the requirement and its consequence in CONTRIBUTING.md. (A preinstall guard such as only-allow was considered and rejected — ignore-scripts=true would skip the guard itself.)

.github/workflows/executable.yml:58 — Medium — accepted, no change
The notify job runs only on release: published, so no PR-time CI proves the npm_config_engine_strict fix. Mitigated by simulating the job locally on Node 14.18.3 / npm 6.14.15 in a full clone of this head, against the real percy.exe from the v1.32.8-beta.0 build: the install fails without the fix (reproducing the v1.32.1 ENOTSUP), succeeds with it, and the patched binary is byte-identical to the same patch run on Node 22. Worth watching the next release's notify job regardless, given this is the failure class that caused the prior revert.

.npmrc:7 — Low — accepted, no change
min-release-age=7 needs npm ≥ 11.10 and is inert on the Node 14 / npm 6 toolchain; it is present to satisfy the org's blocking Semgrep rule package_managers.npm.npm-missing-minimum-release-age. If that job's Node is ever bumped, it becomes live against the unpinned npm install resedit. Worth remembering at that point.


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>
Comment thread .npmrc
@@ -0,0 +1,7 @@
ignore-scripts=true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we were going to keep it false right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but got a method to keep it active with current scenario. Else it will be raised again by security

@pranavz28
pranavz28 merged commit 43af57f into master Aug 25, 2026
48 checks passed
@pranavz28
pranavz28 deleted the PER-9672_npmrc-hardening branch August 25, 2026 09:37
@pranavz28 pranavz28 added the 🧹 maintenance General maintenance label Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🧹 maintenance General maintenance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants