@pnpm/resolving.npm-resolver 1101.1.1 → 1101.3.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.
- package/lib/createNpmResolutionVerifier.d.ts +74 -0
- package/lib/createNpmResolutionVerifier.js +473 -0
- package/lib/fetchAttestationPublishedAt.d.ts +31 -0
- package/lib/fetchAttestationPublishedAt.js +99 -0
- package/lib/fetchFullMetadataCached.d.ts +33 -0
- package/lib/fetchFullMetadataCached.js +63 -0
- package/lib/index.d.ts +7 -5
- package/lib/index.js +134 -39
- package/lib/pickPackage.d.ts +32 -1
- package/lib/pickPackage.js +37 -31
- package/lib/pickPackageFromMeta.js +6 -2
- package/lib/violationCodes.d.ts +12 -0
- package/lib/violationCodes.js +13 -0
- package/package.json +16 -16
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
|
|
2
|
+
import type { Registries, TrustPolicy } from '@pnpm/types';
|
|
3
|
+
import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
|
|
4
|
+
import { type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js';
|
|
5
|
+
export interface CreateNpmResolutionVerifierOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Minimum age (in minutes) a published version must reach before it is
|
|
8
|
+
* accepted. When unset, the verifier is a no-op for the age check.
|
|
9
|
+
*/
|
|
10
|
+
minimumReleaseAge?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Retained on the options bag because the resolver path branches on it
|
|
13
|
+
* (the lowest-version fallback) and tests forward both fields together.
|
|
14
|
+
* The verifier itself no longer gates on this flag — once the loose-mode
|
|
15
|
+
* auto-collect makes every accepted-immature pin explicit in
|
|
16
|
+
* `minimumReleaseAgeExclude`, running the verifier in loose mode is the
|
|
17
|
+
* thing that proves the manifest stays in sync with the lockfile.
|
|
18
|
+
*/
|
|
19
|
+
minimumReleaseAgeStrict?: boolean;
|
|
20
|
+
minimumReleaseAgeExclude?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* When the registry's metadata lacks the per-version `time` field
|
|
23
|
+
* (some self-hosted registries strip it), the verifier can't apply
|
|
24
|
+
* the maturity cutoff. Set this to `true` to mirror the resolver's
|
|
25
|
+
* `pickMatchingVersionFinal` warn-and-skip behavior — the verifier
|
|
26
|
+
* passes the entry with a one-time `globalWarn`, instead of failing
|
|
27
|
+
* closed. Defaults to `false` so the verifier stays stricter than
|
|
28
|
+
* the resolver only when the user has explicitly opted in to the
|
|
29
|
+
* skip on the resolver side.
|
|
30
|
+
*/
|
|
31
|
+
ignoreMissingTimeField?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* `'no-downgrade'` rejects a lockfile entry whose version has weaker
|
|
34
|
+
* trust evidence (no attestations) than an earlier-published version
|
|
35
|
+
* had. This mirrors the resolver-time `failIfTrustDowngraded` check
|
|
36
|
+
* applied during fresh resolution — the verifier catches the same
|
|
37
|
+
* supply-chain signal on entries that bypassed resolution (peek-path,
|
|
38
|
+
* frozen lockfile, etc.).
|
|
39
|
+
*/
|
|
40
|
+
trustPolicy?: TrustPolicy;
|
|
41
|
+
trustPolicyExclude?: string[];
|
|
42
|
+
trustPolicyIgnoreAfter?: number;
|
|
43
|
+
registries: Registries;
|
|
44
|
+
/**
|
|
45
|
+
* Registries reached via the named-registry resolver chain (e.g. `gh:` →
|
|
46
|
+
* GitHub Packages). When a lockfile entry's tarball URL falls under one of
|
|
47
|
+
* these registry base URLs, route the manifest fetch there instead of the
|
|
48
|
+
* scope-derived default.
|
|
49
|
+
*/
|
|
50
|
+
namedRegistries?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline
|
|
53
|
+
* so abbreviated metadata and `peekManifestFromStore` fast paths cannot
|
|
54
|
+
* hide the publish timestamp.
|
|
55
|
+
*/
|
|
56
|
+
fetchOpts: FetchMetadataFromFromRegistryOptions;
|
|
57
|
+
getAuthHeaderValueByURI: (registry: string) => string | undefined;
|
|
58
|
+
cacheDir?: FetchFullMetadataCachedOptions['cacheDir'];
|
|
59
|
+
/** Overrides Date.now() for tests. */
|
|
60
|
+
now?: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
|
|
64
|
+
* and/or `trustPolicy='no-downgrade'` policies to npm-registry-resolved
|
|
65
|
+
* lockfile entries, or `undefined` when no policy is active. Pairs with
|
|
66
|
+
* `createNpmResolver`: each resolver factory may export a sibling
|
|
67
|
+
* verifier factory that the default-resolver combines.
|
|
68
|
+
*
|
|
69
|
+
* Designed for fail-closed semantics: if the manifest can't be loaded or
|
|
70
|
+
* the pinned version is missing from it, the verifier reports a violation
|
|
71
|
+
* rather than silently passing. Mirrors the post-resolution gate bun added
|
|
72
|
+
* for the same shape of bug in oven-sh/bun#30526.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createNpmResolutionVerifier(opts: CreateNpmResolutionVerifierOptions): ResolutionVerifier | undefined;
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
|
|
2
|
+
import { createPackageVersionPolicy } from '@pnpm/config.version-policy';
|
|
3
|
+
import { FULL_META_DIR } from '@pnpm/constants';
|
|
4
|
+
import { PnpmError } from '@pnpm/error';
|
|
5
|
+
import semver from 'semver';
|
|
6
|
+
import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
|
|
7
|
+
import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
|
|
8
|
+
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js';
|
|
9
|
+
import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
|
|
10
|
+
import { failIfTrustDowngraded } from './trustChecks.js';
|
|
11
|
+
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
|
|
12
|
+
/**
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* Designed for fail-closed semantics: if the manifest can't be loaded or
|
|
20
|
+
* the pinned version is missing from it, the verifier reports a violation
|
|
21
|
+
* rather than silently passing. Mirrors the post-resolution gate bun added
|
|
22
|
+
* for the same shape of bug in oven-sh/bun#30526.
|
|
23
|
+
*/
|
|
24
|
+
export function createNpmResolutionVerifier(opts) {
|
|
25
|
+
const ageCheckActive = Boolean(opts.minimumReleaseAge);
|
|
26
|
+
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
|
+
const cutoff = ageCheckActive
|
|
32
|
+
? (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
|
|
33
|
+
: 0;
|
|
34
|
+
const excludePolicy = opts.minimumReleaseAgeExclude?.length
|
|
35
|
+
? createExcludePolicy(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude')
|
|
36
|
+
: undefined;
|
|
37
|
+
const trustExcludePolicy = opts.trustPolicyExclude?.length
|
|
38
|
+
? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude')
|
|
39
|
+
: undefined;
|
|
40
|
+
// Pre-normalize named-registry URLs and sort by length so two registries
|
|
41
|
+
// that share a hostname but differ by path (e.g. `https://npm/team-a/` vs
|
|
42
|
+
// `https://npm/team-b/`) route to the longest matching prefix — matching
|
|
43
|
+
// only `origin` would silently send lookups to the wrong one. Built-in
|
|
44
|
+
// aliases (`gh:` → npm.pkg.github.com, etc.) are merged in alongside the
|
|
45
|
+
// user-defined ones so the verifier recognizes the same set of named
|
|
46
|
+
// registries the resolver does; otherwise a package resolved via `gh:`
|
|
47
|
+
// would land in the lockfile with a tarball URL the verifier can't route.
|
|
48
|
+
const namedRegistryPrefixes = Object.values({
|
|
49
|
+
...BUILTIN_NAMED_REGISTRIES,
|
|
50
|
+
...(opts.namedRegistries ?? {}),
|
|
51
|
+
})
|
|
52
|
+
.map((url) => {
|
|
53
|
+
const parsed = tryParseUrl(url);
|
|
54
|
+
if (!parsed)
|
|
55
|
+
return null;
|
|
56
|
+
// Ensure trailing slash so prefix matching against tarball URLs (which
|
|
57
|
+
// always include the package path under the registry root) does not
|
|
58
|
+
// accidentally match a sibling registry whose URL shares a prefix string.
|
|
59
|
+
const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
|
|
60
|
+
return `${parsed.origin}${pathname}`;
|
|
61
|
+
})
|
|
62
|
+
.filter((value) => value != null)
|
|
63
|
+
.sort((a, b) => b.length - a.length);
|
|
64
|
+
// Per-install dedup of every network/disk fetch the verifier issues.
|
|
65
|
+
// The maturity check uses the layered `fetchPublishedAt` lookup; the
|
|
66
|
+
// trust check uses an attestation fast-path before falling back to
|
|
67
|
+
// the same full-metadata mirror. All maps live here so verifying
|
|
68
|
+
// many versions of the same package only pays the disk/network costs
|
|
69
|
+
// once. The on-disk conditional-GET cache is handled inside
|
|
70
|
+
// fetch{Abbreviated,Full}MetadataCached via the resolver's shared
|
|
71
|
+
// mirrors at opts.cacheDir.
|
|
72
|
+
const lookupContext = {
|
|
73
|
+
fetchOpts: opts.fetchOpts,
|
|
74
|
+
getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
|
|
75
|
+
cacheDir: opts.cacheDir,
|
|
76
|
+
cutoffMs: cutoff,
|
|
77
|
+
abbreviatedMetaCache: new Map(),
|
|
78
|
+
publishedAtCache: new Map(),
|
|
79
|
+
localMetaCache: new Map(),
|
|
80
|
+
fullMetaCache: new Map(),
|
|
81
|
+
fullMetaForTrustCache: new Map(),
|
|
82
|
+
};
|
|
83
|
+
const minimumReleaseAge = opts.minimumReleaseAge ?? 0;
|
|
84
|
+
const trustPolicy = opts.trustPolicy;
|
|
85
|
+
const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
|
|
86
|
+
const verify = async (resolution, { name, version }) => {
|
|
87
|
+
if (!isNpmRegistryResolution(resolution))
|
|
88
|
+
return { ok: true };
|
|
89
|
+
// Non-semver versions identify URL tarballs, file: refs, git refs, etc.
|
|
90
|
+
// Neither the age nor the trust policy applies, and a registry lookup
|
|
91
|
+
// would 404.
|
|
92
|
+
if (!semver.valid(version))
|
|
93
|
+
return { ok: true };
|
|
94
|
+
const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version);
|
|
95
|
+
const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version);
|
|
96
|
+
if (!ageApplies && !trustApplies)
|
|
97
|
+
return { ok: true };
|
|
98
|
+
const tarballUrl = resolution.tarball;
|
|
99
|
+
const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
|
|
100
|
+
if (ageApplies) {
|
|
101
|
+
const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true);
|
|
102
|
+
if (ageViolation)
|
|
103
|
+
return ageViolation;
|
|
104
|
+
}
|
|
105
|
+
if (trustApplies) {
|
|
106
|
+
const trustViolation = await runTrustCheck(lookupContext, registry, name, version, {
|
|
107
|
+
trustPolicyExclude: trustExcludePolicy,
|
|
108
|
+
trustPolicyIgnoreAfter,
|
|
109
|
+
});
|
|
110
|
+
if (trustViolation)
|
|
111
|
+
return trustViolation;
|
|
112
|
+
}
|
|
113
|
+
return { ok: true };
|
|
114
|
+
};
|
|
115
|
+
// Snapshot the exclude lists (sorted, deduped) and require an exact
|
|
116
|
+
// match in `canTrustPastCheck`: cache identity == policy identity.
|
|
117
|
+
// Any change to either exclude list — adding, removing, or
|
|
118
|
+
// substituting an entry — invalidates the cached run. This is
|
|
119
|
+
// stricter than a pure correctness check would require (adding to
|
|
120
|
+
// either list is more permissive and the cached pass would still
|
|
121
|
+
// hold), but it makes the cache contract trivial to reason about and
|
|
122
|
+
// removes a class of bypasses where a previously-approved version
|
|
123
|
+
// stays trusted after its exclude entry has been pulled.
|
|
124
|
+
const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort();
|
|
125
|
+
const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort();
|
|
126
|
+
return {
|
|
127
|
+
verify,
|
|
128
|
+
policy: {
|
|
129
|
+
minimumReleaseAge,
|
|
130
|
+
minimumReleaseAgeExclude: sortedMinAgeExcludes,
|
|
131
|
+
trustPolicy: trustPolicy ?? null,
|
|
132
|
+
trustPolicyExclude: sortedTrustExcludes,
|
|
133
|
+
trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
|
|
134
|
+
},
|
|
135
|
+
canTrustPastCheck: (cached) => {
|
|
136
|
+
// Maturity: a previously cached run under a larger cutoff
|
|
137
|
+
// (stricter window) is trustworthy under a smaller current one —
|
|
138
|
+
// its set of accepted versions is a subset of today's. The
|
|
139
|
+
// reverse — tightening the cutoff — invalidates the cached run:
|
|
140
|
+
// versions that passed before may now be in-window. Non-number
|
|
141
|
+
// cached values come from an older record shape and aren't trusted.
|
|
142
|
+
const past = cached.minimumReleaseAge;
|
|
143
|
+
const pastNumber = typeof past === 'number' ? past : 0;
|
|
144
|
+
if (pastNumber < minimumReleaseAge)
|
|
145
|
+
return false;
|
|
146
|
+
// Excludes: today's sorted-deduped lists must match the cached
|
|
147
|
+
// ones byte for byte. Older records (no field) fall back to an
|
|
148
|
+
// empty array, so they only trust today's empty policy.
|
|
149
|
+
const pastMinAgeExcludes = Array.isArray(cached.minimumReleaseAgeExclude)
|
|
150
|
+
? cached.minimumReleaseAgeExclude
|
|
151
|
+
: [];
|
|
152
|
+
if (JSON.stringify(pastMinAgeExcludes) !== JSON.stringify(sortedMinAgeExcludes))
|
|
153
|
+
return false;
|
|
154
|
+
// Trust policy: any change to `trustPolicy`, the exclude list, or
|
|
155
|
+
// the ignore-after cutoff invalidates the cached run. Older
|
|
156
|
+
// records (no trust field at all) treat the trust policy as
|
|
157
|
+
// absent and are only trusted under an unset-today policy.
|
|
158
|
+
const pastTrustPolicy = cached.trustPolicy ?? null;
|
|
159
|
+
const todayTrustPolicy = trustPolicy ?? null;
|
|
160
|
+
if (pastTrustPolicy !== todayTrustPolicy)
|
|
161
|
+
return false;
|
|
162
|
+
const pastTrustExcludes = Array.isArray(cached.trustPolicyExclude)
|
|
163
|
+
? cached.trustPolicyExclude
|
|
164
|
+
: [];
|
|
165
|
+
if (JSON.stringify(pastTrustExcludes) !== JSON.stringify(sortedTrustExcludes))
|
|
166
|
+
return false;
|
|
167
|
+
const pastIgnoreAfter = typeof cached.trustPolicyIgnoreAfter === 'number'
|
|
168
|
+
? cached.trustPolicyIgnoreAfter
|
|
169
|
+
: null;
|
|
170
|
+
const todayIgnoreAfter = trustPolicyIgnoreAfter ?? null;
|
|
171
|
+
if (pastIgnoreAfter !== todayIgnoreAfter)
|
|
172
|
+
return false;
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissingTimeField) {
|
|
178
|
+
let published;
|
|
179
|
+
try {
|
|
180
|
+
published = await fetchPublishedAt(context, registry, name, version);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
|
|
186
|
+
reason: uncheckable('minimumReleaseAge', err instanceof Error ? err.message : String(err)),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!published) {
|
|
190
|
+
// No source — attestation, local mirror, or full metadata —
|
|
191
|
+
// surfaced a publish timestamp for this version. The resolver's
|
|
192
|
+
// pickMatchingVersionFinal honors `minimumReleaseAgeIgnoreMissingTime`
|
|
193
|
+
// for the same shape (some self-hosted registries strip per-version
|
|
194
|
+
// `time`); the verifier mirrors that so it can't be stricter than
|
|
195
|
+
// fresh resolution. Without the flag we still fail closed — better
|
|
196
|
+
// a false reject than silent bypass when the user hasn't opted in.
|
|
197
|
+
if (ignoreMissingTimeField) {
|
|
198
|
+
warnMissingTimeFieldOnce(name);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
|
|
204
|
+
reason: uncheckable('minimumReleaseAge', 'version not present in registry manifest'),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const publishedAt = new Date(published);
|
|
208
|
+
const ts = publishedAt.getTime();
|
|
209
|
+
if (Number.isNaN(ts)) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
|
|
213
|
+
reason: 'publish timestamp is not a valid date',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (ts > cutoff) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
|
|
220
|
+
reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Run the resolver-time `failIfTrustDowngraded` check against the
|
|
227
|
+
* pinned lockfile version. The packument is fetched through a
|
|
228
|
+
* per-install cache so multiple versions of the same package share
|
|
229
|
+
* one fetch.
|
|
230
|
+
*
|
|
231
|
+
* No attestation fast-path here even though the per-version
|
|
232
|
+
* attestation endpoint is cheaper than the packument: presence of
|
|
233
|
+
* provenance on the current version is not sufficient to clear a
|
|
234
|
+
* downgrade. A package could have shipped earlier versions under a
|
|
235
|
+
* `trustedPublisher` (the higher-rank evidence) and then dropped
|
|
236
|
+
* back to plain provenance for the version we're verifying —
|
|
237
|
+
* `failIfTrustDowngraded` correctly flags that, and a "has any
|
|
238
|
+
* attestation → pass" shortcut would silently miss it.
|
|
239
|
+
*/
|
|
240
|
+
async function runTrustCheck(context, registry, name, version, opts) {
|
|
241
|
+
let meta;
|
|
242
|
+
try {
|
|
243
|
+
meta = await fetchFullMetaForTrust(context, registry, name);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
// `fetchFullMetadataCached` rejects (network error, 404, etc.); the
|
|
247
|
+
// verifier fails closed so a missing manifest can't be mistaken
|
|
248
|
+
// for a passing trust check.
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
code: TRUST_DOWNGRADE_VIOLATION_CODE,
|
|
252
|
+
reason: uncheckable('trustPolicy', err instanceof Error ? err.message : String(err)),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
failIfTrustDowngraded(meta, version, opts);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
code: TRUST_DOWNGRADE_VIOLATION_CODE,
|
|
262
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
function fetchFullMetaForTrust(context, registry, name) {
|
|
268
|
+
const cacheKey = `${registry}\x00${name}`;
|
|
269
|
+
let cachedPromise = context.fullMetaForTrustCache.get(cacheKey);
|
|
270
|
+
if (cachedPromise == null) {
|
|
271
|
+
// Don't swallow the fetch rejection here — `runTrustCheck` catches it
|
|
272
|
+
// and surfaces the underlying message in the violation reason, which
|
|
273
|
+
// is more actionable than the generic "metadata is unavailable" the
|
|
274
|
+
// `!meta` fallback emits. The cache still holds the rejected promise
|
|
275
|
+
// so repeat verifier calls for the same (registry, name) within one
|
|
276
|
+
// install don't refetch a known-failing endpoint.
|
|
277
|
+
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
|
|
278
|
+
registry,
|
|
279
|
+
authHeaderValue: context.getAuthHeaderValueByURI(registry),
|
|
280
|
+
cacheDir: context.cacheDir,
|
|
281
|
+
});
|
|
282
|
+
context.fullMetaForTrustCache.set(cacheKey, cachedPromise);
|
|
283
|
+
}
|
|
284
|
+
return cachedPromise;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Per-(registry, name, version) lookup with a layered fallback:
|
|
288
|
+
*
|
|
289
|
+
* 1. **Abbreviated metadata `modified` shortcut.** This is what the
|
|
290
|
+
* resolver already fetches by default; it's a small document with
|
|
291
|
+
* a package-level last-modified time but no per-version timestamps.
|
|
292
|
+
* If `modified` is older than the policy cutoff, every version in
|
|
293
|
+
* this package was published at least that long ago — return the
|
|
294
|
+
* `modified` timestamp as a conservative upper bound and skip the
|
|
295
|
+
* rest of the chain. Costs one conditional GET that the resolver
|
|
296
|
+
* has usually already paid for.
|
|
297
|
+
* 2. **On-disk full-metadata mirror.** If a previous verification
|
|
298
|
+
* populated `FULL_META_DIR`, take the per-version timestamp from
|
|
299
|
+
* there.
|
|
300
|
+
* 3. **npm attestation endpoint.** Small payload, just this version's
|
|
301
|
+
* Sigstore-anchored timestamp. Wins on cold cache when the package
|
|
302
|
+
* was published with provenance.
|
|
303
|
+
* 4. **Full metadata fetch.** Last resort — only paid when the
|
|
304
|
+
* abbreviated shortcut can't decide, the local full mirror is
|
|
305
|
+
* cold, and there's no attestation.
|
|
306
|
+
*/
|
|
307
|
+
async function fetchPublishedAt(context, registry, name, version) {
|
|
308
|
+
const cacheKey = `${registry}\x00${name}\x00${version}`;
|
|
309
|
+
let cachedPromise = context.publishedAtCache.get(cacheKey);
|
|
310
|
+
if (cachedPromise == null) {
|
|
311
|
+
cachedPromise = resolvePublishedAt(context, registry, name, version);
|
|
312
|
+
context.publishedAtCache.set(cacheKey, cachedPromise);
|
|
313
|
+
}
|
|
314
|
+
return cachedPromise;
|
|
315
|
+
}
|
|
316
|
+
async function resolvePublishedAt(context, registry, name, version) {
|
|
317
|
+
const abbreviatedShortcut = await tryAbbreviatedModifiedShortcut(context, registry, name, version);
|
|
318
|
+
if (abbreviatedShortcut != null)
|
|
319
|
+
return abbreviatedShortcut;
|
|
320
|
+
const localTime = await readLocalMetaTime(context, registry, name);
|
|
321
|
+
if (localTime?.[version])
|
|
322
|
+
return localTime[version];
|
|
323
|
+
const attestationTime = await fetchAttestationPublishedAt(context.fetchOpts, name, version, {
|
|
324
|
+
registry,
|
|
325
|
+
authHeaderValue: context.getAuthHeaderValueByURI(registry),
|
|
326
|
+
});
|
|
327
|
+
if (attestationTime != null)
|
|
328
|
+
return attestationTime;
|
|
329
|
+
const fullMetaTime = await fetchFullMetaTime(context, registry, name);
|
|
330
|
+
return fullMetaTime?.[version];
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Returns the abbreviated metadata's `modified` timestamp **iff** it
|
|
334
|
+
* proves the gate would pass — i.e. modified is strictly older than
|
|
335
|
+
* the policy cutoff *and* the pinned version still exists in the
|
|
336
|
+
* package's current versions map.
|
|
337
|
+
*
|
|
338
|
+
* The version check is the fail-closed contract: an unpublished or
|
|
339
|
+
* never-published version must not slip through on the package-level
|
|
340
|
+
* `modified` timestamp. When the version is missing here we fall
|
|
341
|
+
* through to the later layers so the caller eventually surfaces the
|
|
342
|
+
* "version not present in registry manifest" violation.
|
|
343
|
+
*
|
|
344
|
+
* Returns `undefined` otherwise (modified is too recent, the metadata
|
|
345
|
+
* lacks a parseable modified field, the version isn't in the abbreviated
|
|
346
|
+
* form, or the fetch failed) and the caller proceeds with per-version
|
|
347
|
+
* lookups.
|
|
348
|
+
*/
|
|
349
|
+
async function tryAbbreviatedModifiedShortcut(context, registry, name, version) {
|
|
350
|
+
const meta = await fetchAbbreviatedMeta(context, registry, name);
|
|
351
|
+
const modified = meta?.modified;
|
|
352
|
+
if (typeof modified !== 'string')
|
|
353
|
+
return undefined;
|
|
354
|
+
const modifiedMs = Date.parse(modified);
|
|
355
|
+
if (Number.isNaN(modifiedMs))
|
|
356
|
+
return undefined;
|
|
357
|
+
if (modifiedMs >= context.cutoffMs)
|
|
358
|
+
return undefined;
|
|
359
|
+
// The shortcut treats `modified` as an upper bound on every version's
|
|
360
|
+
// publish time — but only for versions the registry currently lists.
|
|
361
|
+
// An unpublished or never-published pin would otherwise pass the gate
|
|
362
|
+
// on a stale package-level timestamp.
|
|
363
|
+
if (!meta?.versions || !(version in meta.versions))
|
|
364
|
+
return undefined;
|
|
365
|
+
return modified;
|
|
366
|
+
}
|
|
367
|
+
function fetchAbbreviatedMeta(context, registry, name) {
|
|
368
|
+
const cacheKey = `${registry}\x00${name}`;
|
|
369
|
+
let cachedPromise = context.abbreviatedMetaCache.get(cacheKey);
|
|
370
|
+
if (cachedPromise == null) {
|
|
371
|
+
cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
|
|
372
|
+
registry,
|
|
373
|
+
authHeaderValue: context.getAuthHeaderValueByURI(registry),
|
|
374
|
+
cacheDir: context.cacheDir,
|
|
375
|
+
}).catch(() => undefined);
|
|
376
|
+
context.abbreviatedMetaCache.set(cacheKey, cachedPromise);
|
|
377
|
+
}
|
|
378
|
+
return cachedPromise;
|
|
379
|
+
}
|
|
380
|
+
function readLocalMetaTime(context, registry, name) {
|
|
381
|
+
if (!context.cacheDir)
|
|
382
|
+
return Promise.resolve(undefined);
|
|
383
|
+
const cacheKey = `${registry}\x00${name}`;
|
|
384
|
+
let cachedPromise = context.localMetaCache.get(cacheKey);
|
|
385
|
+
if (cachedPromise == null) {
|
|
386
|
+
cachedPromise = loadLocalMetaTime(context.cacheDir, registry, name);
|
|
387
|
+
context.localMetaCache.set(cacheKey, cachedPromise);
|
|
388
|
+
}
|
|
389
|
+
return cachedPromise;
|
|
390
|
+
}
|
|
391
|
+
async function loadLocalMetaTime(cacheDir, registry, name) {
|
|
392
|
+
const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registry, name);
|
|
393
|
+
const cached = await loadMeta(pkgMirror);
|
|
394
|
+
return cached?.time;
|
|
395
|
+
}
|
|
396
|
+
function fetchFullMetaTime(context, registry, name) {
|
|
397
|
+
const cacheKey = `${registry}\x00${name}`;
|
|
398
|
+
let cachedPromise = context.fullMetaCache.get(cacheKey);
|
|
399
|
+
if (cachedPromise == null) {
|
|
400
|
+
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
|
|
401
|
+
registry,
|
|
402
|
+
authHeaderValue: context.getAuthHeaderValueByURI(registry),
|
|
403
|
+
cacheDir: context.cacheDir,
|
|
404
|
+
}).then((meta) => meta.time);
|
|
405
|
+
context.fullMetaCache.set(cacheKey, cachedPromise);
|
|
406
|
+
}
|
|
407
|
+
return cachedPromise;
|
|
408
|
+
}
|
|
409
|
+
function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarballUrl) {
|
|
410
|
+
// If the lockfile records where the tarball lives, prefer that — scope
|
|
411
|
+
// routing (`@scope:registry`) only covers scoped packages, but named
|
|
412
|
+
// registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
|
|
413
|
+
// origin we'd otherwise miss. Match the longest prefix so that two named
|
|
414
|
+
// registries sharing a host but differing by path don't collide.
|
|
415
|
+
if (tarballUrl) {
|
|
416
|
+
const normalized = tryParseUrl(tarballUrl)?.toString();
|
|
417
|
+
if (normalized) {
|
|
418
|
+
for (const prefix of namedRegistryPrefixes) {
|
|
419
|
+
if (normalized.startsWith(prefix))
|
|
420
|
+
return prefix;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return pickRegistryForPackage(registries, name);
|
|
425
|
+
}
|
|
426
|
+
function tryParseUrl(url) {
|
|
427
|
+
try {
|
|
428
|
+
return new URL(url);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function uncheckable(policy, why) {
|
|
435
|
+
return `could not be checked against ${policy} (${why})`;
|
|
436
|
+
}
|
|
437
|
+
function createExcludePolicy(patterns, key) {
|
|
438
|
+
// Mirror the wrapping done by the full-resolution path
|
|
439
|
+
// (installing/deps-resolver/src/resolveDependencyTree.ts) so the error
|
|
440
|
+
// code is identical regardless of which path surfaced the invalid pattern.
|
|
441
|
+
try {
|
|
442
|
+
return createPackageVersionPolicy(patterns);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
if (!err || typeof err !== 'object' || !('message' in err))
|
|
446
|
+
throw err;
|
|
447
|
+
throw new PnpmError(`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`, `Invalid value in ${key}: ${err.message}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function isExcluded(policy, name, version) {
|
|
451
|
+
if (!policy)
|
|
452
|
+
return false;
|
|
453
|
+
const result = policy(name);
|
|
454
|
+
if (result === true)
|
|
455
|
+
return true;
|
|
456
|
+
if (Array.isArray(result) && result.includes(version))
|
|
457
|
+
return true;
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
function isNpmRegistryResolution(resolution) {
|
|
461
|
+
if (resolution == null || typeof resolution !== 'object')
|
|
462
|
+
return false;
|
|
463
|
+
// Only plain tarball resolutions (npm registry / named registries) have no
|
|
464
|
+
// `type` field. Git / directory / binary / custom resolutions all carry one.
|
|
465
|
+
if ('type' in resolution && resolution.type != null)
|
|
466
|
+
return false;
|
|
467
|
+
// Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
|
|
468
|
+
// the resolver and aren't subject to release-age policy.
|
|
469
|
+
if ('gitHosted' in resolution && resolution.gitHosted)
|
|
470
|
+
return false;
|
|
471
|
+
return 'tarball' in resolution || 'integrity' in resolution;
|
|
472
|
+
}
|
|
473
|
+
//# sourceMappingURL=createNpmResolutionVerifier.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-version publish timestamp from npm's attestation endpoint —
|
|
4
|
+
* `/-/npm/v1/attestations/<name>@<version>`.
|
|
5
|
+
*
|
|
6
|
+
* The response is a small JSON document containing one or more Sigstore
|
|
7
|
+
* bundles. We read `bundle.verificationMaterial.tlogEntries[].integratedTime`
|
|
8
|
+
* (the Rekor inclusion time) and surface it as an ISO date. This is a
|
|
9
|
+
* couple of seconds after the actual publish — close enough for a
|
|
10
|
+
* release-age policy that operates in minutes/hours/days.
|
|
11
|
+
*
|
|
12
|
+
* We deliberately do **not** verify the Sigstore signature here: the
|
|
13
|
+
* trust model is identical to reading the registry's `time` field on
|
|
14
|
+
* the full metadata document. The win is bandwidth — the attestation
|
|
15
|
+
* payload is tens of KB versus the multi-MB full metadata document, so
|
|
16
|
+
* cold-cache + `--frozen-lockfile` installs against a fleet of
|
|
17
|
+
* provenance-published packages pay far less to verify timestamps.
|
|
18
|
+
*
|
|
19
|
+
* Returns `undefined` when:
|
|
20
|
+
*
|
|
21
|
+
* - The package has no published attestations (`404`).
|
|
22
|
+
* - The response is malformed or missing the timestamp.
|
|
23
|
+
* - The request itself fails (network error, registry 5xx).
|
|
24
|
+
*
|
|
25
|
+
* In all of those cases the caller falls back to fetching full metadata.
|
|
26
|
+
*/
|
|
27
|
+
export interface FetchAttestationOptions {
|
|
28
|
+
registry: string;
|
|
29
|
+
authHeaderValue?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function fetchAttestationPublishedAt(fetchOpts: FetchMetadataFromFromRegistryOptions, pkgName: string, version: string, opts: FetchAttestationOptions): Promise<string | undefined>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as retry from '@zkochan/retry';
|
|
2
|
+
export async function fetchAttestationPublishedAt(fetchOpts, pkgName, version, opts) {
|
|
3
|
+
const url = `${opts.registry.replace(/\/$/, '')}/-/npm/v1/attestations/${pkgName}@${version}`;
|
|
4
|
+
const retryOperation = retry.operation(fetchOpts.retry);
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
retryOperation.attempt(async () => {
|
|
7
|
+
let response;
|
|
8
|
+
try {
|
|
9
|
+
response = await fetchOpts.fetch(url, {
|
|
10
|
+
authHeaderValue: opts.authHeaderValue,
|
|
11
|
+
retry: fetchOpts.retry,
|
|
12
|
+
timeout: fetchOpts.timeout,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Network errors fall through to the full-metadata path; the
|
|
17
|
+
// caller's `fetchFullMetadataCached` has its own retry policy.
|
|
18
|
+
resolve(undefined);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// 404 = package never published attestations. Other 4xx/5xx also
|
|
22
|
+
// mean "can't get an answer from this endpoint, fall back."
|
|
23
|
+
if (response.status >= 400) {
|
|
24
|
+
resolve(undefined);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let body;
|
|
28
|
+
try {
|
|
29
|
+
body = await response.json();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
resolve(undefined);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
resolve(extractPublishedAt(body));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pull the earliest `integratedTime` across every attestation bundle in
|
|
41
|
+
* the response and convert it to an ISO timestamp. Earliest is the
|
|
42
|
+
* conservative choice: if two attestations disagree (e.g. publish
|
|
43
|
+
* v0.1 vs SLSA provenance v1), we attribute the publish to the older
|
|
44
|
+
* Rekor entry. The Rekor timestamp is what tells us when the artifact
|
|
45
|
+
* existed in a transparency log — that's the floor on publish time.
|
|
46
|
+
*/
|
|
47
|
+
function extractPublishedAt(body) {
|
|
48
|
+
if (!body || typeof body !== 'object')
|
|
49
|
+
return undefined;
|
|
50
|
+
const attestations = body.attestations;
|
|
51
|
+
if (!Array.isArray(attestations))
|
|
52
|
+
return undefined;
|
|
53
|
+
let earliestSeconds;
|
|
54
|
+
for (const attestation of attestations) {
|
|
55
|
+
const seconds = readEarliestIntegratedTime(attestation);
|
|
56
|
+
if (seconds == null)
|
|
57
|
+
continue;
|
|
58
|
+
if (earliestSeconds == null || seconds < earliestSeconds) {
|
|
59
|
+
earliestSeconds = seconds;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (earliestSeconds == null)
|
|
63
|
+
return undefined;
|
|
64
|
+
return new Date(earliestSeconds * 1000).toISOString();
|
|
65
|
+
}
|
|
66
|
+
function readEarliestIntegratedTime(attestation) {
|
|
67
|
+
if (!attestation || typeof attestation !== 'object')
|
|
68
|
+
return undefined;
|
|
69
|
+
const bundle = attestation.bundle;
|
|
70
|
+
if (!bundle || typeof bundle !== 'object')
|
|
71
|
+
return undefined;
|
|
72
|
+
const verificationMaterial = bundle.verificationMaterial;
|
|
73
|
+
if (!verificationMaterial || typeof verificationMaterial !== 'object')
|
|
74
|
+
return undefined;
|
|
75
|
+
const tlogEntries = verificationMaterial.tlogEntries;
|
|
76
|
+
if (!Array.isArray(tlogEntries))
|
|
77
|
+
return undefined;
|
|
78
|
+
let earliest;
|
|
79
|
+
for (const entry of tlogEntries) {
|
|
80
|
+
if (!entry || typeof entry !== 'object')
|
|
81
|
+
continue;
|
|
82
|
+
const rawIntegratedTime = entry.integratedTime;
|
|
83
|
+
// npm serializes integratedTime as a string ("1778583836") to avoid
|
|
84
|
+
// JSON precision loss; accept either string or number defensively.
|
|
85
|
+
const seconds = parseIntegratedTimeSeconds(rawIntegratedTime);
|
|
86
|
+
if (seconds == null)
|
|
87
|
+
continue;
|
|
88
|
+
if (earliest == null || seconds < earliest)
|
|
89
|
+
earliest = seconds;
|
|
90
|
+
}
|
|
91
|
+
return earliest;
|
|
92
|
+
}
|
|
93
|
+
function parseIntegratedTimeSeconds(raw) {
|
|
94
|
+
const seconds = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
|
|
95
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
return seconds;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=fetchAttestationPublishedAt.js.map
|