fix(security): verify npm registry signature before spawning a package-manager binary#12292
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
📝 WalkthroughWalkthroughThis PR implements npm registry signature verification for pnpm and pacquet binaries before execution. It adds npm signing key infrastructure, embedded key data, installed-package verification APIs, and integrates verification into pnpm self-update and pacquet delegation flows. Changesnpm Registry Signature Verification for Package Managers
Sequence Diagram(s)sequenceDiagram
participant KeySync as update-npm-signing-keys.mjs
participant NpmKeys as npm /-/npm/v1/keys
participant EmbeddedKeys as npmSigningKeys.ts
participant InstallPnpm as installPnpmToStore/installPnpmToGlobalDir
participant VerifyEngine as verifyPnpmEngineIdentity
participant VerifyInstalled as verifyInstalledPackageSignatures
participant Registry as npm registry
KeySync->>NpmKeys: fetch advertised signing keys
KeySync->>EmbeddedKeys: merge and refresh NPM_SIGNING_KEYS
InstallPnpm->>VerifyEngine: verify engine packages
VerifyEngine->>VerifyInstalled: verify installed signatures
VerifyInstalled->>Registry: fetch packuments for pnpm/@pnpm/exe/@pnpm/platform
Registry-->>VerifyInstalled: dist.integrity and dist.signatures
VerifyInstalled-->>VerifyEngine: categorized failures or verified: true
VerifyEngine-->>InstallPnpm: continue or throw error
sequenceDiagram
participant InstallDeps as installDeps
participant VerifyPacquet as verifyPacquetIdentity
participant Collect as collectPacquetPackagesToVerify
participant VerifyInstalled as verifyInstalledPackageSignatures
participant Registry as npm registry
InstallDeps->>VerifyPacquet: verify pacquet before delegation
VerifyPacquet->>Collect: collect pacquet shim and platform binary
VerifyPacquet->>VerifyInstalled: verify installed signatures
VerifyInstalled->>Registry: fetch packuments
Registry-->>VerifyInstalled: packument data
VerifyInstalled-->>VerifyPacquet: verification result
VerifyPacquet-->>InstallDeps: return true/false or throw
InstallDeps->>InstallDeps: enable/disable pacquet delegation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…e-manager binary A repository can make pnpm download and execute a native binary through two repository-controlled inputs, neither of which was authenticated: - `configDependencies` naming `pacquet`/`@pnpm/pacquet`, which opts in to the pacquet install engine and spawns `@pacquet/<platform>-<arch>`. - the `packageManager` / `devEngines.packageManager` field, which makes pnpm download and run a specific pnpm version (on by default; also `self-update` and `pnpm with`). In both cases the repository also controls the lockfile integrity and the registry the bytes come from, so integrity-matching alone proves nothing. pnpm now verifies the npm registry signature (`dist.signatures` against the registry's ECDSA-P256 signing keys) of the bytes it is about to spawn, over the *installed* integrity, against the canonical `registry.npmjs.org`. Substituted or tampered bytes fail verification. - New `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures`. - pacquet: verify the shim and the host platform binary; on failure fall back to pnpm's own install engine. - pnpm engine: verify `pnpm`, `@pnpm/exe`, and the host platform binary, only on a store cache miss (no per-command network cost); throw on tamper, warn and proceed when the trust root is merely unreachable (offline / mirror). The trust root is overridable for npm mirrors via `PNPM_ENGINE_IDENTITY_REGISTRY`.
Instead of fetching signing keys from a hardcoded canonical registry (and
overriding it via PNPM_ENGINE_IDENTITY_REGISTRY for mirrors/tests), embed npm's
public signing keys in the pnpm CLI, like corepack does. The registry-served
`dist.signatures` are verified against the embedded keys, so:
- An npm mirror works transparently: it proxies the same signed packument, with
no override needed.
- A repository pointing the registry at a server it controls cannot supply its
own keypair to forge a signature.
The signed packument is fetched from the configured registry. Signature
verification can be disabled, or the trusted keys overridden, with the
PNPM_NPM_SIGNING_KEYS environment variable (`0` disables; a `{"keys":[...]}`
document overrides) — a process-level setting, not project config.
The embedded keys live in a generated file. A script keeps them in sync with
npm's `-/npm/v1/keys` endpoint, and the create-release-pr workflow runs it as a
gate so a key rotation cannot silently break verification:
node deps/security/signatures/scripts/update-npm-signing-keys.mjs [--update]
632b72c to
82ce79c
Compare
Address review feedback: - Remove the `PNPM_NPM_SIGNING_KEYS` environment variable. It was an unnecessary "turn off signature verification" footgun: with the keys embedded in the CLI, npm mirrors already work without any override (they proxy the signed packument), and the keys are kept fresh by the release-time check. The embedded keys are now the only trust root; tests inject keys through a code parameter rather than the environment. - Fail closed. The pnpm-engine version switch now throws when verification cannot be completed (e.g. the registry is unreachable) instead of proceeding on the project-controlled lockfile integrity. Likewise pacquet now refuses to run (failing the command) when its signature does not verify or cannot be checked, rather than silently falling back to pnpm's own engine — the only graceful fallback left is when pacquet has no binary for the current platform.
Code Review by Qodo
1.
|
PR Summary by Qodofix(security): verify npm registry signature before spawning a package-manager binary WalkthroughsDescription• Adds verifyInstalledPackageSignatures() to @pnpm/deps.security.signatures, verifying name@version:integrity against dist.signatures using npm's ECDSA-P256 public keys embedded in the CLI (corepack-style), so a project-controlled registry cannot supply its own keypair. • Blocks pacquet engine execution (verifyPacquetIdentity) unless the installed pacquet shim and host platform binary carry a valid npm registry signature; fails closed on tamper or unreachable registry. • Blocks pnpm version-switch / self-update (verifyPnpmEngineIdentity) unless pnpm, @pnpm/exe, and the host platform binary are registry-signed; check runs only on a store cache miss to avoid per-command network cost. • Embeds npm's current ECDSA signing keys in a generated npmSigningKeys.ts; a create-release-pr CI gate and check:npm-signing-keys script fail the release if the embedded keys drift from npm's live endpoint. Diagramgraph TD
A["pnpm install / self-update"] --> B["installDeps\n(installing/commands)"]
A --> C["switchCliVersion\n(pnpm/src)"]
B --> D["verifyPacquetIdentity\n(installing/commands)"]
C --> E["installPnpmToStore\n(engine/pm/commands)"]
E --> F["verifyPnpmEngineIdentity\n(engine/pm/commands)"]
D --> G["verifyInstalledPackageSignatures\n(deps.security.signatures)"]
F --> G
G --> H[("npmSigningKeys.ts\nEmbedded ECDSA Keys")]
G --> I["npm Registry\n(packument fetch)"]
J["update-npm-signing-keys.mjs"] -->|"CI gate / --update"| H
subgraph Legend
direction LR
_proc["Process / Module"] ~~~ _db[("Embedded Data")] ~~~ _ext["External Service"]
end
High-Level AssessmentThe following are alternative approaches to this PR: 1. Fetch signing keys from registry at runtime
2. Sigstore / TUF-based verification
Recommendation: The embedded-keys approach (corepack-style) is the right choice here. The main alternative — fetching signing keys from the registry at verification time — was the approach in an earlier commit but was correctly replaced: it allows a project-controlled registry to supply its own keypair, defeating the trust model entirely. A third option (Sigstore/TUF-based verification) would be more robust against key compromise but requires significant infrastructure and is not yet standard for npm packages. The current approach is the pragmatic, proven solution. File ChangesEnhancement (2)
Bug fix (5)
Tests (3)
Documentation (1)
Other (9)
|
Integrated-Benchmark Report (Linux)Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD. Scenario: Isolated linker: fresh restore, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 9.92959932068,
"stddev": 0.18990523651653632,
"median": 9.95937050358,
"user": 3.38135644,
"system": 2.11382872,
"min": 9.70487612258,
"max": 10.29894270058,
"times": [
10.29894270058,
9.92538397958,
10.06806966458,
10.00853838658,
9.99335702758,
9.76465056358,
9.70487612258,
9.732149292579999,
9.758189998579999,
10.041835470579999
]
},
{
"command": "pacquet@main",
"mean": 9.873392527979998,
"stddev": 0.18595622440038376,
"median": 9.82711260908,
"user": 3.3712210399999996,
"system": 2.0907908199999996,
"min": 9.704479320579999,
"max": 10.22428693258,
"times": [
9.909769214579999,
10.149182271579999,
9.90422907058,
9.910080431579999,
9.704479320579999,
9.74709800158,
10.22428693258,
9.74999614758,
9.713179221579999,
9.72162466758
]
},
{
"command": "pnpr@HEAD",
"mean": 5.28028532078,
"stddev": 0.17911275746059885,
"median": 5.19118323708,
"user": 2.7649089399999993,
"system": 1.8796187199999999,
"min": 5.14843402058,
"max": 5.658005988579999,
"times": [
5.17243861358,
5.32461452958,
5.15839631158,
5.14843402058,
5.20992786058,
5.1644459955799995,
5.25226856458,
5.17108496058,
5.54323636258,
5.658005988579999
]
},
{
"command": "pnpr@main",
"mean": 5.21779199278,
"stddev": 0.1347961206788497,
"median": 5.150420663079999,
"user": 2.7623341399999997,
"system": 1.86775852,
"min": 5.108465218579999,
"max": 5.51086433158,
"times": [
5.374342963579999,
5.26317115858,
5.11899315258,
5.51086433158,
5.142425945579999,
5.126756289579999,
5.158415380579999,
5.11653953858,
5.108465218579999,
5.25794594858
]
}
]
}Scenario: Isolated linker: fresh restore, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.47391842084000013,
"stddev": 0.005758313353975136,
"median": 0.4715501457400001,
"user": 0.37149286,
"system": 0.7686322599999998,
"min": 0.46711804474000007,
"max": 0.4873024697400001,
"times": [
0.4712330457400001,
0.4873024697400001,
0.47064454574000003,
0.47088877374000004,
0.4764381737400001,
0.47911396974000003,
0.47352933074000003,
0.47186724574000005,
0.47104860874000004,
0.46711804474000007
]
},
{
"command": "pacquet@main",
"mean": 0.4852709936400001,
"stddev": 0.015361002484673753,
"median": 0.4835085142400001,
"user": 0.37480856,
"system": 0.7744741599999999,
"min": 0.46712452874000004,
"max": 0.51978290374,
"times": [
0.49042462374000007,
0.46712452874000004,
0.49266550974000006,
0.48811140574000006,
0.47344092674000005,
0.47515440974000006,
0.49416210474000005,
0.4729379007400001,
0.47890562274000004,
0.51978290374
]
},
{
"command": "pnpr@HEAD",
"mean": 0.57319868424,
"stddev": 0.009372839249852931,
"median": 0.57190899874,
"user": 0.38727796000000003,
"system": 0.7732513599999999,
"min": 0.5582021767400001,
"max": 0.58900590274,
"times": [
0.56677189374,
0.58647183074,
0.56749039174,
0.57743961874,
0.56986364274,
0.58900590274,
0.5752423237400001,
0.56754470674,
0.57395435474,
0.5582021767400001
]
},
{
"command": "pnpr@main",
"mean": 0.58663080814,
"stddev": 0.0202723251476856,
"median": 0.58321171074,
"user": 0.39566436000000005,
"system": 0.7872450599999998,
"min": 0.56703866474,
"max": 0.63848802374,
"times": [
0.5972864907400001,
0.58318206174,
0.63848802374,
0.58952175874,
0.58324135974,
0.57322786274,
0.58518428774,
0.57260892674,
0.57652864474,
0.56703866474
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 9.129464426139998,
"stddev": 0.05123755892440537,
"median": 9.12824994224,
"user": 3.9557925399999996,
"system": 2.14501798,
"min": 9.06384741924,
"max": 9.243274571239999,
"times": [
9.09212635224,
9.13460436424,
9.243274571239999,
9.14087648124,
9.06384741924,
9.121895520239999,
9.08169846224,
9.10327010524,
9.14370286024,
9.169348125239999
]
},
{
"command": "pacquet@main",
"mean": 9.131696329239999,
"stddev": 0.0474971547794126,
"median": 9.12323046074,
"user": 3.9196120399999996,
"system": 2.1439975799999997,
"min": 9.072265205239999,
"max": 9.21925442024,
"times": [
9.21925442024,
9.10341977824,
9.15102279224,
9.072265205239999,
9.09135338524,
9.11104466824,
9.135416253239999,
9.16619974324,
9.18180209724,
9.08518494924
]
},
{
"command": "pnpr@HEAD",
"mean": 5.010799501139999,
"stddev": 0.19580720947465932,
"median": 4.93484761274,
"user": 2.58507074,
"system": 1.8000431799999999,
"min": 4.85916625524,
"max": 5.39563218024,
"times": [
4.93129255524,
4.959429388239999,
4.8927316872399995,
4.887196448239999,
4.9384026702399995,
4.85916625524,
4.9762068812399995,
4.91146203524,
5.356474910239999,
5.39563218024
]
},
{
"command": "pnpr@main",
"mean": 4.96035271774,
"stddev": 0.0866040551235765,
"median": 4.92381444674,
"user": 2.5678590399999996,
"system": 1.8214850799999998,
"min": 4.89311147624,
"max": 5.12467221924,
"times": [
4.916652281239999,
5.11695050924,
4.91169496424,
4.89991708124,
4.95583362724,
4.941008934239999,
4.930976612239999,
4.9127094722399995,
5.12467221924,
4.89311147624
]
}
]
}Scenario: Isolated linker: fresh install, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 1.22051170824,
"stddev": 0.02310221643264936,
"median": 1.21727642424,
"user": 1.55672042,
"system": 1.07769016,
"min": 1.19590528974,
"max": 1.27794829374,
"times": [
1.22340151074,
1.21398615274,
1.22518465074,
1.2023696017399998,
1.20502873874,
1.2317054427399998,
1.2205666957399999,
1.27794829374,
1.20902070574,
1.19590528974
]
},
{
"command": "pacquet@main",
"mean": 1.2279693324399998,
"stddev": 0.03989235185985859,
"median": 1.21674361174,
"user": 1.5741168199999998,
"system": 1.0791248599999999,
"min": 1.1896383637399999,
"max": 1.33277101774,
"times": [
1.2423331257399999,
1.1896383637399999,
1.33277101774,
1.20994458274,
1.23782370574,
1.20551669174,
1.20708458974,
1.2210940237399999,
1.2177499327399999,
1.21573729074
]
},
{
"command": "pnpr@HEAD",
"mean": 0.5299981225400001,
"stddev": 0.03459156221632053,
"median": 0.51556496974,
"user": 0.34380921999999997,
"system": 0.7463500599999999,
"min": 0.5036270397400001,
"max": 0.6188850957400001,
"times": [
0.54842678174,
0.6188850957400001,
0.50897606874,
0.51561075174,
0.5036270397400001,
0.5135617947400001,
0.51551918774,
0.50457769374,
0.53543134374,
0.53536546774
]
},
{
"command": "pnpr@main",
"mean": 0.5100178482400001,
"stddev": 0.013254369676822815,
"median": 0.5106324257400001,
"user": 0.34189901999999994,
"system": 0.7532418599999999,
"min": 0.48765897774,
"max": 0.53452876774,
"times": [
0.53452876774,
0.5176742257400001,
0.51307746874,
0.5049862377400001,
0.5166935567400001,
0.5183620267400001,
0.48765897774,
0.49457959074,
0.5044302477400001,
0.5081873827400001
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 4.89051385452,
"stddev": 0.017439031893901443,
"median": 4.88900769052,
"user": 1.8260538399999995,
"system": 1.2321849999999999,
"min": 4.86475046152,
"max": 4.919281048519999,
"times": [
4.887572609519999,
4.890442771519999,
4.90332402352,
4.875577208519999,
4.86979055252,
4.90821054852,
4.919281048519999,
4.89994905652,
4.86475046152,
4.88624026452
]
},
{
"command": "pacquet@main",
"mean": 4.90573540392,
"stddev": 0.03422020143296685,
"median": 4.90168160402,
"user": 1.83759044,
"system": 1.2325152999999998,
"min": 4.855040722519999,
"max": 4.962910250519999,
"times": [
4.91028201352,
4.890575858519999,
4.91120912552,
4.89308119452,
4.95257203852,
4.962910250519999,
4.926941407519999,
4.855040722519999,
4.87546866852,
4.87927275952
]
},
{
"command": "pnpr@HEAD",
"mean": 0.52707806072,
"stddev": 0.021205099710090856,
"median": 0.5230801730200001,
"user": 0.33706824,
"system": 0.7716267999999998,
"min": 0.4961401435200001,
"max": 0.5665933415200001,
"times": [
0.5665933415200001,
0.5178705615200001,
0.5375462415200001,
0.51971015852,
0.5093448795200001,
0.5264501875200001,
0.4961401435200001,
0.5531418785200001,
0.51118140952,
0.5328018055200001
]
},
{
"command": "pnpr@main",
"mean": 0.52292222462,
"stddev": 0.01734719631268414,
"median": 0.5221662165200001,
"user": 0.33778734000000005,
"system": 0.7562089999999999,
"min": 0.5013084855200001,
"max": 0.56226413452,
"times": [
0.56226413452,
0.5114435245200001,
0.5013084855200001,
0.52343346152,
0.5235011005200001,
0.5392247625200001,
0.5208989715200001,
0.5241850105200001,
0.51520862152,
0.5077541735200001
]
}
]
} |
|
| Branch | pr/12292 |
| Testbed | pacquet |
🚨 1 Alert
| Benchmark | Measure Units | View | Benchmark Result (Result Δ%) | Upper Boundary (Limit %) |
|---|---|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | Latency seconds (s) | 📈 plot 🚷 threshold 🚨 alert (🔔) | 9.13 s(+27.52%)Baseline: 7.16 s | 8.59 s (106.27%) |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result milliseconds (ms) (Result Δ%) | Upper Boundary milliseconds (ms) (Limit %) |
|---|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot 🚷 view threshold 🚨 view alert (🔔) | 9,129.46 ms(+27.52%)Baseline: 7,159.34 ms | 8,591.21 ms (106.27%) |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot 🚷 view threshold | 4,890.51 ms(-2.54%)Baseline: 5,018.19 ms | 6,021.83 ms (81.21%) |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 1,220.51 ms(-13.42%)Baseline: 1,409.71 ms | 1,691.65 ms (72.15%) |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 9,929.60 ms(+8.58%)Baseline: 9,145.30 ms | 10,974.36 ms (90.48%) |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 473.92 ms(-27.20%)Baseline: 651.03 ms | 781.23 ms (60.66%) |
verifyPackageSignatures() failed on the first unknown, expired, or invalid signature instead of accepting any signature that validates against a trusted key. That breaks key-rotation / multi-signature packuments and lets a mirror force a verification failure by appending a junk signature. Now a package is accepted as soon as one trusted signature validates; it fails only when none do.
|
Code review by qodo was updated up to the latest commit 30667be |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@deps/security/signatures/src/verifySignatures.ts`:
- Around line 262-277: The expiry check currently trusts publishedTime (from
packument.time[pkg.version]) which is untrusted; update the verification in the
loop over pkg.signatures so that when key.expires is present you must not
consider an unverified/packument-derived publishedTime as valid — either require
a cryptographically verified timestamp/provenance before using publishedTime or
treat missing/unverified publishedTime as failing the expiry check (i.e., treat
the signature as expired). Modify the logic around publishedTime and key.expires
in the signature verification loop (references: pkg.signatures, keys.find,
publishedTime, key.expires) to fail-closed for unverified timestamps until a
proper authenticated timestamp mechanism is implemented.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7fee4316-5d09-448d-801b-3a2334d14880
📒 Files selected for processing (2)
deps/security/signatures/src/verifySignatures.tsdeps/security/signatures/test/verifySignatures.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Analyze (javascript)
- GitHub Check: Compile & Lint
- GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate
Files:
deps/security/signatures/test/verifySignatures.tsdeps/security/signatures/src/verifySignatures.ts
🧠 Learnings (3)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.
Applied to files:
deps/security/signatures/test/verifySignatures.tsdeps/security/signatures/src/verifySignatures.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.
Applied to files:
deps/security/signatures/test/verifySignatures.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.
Applied to files:
deps/security/signatures/test/verifySignatures.tsdeps/security/signatures/src/verifySignatures.ts
🔇 Additional comments (1)
deps/security/signatures/test/verifySignatures.ts (1)
139-155: LGTM!Also applies to: 293-308
The expiry comparison is a consistency check, not a security boundary: the publish time comes from the same unauthenticated packument as the signatures, so failing closed on a missing timestamp would not stop a forger (who could backdate it) while rejecting expired keys outright would break every npm package published before the 2025-01-29 key rotation. Matches the trade-off npm's pacote makes.
|
Code review by qodo was updated up to the latest commit 5fd3e39 |
installPnpmToStore() fetches the packument to verify the pnpm engine's registry signature on a store cache miss, so the with command must pass the proxy/TLS/auth settings the same way switchCliVersion does.
|
Code review by qodo was updated up to the latest commit 91d72bb |
…etadata collectEnginePackagesToVerify() silently skipped lockfile entries without resolution.integrity, so a crafted env lockfile could exempt a component (including the @pnpm/exe platform binary actually executed) from signature verification while the remaining components verified. pnpm can install a tarball without integrity, so a missing integrity now throws PNPM_ENGINE_IDENTITY_UNVERIFIABLE instead of narrowing the verified set.
91d72bb to
50ac1f3
Compare
|
Code review by qodo was updated up to the latest commit 50ac1f3 |
Follow-up to #12292 (which verifies the **package-manager** binary). This closes the same class of gap for the **Node.js runtime**. When a repository requests a Node.js runtime — `devEngines.runtime: node@X` (with `onFail: download`, the default) or `useNodeVersion` — pnpm downloads and then executes a Node binary (it's used to run lifecycle / `run` / `exec` scripts). The download **mirror is repository-configurable** via `node-mirror:<channel>` (`nodeDownloadMirrors`) in project `.npmrc`, and the integrity comes from `SHASUMS256.txt` fetched **from that same mirror**. That's a circular check: a malicious mirror serves a tampered `node` tarball **and** a matching `SHASUMS256.txt`, the sha256 check passes, and pnpm runs the binary. Drive-by on a normal command in a cloned repo. ## Fix pnpm now fetches `SHASUMS256.txt.sig` and verifies its **detached OpenPGP signature** against the **Node.js release team's public keys, embedded in the pnpm CLI**, before trusting the hashes. A mirror that serves a tampered binary cannot also produce a valid signature, so verification fails. Any faithful mirror (one that proxies the real signed SHASUMS) keeps working. - `@pnpm/crypto.shasums-file`: new `fetchVerifiedNodeShasums` / `fetchVerifiedNodeShasumsFile` verify the signature via `openpgp` against the embedded keys. - The keys live in a generated file (`src/nodeReleaseKeys.ts`, 28 keys) mirrored from the canonical `nodejs/release-keys` list. `crypto/shasums-file/scripts/update-node-release-keys.mjs` keeps them current (`pnpm check:node-release-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate so a new release signer can't silently break verification. - `@pnpm/engine.runtime.node-resolver` verifies the **configurable-mirror** SHASUMS. The hardcoded `unofficial-builds.nodejs.org` musl mirror is **not** repo-configurable and is signed by a different key, so it stays trusted over TLS. ## Scope - **Pre-release channels (rc, nightly, …) are not verified** — Node only signs the `release` channel (no `SHASUMS256.txt.sig` exists for them, even on nodejs.org), so they remain unverifiable. Verification is gated on the `release` channel. - **Bun / Deno are unaffected** — their download/SHASUMS URLs are hardcoded to canonical GitHub (`github.com/oven-sh/bun`, `api.github.com/repos/denoland/deno`), not mirror-configurable, so a repo can't redirect them. - **Pacquet parity:** `pacquet/crates/engine-runtime-node-resolver` has the same mirror-configurable SHASUMS logic and needs the equivalent Rust port — tracked as a follow-up (per the repo's parity rule, opening the TS side first).
* fix(package-bins): reject reserved manifest bin names Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.." passed the bin-name guard because encodeURIComponent leaves them unchanged. When joined to the global bin directory during global remove/update/add operations, "." resolves to the bin directory itself and ".." to its parent, which removeBin then recursively deletes. Reject empty, ".", and ".." bin names after scope stripping. Backport of #12289 to v10. * fix: block untrusted request destination env expansion Makes environment expansion trust-aware for registry/auth config and request destinations: - Stops project and workspace .npmrc files from expanding ${...} placeholders in registry/proxy request destinations, URL-scoped keys, and registry credential values. - Stops repository-controlled pnpm-workspace.yaml from expanding ${...} placeholders in the registry setting. - Preserves env expansion for trusted user/global/CLI/env config so existing token and registry setup flows continue to work. Backport of #12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10. * fix(security): verify npm registry signature before spawning a package-manager binary The packageManager field (and pnpm self-update) makes pnpm download and run a specific pnpm version. The staged install's bytes were trusted based on lockfile integrity alone, which proves nothing when the inputs are repository-controlled. pnpm now verifies the npm registry signature of the engine it is about to spawn, over the installed integrity, against npm's public signing keys embedded in the pnpm CLI (exactly as corepack does): - verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized platform binaries of the staged install before it is linked into the tools directory. - Fails closed: any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary. Runs only on a tools-directory cache miss (an actual download). - The embedded keys live in a generated file kept in sync with npm's keys endpoint by scripts/update-npm-signing-keys.mjs; the release workflow runs the check as a gate so a key rotation cannot silently break verification. Backport of #12292 (CAND-PNPM-097) to v10. * fix: harden package-manager bootstrap metadata Resolve package-manager bootstrap traffic through trusted user/CLI registries and trusted network config, defaulting to the public npm registry instead of project/workspace registry settings: - getConfig() now computes packageManagerRegistries and packageManagerNetworkConfig from trusted config sources only (CLI options, env config, user and global .npmrc) — never the repository's project/workspace .npmrc or pnpm-workspace.yaml. - switchCliVersion() applies that bootstrap config when installing and verifying the wanted pnpm version, so repository .npmrc proxy/TLS/registry values cannot steer package-manager bootstrap traffic. Backport of #12296 to v10. The v11 env-lockfile validation parts do not apply: v10 bootstraps the wanted version through a staged child install instead of an env lockfile. * fix(security): verify Node.js runtime SHASUMS OpenPGP signature When a repository requests a Node.js runtime (useNodeVersion or an execution env), pnpm downloads and then executes a Node binary. The download mirror is repository-configurable via node-mirror:<channel> in project .npmrc, and the integrity came from SHASUMS256.txt fetched from that same mirror — a circular check a malicious mirror can satisfy with a tampered binary and matching hashes. pnpm now fetches SHASUMS256.txt.sig and verifies its detached OpenPGP signature against the Node.js release team's public keys, embedded in the pnpm CLI, before trusting the hashes: - @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums / fetchVerifiedNodeShasumsFile verify the signature via openpgp against the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from the canonical nodejs/release-keys list). - @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the release channel; pre-release channels (rc, nightly, ...) are unsigned by Node and remain unverified. - scripts/update-node-release-keys.mjs keeps the keys current (pnpm run check:node-release-keys / update:node-release-keys), and the release workflow runs the check as a gate. Backport of #12295 to v10 (without the pacquet Rust port, which does not exist on this branch). * test(env): sign the SHASUMS fixture for Node.js download tests The Node.js download tests exercise the release channel, whose SHASUMS256.txt is now signature-verified. Sign the fixture with a generated OpenPGP key and trust it through the new trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via @pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep exercising the verification path instead of bypassing it. * fix(self-updater): redact registry credentials from engine identity errors Registry URLs may legally embed basic-auth credentials (https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the packument URL and registry URL into PnpmError messages, and the unreachable-registry path surfaced fetch-layer error messages that embed the request URL — all of which land in terminal output and CI logs. Strip URL credentials from every error message and truncate the non-200 response body. * fix: update vulnerable transitive dependencies Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled in via concurrently) so the audit workflow passes again. The advisory was published after the last release/10 audit run; it is unrelated to the security backports on this branch.
|
🚢 v11.5.3 |
Summary
pnpm can be made to download and execute a native binary through two repository-controlled inputs, neither of which was authenticated before this change:
pacquet(or@pnpm/pacquet) inconfigDependencies(inpnpm-workspace.yaml) opts in to pnpm's Rust install engine, and pnpm spawns the platform binary@pacquet/<platform>-<arch>duringpnpm install.packageManager/devEngines.packageManagerfield makes pnpm download and run a specific pnpm version. This is on by default (onFaildefaults todownload) and also coverspnpm self-updateandpnpm with.In both cases the repository also controls the lockfile integrity and the registry the bytes are fetched from (via
.npmrc), so matching the lockfile integrity proves nothing — it matches the hash the attacker wrote. A cloned, untrusted repository could therefore execute an arbitrary native binary just by running a normal pnpm command.Fix (corepack-style registry-signature verification)
pnpm now verifies the npm registry signature of the bytes it is about to spawn, over the installed integrity, against npm's public signing keys that ship embedded in the pnpm CLI (exactly as corepack does). If the bytes on disk were substituted or tampered with, npm's real signature does not validate over them.
verifyInstalledPackageSignatures()in@pnpm/deps.security.signaturesverifiesname@version:integrityagainstdist.signaturesusing the embedded keys.installing/commands): verifies thepacquetshim and the host platform binary. It fails the command if the signature does not verify or cannot be checked (e.g. registry unreachable); the only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.engine/pm/commands): verifiespnpm,@pnpm/exe, and the host platform binary, only on a store cache miss (an actual download), so it adds no network round trip to every command. It fails closed — any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary.Keeping the embedded keys fresh
The embedded keys live in a generated file.
deps/security/signatures/scripts/update-npm-signing-keys.mjskeeps them in sync with npm's keys endpoint (pnpm check:npm-signing-keys/--update), and the create-release-pr workflow runs the check as a gate, so a key rotation cannot silently break verification — a stale key set blocks the release until refreshed.Pacquet parity
pacquet gained
configDependenciessupport onmain(#12285), but it has no install-engine-spawn sink — pacquet is the engine, and it does not select/spawn an alternate engine fromconfigDependencies(its only config-dependency code-execution path isupdateConfigplugin pnpmfiles, which it shares with pnpm and which this advisory does not cover). So CAND-PNPM-097 has no pacquet-side analog; no pacquet code change is needed.Testing
@pnpm/deps.security.signatures: 17 (signature verification incl. tamper / untrusted-key cases;getNpmSigningKeysreturns the embedded keys).selfUpdate46 +verifyPnpmEngineIdentity6 (valid, tamper, untrusted-key, absent, unreachable-fails-closed, skip).switchingVersions12,install/selfUpdate,packageManagerCheck,withCommand,install/pacquet— all passing, verifying the real signed pnpm/pacquet packages against the embedded keys (Verdaccio preserves the upstreamdist.signatures).Written by an agent (Claude Code, claude-opus-4-8).
Summary by CodeRabbit
New Features
Chores