@pnpm/resolving.npm-resolver 1101.4.0 → 1101.5.0
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`
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
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`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
//
|
|
91
|
-
//
|
|
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?.
|
|
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
|
|
507
|
-
// actually reads — package-level `modified
|
|
508
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
package/lib/violationCodes.d.ts
CHANGED
package/lib/violationCodes.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1101.5.0",
|
|
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":
|
|
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.
|
|
44
|
-
"@pnpm/
|
|
45
|
-
"@pnpm/error": "1100.0.0",
|
|
46
|
+
"@pnpm/config.pick-registry-for-package": "1100.0.7",
|
|
47
|
+
"@pnpm/config.version-policy": "1100.1.3",
|
|
46
48
|
"@pnpm/crypto.hash": "1100.0.1",
|
|
47
|
-
"@pnpm/fetching.types": "1100.0.1",
|
|
48
49
|
"@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",
|
|
51
50
|
"@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
|
|
52
|
-
"@pnpm/
|
|
51
|
+
"@pnpm/constants": "1100.0.0",
|
|
52
|
+
"@pnpm/resolving.registry.types": "1100.1.1",
|
|
53
|
+
"@pnpm/resolving.resolver-base": "1100.4.0",
|
|
54
|
+
"@pnpm/store.cafs": "1100.1.8",
|
|
53
55
|
"@pnpm/store.index": "1100.1.0",
|
|
54
|
-
"@pnpm/
|
|
55
|
-
"@pnpm/
|
|
56
|
-
"@pnpm/
|
|
56
|
+
"@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.7",
|
|
57
|
+
"@pnpm/core-loggers": "1100.1.3",
|
|
58
|
+
"@pnpm/types": "1101.3.0",
|
|
59
|
+
"@pnpm/fetching.types": "1100.0.1",
|
|
57
60
|
"@pnpm/workspace.range-resolver": "1100.0.1",
|
|
58
|
-
"@pnpm/
|
|
59
|
-
"@pnpm/
|
|
61
|
+
"@pnpm/error": "1100.0.0",
|
|
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.
|
|
66
|
+
"@pnpm/worker": "^1100.1.9"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
|
66
69
|
"@jest/globals": "30.3.0",
|
|
@@ -70,11 +73,11 @@
|
|
|
70
73
|
"@types/ssri": "^7.1.5",
|
|
71
74
|
"load-json-file": "^7.0.1",
|
|
72
75
|
"tempy": "3.0.0",
|
|
73
|
-
"@pnpm/
|
|
74
|
-
"@pnpm/
|
|
76
|
+
"@pnpm/network.fetch": "1100.1.0",
|
|
77
|
+
"@pnpm/resolving.npm-resolver": "1101.5.0",
|
|
75
78
|
"@pnpm/test-fixtures": "1100.0.0",
|
|
76
|
-
"@pnpm/
|
|
77
|
-
"@pnpm/
|
|
79
|
+
"@pnpm/testing.mock-agent": "1101.0.0",
|
|
80
|
+
"@pnpm/logger": "1100.0.0"
|
|
78
81
|
},
|
|
79
82
|
"engines": {
|
|
80
83
|
"node": ">=22.13"
|