@pnpm/resolving.npm-resolver 1101.4.0 → 1101.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,15 +71,18 @@ export interface CreateNpmResolutionVerifierOptions {
71
71
  now?: number;
72
72
  }
73
73
  /**
74
- * Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
75
- * and/or `trustPolicy='no-downgrade'` policies to npm-registry-resolved
76
- * lockfile entries, or `undefined` when no policy is active. Pairs with
77
- * `createNpmResolver`: each resolver factory may export a sibling
78
- * verifier factory that the default-resolver combines.
74
+ * Returns a `ResolutionVerifier` for npm-registry-resolved lockfile
75
+ * entries. It always binds each entry's recorded tarball URL to the
76
+ * artifact the registry's metadata lists (an anti-tamper check that does
77
+ * not depend on any policy), and additionally re-applies the
78
+ * `minimumReleaseAge` and/or `trustPolicy='no-downgrade'` policies when
79
+ * those are configured. Pairs with `createNpmResolver`: each resolver
80
+ * factory may export a sibling verifier factory that the default-resolver
81
+ * combines.
79
82
  *
80
83
  * Designed for fail-closed semantics: if the manifest can't be loaded or
81
84
  * the pinned version is missing from it, the verifier reports a violation
82
85
  * rather than silently passing. Mirrors the post-resolution gate bun added
83
86
  * for the same shape of bug in oven-sh/bun#30526.
84
87
  */
85
- export declare function createNpmResolutionVerifier(opts: CreateNpmResolutionVerifierOptions): ResolutionVerifier | undefined;
88
+ export declare function createNpmResolutionVerifier(opts: CreateNpmResolutionVerifierOptions): ResolutionVerifier;
@@ -5,16 +5,20 @@ import { PnpmError } from '@pnpm/error';
5
5
  import semver from 'semver';
6
6
  import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
7
7
  import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
8
+ import { normalizeRegistryUrl } from './normalizeRegistryUrl.js';
8
9
  import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js';
9
10
  import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
10
11
  import { failIfTrustDowngraded } from './trustChecks.js';
11
- import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
12
+ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TARBALL_URL_MISMATCH_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
12
13
  /**
13
- * Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
14
- * and/or `trustPolicy='no-downgrade'` policies to npm-registry-resolved
15
- * lockfile entries, or `undefined` when no policy is active. Pairs with
16
- * `createNpmResolver`: each resolver factory may export a sibling
17
- * verifier factory that the default-resolver combines.
14
+ * Returns a `ResolutionVerifier` for npm-registry-resolved lockfile
15
+ * entries. It always binds each entry's recorded tarball URL to the
16
+ * artifact the registry's metadata lists (an anti-tamper check that does
17
+ * not depend on any policy), and additionally re-applies the
18
+ * `minimumReleaseAge` and/or `trustPolicy='no-downgrade'` policies when
19
+ * those are configured. Pairs with `createNpmResolver`: each resolver
20
+ * factory may export a sibling verifier factory that the default-resolver
21
+ * combines.
18
22
  *
19
23
  * Designed for fail-closed semantics: if the manifest can't be loaded or
20
24
  * the pinned version is missing from it, the verifier reports a violation
@@ -24,10 +28,6 @@ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } f
24
28
  export function createNpmResolutionVerifier(opts) {
25
29
  const ageCheckActive = Boolean(opts.minimumReleaseAge);
26
30
  const trustCheckActive = opts.trustPolicy === 'no-downgrade';
27
- // No policy → no verifier. Skipping early keeps the install-side fan-out
28
- // empty when nothing is configured.
29
- if (!ageCheckActive && !trustCheckActive)
30
- return undefined;
31
31
  const cutoff = ageCheckActive
32
32
  ? (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
33
33
  : 0;
@@ -84,20 +84,37 @@ export function createNpmResolutionVerifier(opts) {
84
84
  const minimumReleaseAge = opts.minimumReleaseAge ?? 0;
85
85
  const trustPolicy = opts.trustPolicy;
86
86
  const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
87
- const verify = async (resolution, { name, version }) => {
87
+ const verify = async (resolution, { name, version, nonSemverVersion }) => {
88
88
  if (!isNpmRegistryResolution(resolution))
89
89
  return { ok: true };
90
- // Non-semver versions identify URL tarballs, file: refs, git refs, etc.
91
- // Neither the age nor the trust policy applies, and a registry lookup
90
+ // URL/git-keyed entries are deliberate non-registry deps. They can still
91
+ // carry a semver `version` copied from the resolved manifest, so the
92
+ // semver guard below isn't enough on its own — the registry policies and
93
+ // the tarball-URL binding don't apply to them, and a registry lookup
92
94
  // would 404.
95
+ if (nonSemverVersion != null)
96
+ return { ok: true };
93
97
  if (!semver.valid(version))
94
98
  return { ok: true };
99
+ const tarballUrl = resolution.tarball;
100
+ const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
101
+ // A registry entry that pins an explicit tarball URL must point at the
102
+ // artifact the registry's own metadata lists. Otherwise a trusted
103
+ // `name@version` could front bytes from an attacker-chosen URL (with a
104
+ // matching integrity for those bytes). This binding is unconditional —
105
+ // it does not depend on `minimumReleaseAge`/`trustPolicy` and isn't
106
+ // narrowed by their exclude lists, since it guards integrity rather
107
+ // than maturity/trust. Registry entries with no tarball URL reconstruct
108
+ // it from name+version+registry, so they're inherently bound.
109
+ if (typeof tarballUrl === 'string') {
110
+ const urlViolation = await runTarballUrlCheck(lookupContext, registry, name, version, tarballUrl);
111
+ if (urlViolation)
112
+ return urlViolation;
113
+ }
95
114
  const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version);
96
115
  const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version);
97
116
  if (!ageApplies && !trustApplies)
98
117
  return { ok: true };
99
- const tarballUrl = resolution.tarball;
100
- const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
101
118
  if (ageApplies) {
102
119
  const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true);
103
120
  if (ageViolation)
@@ -127,6 +144,12 @@ export function createNpmResolutionVerifier(opts) {
127
144
  return {
128
145
  verify,
129
146
  policy: {
147
+ // Marks runs that enforced the tarball-URL binding. A cache record
148
+ // written before this rule existed lacks the flag, so
149
+ // `canTrustPastCheck` rejects it and forces a re-verification that
150
+ // applies the binding — otherwise an upgrade could keep trusting a
151
+ // lockfile that was only ever age/trust-checked.
152
+ tarballUrlBinding: true,
130
153
  minimumReleaseAge,
131
154
  minimumReleaseAgeExclude: sortedMinAgeExcludes,
132
155
  trustPolicy: trustPolicy ?? null,
@@ -134,6 +157,10 @@ export function createNpmResolutionVerifier(opts) {
134
157
  trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
135
158
  },
136
159
  canTrustPastCheck: (cached) => {
160
+ // The tarball-URL binding is unconditional today; a cached run that
161
+ // didn't record it can't be trusted to have enforced it.
162
+ if (cached.tarballUrlBinding !== true)
163
+ return false;
137
164
  // Maturity: a previously cached run under a larger cutoff
138
165
  // (stricter window) is trustworthy under a smaller current one —
139
166
  // its set of accepted versions is a subset of today's. The
@@ -223,6 +250,44 @@ async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissi
223
250
  }
224
251
  return undefined;
225
252
  }
253
+ /**
254
+ * Confirm the lockfile-pinned tarball URL is the artifact the registry's
255
+ * own metadata lists for this exact `name@version`.
256
+ *
257
+ * Fail-closed: the entry passes only when the registry metadata
258
+ * affirmatively lists this version with a matching tarball URL. If the
259
+ * metadata can't be fetched, doesn't list the version, or omits
260
+ * `dist.tarball`, the entry can't be confirmed and is rejected — otherwise
261
+ * a tampered lockfile could smuggle a malicious URL past the check by
262
+ * pointing it at a `name@version` the registry can't vouch for.
263
+ */
264
+ async function runTarballUrlCheck(context, registry, name, version, lockfileTarball) {
265
+ const meta = await fetchAbbreviatedMeta(context, registry, name);
266
+ const registryTarball = meta?.versionTarballs?.get(version);
267
+ if (registryTarball != null && sameTarballUrl(lockfileTarball, registryTarball)) {
268
+ return undefined;
269
+ }
270
+ return {
271
+ ok: false,
272
+ code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
273
+ reason: registryTarball == null
274
+ ? "could not be verified against the registry's published metadata"
275
+ : `has a tarball URL (${lockfileTarball}) that does not match the registry's published metadata (${registryTarball})`,
276
+ };
277
+ }
278
+ function sameTarballUrl(a, b) {
279
+ return canonicalTarballUrl(a) === canonicalTarballUrl(b);
280
+ }
281
+ // Mirror the tolerance toLockfileResolution applies when it decides whether
282
+ // a tarball URL is "the expected one": ignore the protocol and `%2f` scope
283
+ // encoding so a benign http/https or encoding difference isn't read as
284
+ // tampering. The `%2f` match is case-insensitive because `normalizeRegistryUrl`
285
+ // (`new URL().toString()`) can upper-case percent-escapes to `%2F`.
286
+ function canonicalTarballUrl(url) {
287
+ const normalized = normalizeRegistryUrl(url).replace(/%2f/gi, '/');
288
+ const schemeEnd = normalized.indexOf('://');
289
+ return schemeEnd === -1 ? normalized : normalized.slice(schemeEnd + 3);
290
+ }
226
291
  /**
227
292
  * Run the resolver-time `failIfTrustDowngraded` check against the
228
293
  * pinned lockfile version. The packument is fetched through a
@@ -440,7 +505,7 @@ async function tryAbbreviatedModifiedShortcut(context, registry, name, version)
440
505
  // publish time — but only for versions the registry currently lists.
441
506
  // An unpublished or never-published pin would otherwise pass the gate
442
507
  // on a stale package-level timestamp.
443
- if (!meta?.versionNames?.has(version))
508
+ if (!meta?.versionTarballs?.has(version))
444
509
  return undefined;
445
510
  return modified;
446
511
  }
@@ -503,18 +568,26 @@ function validateSharedMeta(meta, name) {
503
568
  return undefined;
504
569
  return meta;
505
570
  }
506
- // Project the abbreviated packument down to the two fields the verifier
507
- // actually reads — package-level `modified` and the set of version names
508
- // for the existence check inside `tryAbbreviatedModifiedShortcut`. The
571
+ // Project the abbreviated packument down to the few fields the verifier
572
+ // actually reads — package-level `modified`, plus a per-version map of
573
+ // `dist.tarball` (whose keys double as the version-existence set for the
574
+ // `tryAbbreviatedModifiedShortcut` check and the tarball-URL binding). The
509
575
  // resolver populates the abbreviated mirror with every version's
510
576
  // dependency / engine / dist info, which can run to hundreds of KB per
511
577
  // package and accumulate to many GB across a multi-thousand-entry
512
578
  // lockfile (see #11860). The full document is GC-able as soon as this
513
- // closure returns.
579
+ // closure returns; only the short tarball-URL strings are retained.
514
580
  function projectAbbreviatedMeta(meta) {
581
+ let versionTarballs;
582
+ if (meta.versions) {
583
+ versionTarballs = new Map();
584
+ for (const [version, manifest] of Object.entries(meta.versions)) {
585
+ versionTarballs.set(version, manifest.dist?.tarball);
586
+ }
587
+ }
515
588
  return {
516
589
  modified: meta.modified,
517
- versionNames: meta.versions ? new Set(Object.keys(meta.versions)) : undefined,
590
+ versionTarballs,
518
591
  };
519
592
  }
520
593
  function readLocalMetaTime(context, registry, name) {
@@ -553,12 +626,14 @@ function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarball
553
626
  // origin we'd otherwise miss. Match the longest prefix so that two named
554
627
  // registries sharing a host but differing by path don't collide.
555
628
  if (tarballUrl) {
556
- const normalized = tryParseUrl(tarballUrl)?.toString();
557
- if (normalized) {
558
- for (const prefix of namedRegistryPrefixes) {
559
- if (normalized.startsWith(prefix))
560
- return prefix;
561
- }
629
+ // Match on the same canonical form the tarball comparison uses, so a
630
+ // named-registry tarball that differs from the configured base only by
631
+ // scheme or `%2f` encoding still routes to its registry instead of
632
+ // falling back (and then failing closed against the wrong packument).
633
+ const normalized = canonicalTarballUrl(tarballUrl);
634
+ for (const prefix of namedRegistryPrefixes) {
635
+ if (normalized.startsWith(canonicalTarballUrl(prefix)))
636
+ return prefix;
562
637
  }
563
638
  }
564
639
  return pickRegistryForPackage(registries, name);
@@ -608,6 +683,14 @@ function isNpmRegistryResolution(resolution) {
608
683
  // the resolver and aren't subject to release-age policy.
609
684
  if ('gitHosted' in resolution && resolution.gitHosted)
610
685
  return false;
686
+ const tarball = resolution.tarball;
687
+ if (typeof tarball === 'string') {
688
+ // Local/non-registry tarballs (for example `file:`) have no packument
689
+ // metadata, so minimumReleaseAge/trustPolicy verification cannot apply.
690
+ const protocol = tryParseUrl(tarball)?.protocol;
691
+ if (protocol != null && protocol !== 'http:' && protocol !== 'https:')
692
+ return false;
693
+ }
611
694
  return 'tarball' in resolution || 'integrity' in resolution;
612
695
  }
613
696
  //# sourceMappingURL=createNpmResolutionVerifier.js.map
@@ -10,3 +10,4 @@
10
10
  */
11
11
  export declare const MINIMUM_RELEASE_AGE_VIOLATION_CODE = "MINIMUM_RELEASE_AGE_VIOLATION";
12
12
  export declare const TRUST_DOWNGRADE_VIOLATION_CODE = "TRUST_DOWNGRADE";
13
+ export declare const TARBALL_URL_MISMATCH_VIOLATION_CODE = "TARBALL_URL_MISMATCH";
@@ -10,4 +10,5 @@
10
10
  */
11
11
  export const MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION';
12
12
  export const TRUST_DOWNGRADE_VIOLATION_CODE = 'TRUST_DOWNGRADE';
13
+ export const TARBALL_URL_MISMATCH_VIOLATION_CODE = 'TARBALL_URL_MISMATCH';
13
14
  //# sourceMappingURL=violationCodes.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.npm-resolver",
3
- "version": "1101.4.0",
3
+ "version": "1101.5.1",
4
4
  "description": "Resolver for npm-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -10,7 +10,10 @@
10
10
  ],
11
11
  "license": "MIT",
12
12
  "funding": "https://opencollective.com/pnpm",
13
- "repository": "https://github.com/pnpm/pnpm/tree/main/resolving/npm-resolver",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/pnpm/pnpm/tree/main/resolving/npm-resolver"
16
+ },
14
17
  "homepage": "https://github.com/pnpm/pnpm/tree/main/resolving/npm-resolver#readme",
15
18
  "bugs": {
16
19
  "url": "https://github.com/pnpm/pnpm/issues"
@@ -40,27 +43,27 @@
40
43
  "semver-utils": "^1.1.4",
41
44
  "ssri": "13.0.1",
42
45
  "version-selector-type": "^3.0.0",
43
- "@pnpm/config.pick-registry-for-package": "1100.0.6",
46
+ "@pnpm/config.pick-registry-for-package": "1100.0.8",
44
47
  "@pnpm/constants": "1100.0.0",
45
- "@pnpm/error": "1100.0.0",
48
+ "@pnpm/core-loggers": "1100.1.4",
46
49
  "@pnpm/crypto.hash": "1100.0.1",
47
- "@pnpm/fetching.types": "1100.0.1",
50
+ "@pnpm/config.version-policy": "1100.1.4",
51
+ "@pnpm/error": "1100.0.0",
48
52
  "@pnpm/fs.graceful-fs": "1100.1.0",
49
- "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.6",
50
- "@pnpm/resolving.registry.types": "1100.1.0",
53
+ "@pnpm/fetching.types": "1100.0.1",
51
54
  "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
52
- "@pnpm/store.cafs": "1100.1.7",
55
+ "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.8",
56
+ "@pnpm/resolving.registry.types": "1100.1.2",
57
+ "@pnpm/resolving.resolver-base": "1100.4.1",
58
+ "@pnpm/store.cafs": "1100.1.9",
53
59
  "@pnpm/store.index": "1100.1.0",
54
- "@pnpm/types": "1101.2.0",
55
- "@pnpm/workspace.spec-parser": "1100.0.0",
56
- "@pnpm/resolving.resolver-base": "1100.3.1",
60
+ "@pnpm/types": "1101.3.1",
57
61
  "@pnpm/workspace.range-resolver": "1100.0.1",
58
- "@pnpm/config.version-policy": "1100.1.2",
59
- "@pnpm/core-loggers": "1100.1.2"
62
+ "@pnpm/workspace.spec-parser": "1100.0.0"
60
63
  },
61
64
  "peerDependencies": {
62
65
  "@pnpm/logger": "^1001.0.1",
63
- "@pnpm/worker": "^1100.1.8"
66
+ "@pnpm/worker": "^1100.1.10"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@jest/globals": "30.3.0",
@@ -71,10 +74,10 @@
71
74
  "load-json-file": "^7.0.1",
72
75
  "tempy": "3.0.0",
73
76
  "@pnpm/logger": "1100.0.0",
74
- "@pnpm/network.fetch": "1100.0.8",
75
- "@pnpm/test-fixtures": "1100.0.0",
76
- "@pnpm/resolving.npm-resolver": "1101.4.0",
77
- "@pnpm/testing.mock-agent": "1100.0.8"
77
+ "@pnpm/network.fetch": "1100.1.1",
78
+ "@pnpm/resolving.npm-resolver": "1101.5.1",
79
+ "@pnpm/testing.mock-agent": "1101.0.1",
80
+ "@pnpm/test-fixtures": "1100.0.0"
78
81
  },
79
82
  "engines": {
80
83
  "node": ">=22.13"