eval-quality 3.0.0 → 3.2.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/README.md +4 -1
- package/dist/application/index.d.ts +4 -0
- package/dist/application/index.js +2 -0
- package/dist/core/emit/emit.js +4 -2
- package/dist/core/preflight/reduce.d.ts +1 -1
- package/dist/core/preflight/reduce.js +5 -2
- package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
- package/dist/core/schemas/evaluator-configuration.js +9 -0
- package/dist/core/schemas/evidence-artifact.d.ts +9 -0
- package/dist/core/schemas/evidence-artifact.js +9 -0
- package/dist/core/schemas/isolation-manifest.d.ts +18 -0
- package/dist/core/schemas/isolation-manifest.js +18 -0
- package/dist/core/schemas/preflight-verdict.d.ts +9 -0
- package/dist/core/schemas/preflight-verdict.js +9 -0
- package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
- package/dist/core/schemas/private-artifact-manifest.js +10 -0
- package/dist/core/schemas/scoring-policy.d.ts +11 -0
- package/dist/core/schemas/scoring-policy.js +11 -0
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
- package/dist/core/schemas/sealed-run-record.d.ts +11 -0
- package/dist/core/schemas/sealed-run-record.js +11 -0
- package/dist/core/seal/seal.js +4 -5
- package/dist/gates/audit-lockfile-age.mjs +392 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-doc-claims.js +1012 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +618 -0
- package/dist/gates/check-licenses.mjs +378 -0
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +415 -0
- package/dist/gates/gates-cli.js +607 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +277 -0
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/token-scan.js +203 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +20 -1
- package/dist/testing/probe-conformance.d.ts +23 -18
- package/package.json +24 -11
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Audits every registry entry in package-lock.json for supply-chain freshness.
|
|
3
|
+
//
|
|
4
|
+
// .npmrc's `min-release-age=7` only filters *resolution*: a young package already sitting in a
|
|
5
|
+
// committed lockfile installs cleanly under `npm ci` regardless of that setting (a verified fail-open
|
|
6
|
+
// in this repo's history, see ARCHITECTURE-SPINE.md#Stack). This script re-checks every locked
|
|
7
|
+
// version's real registry publish timestamp and fails closed: on a young entry, on metadata that
|
|
8
|
+
// could not be fetched after retries, or on an entry whose `resolved` is not the registry tarball for
|
|
9
|
+
// that entry's own name and version (a relabelled lockfile entry pointing `resolved` at a mirror, or
|
|
10
|
+
// at another package on the registry itself, would otherwise sail through).
|
|
11
|
+
//
|
|
12
|
+
// These flags are the pre-install path: `.github/actions/audit-lockfile-age`
|
|
13
|
+
// runs this before `npm ci`, so nothing here may import from `node_modules`. A
|
|
14
|
+
// consumer runs the same audit through `eval-quality-gates lockfile-age`, which
|
|
15
|
+
// reads its lockfiles, its window and its exclusions out of a configuration
|
|
16
|
+
// file.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node scripts/audit-lockfile-age.mjs [--lockfile <path>] [--window-days <n>] [--now <RFC3339>]
|
|
20
|
+
//
|
|
21
|
+
// --now lets a canary pin the clock to a fixed offset from a fixture entry's real publish date, so the
|
|
22
|
+
// fixture never rots as real time passes. Ordinary runs omit it and audit against the real wall clock.
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
// The window this script's own flags default to. `gate-config.ts` declares the
|
|
26
|
+
// same default for the configured path as `LOCKFILE_WINDOW_DAYS_DEFAULT`, and
|
|
27
|
+
// `tests/architecture/published-gates.test.ts` holds the two equal. The number
|
|
28
|
+
// is declared twice because this file runs before `npm ci` in CI and so may
|
|
29
|
+
// import nothing from `node_modules`, where the schema that carries the other
|
|
30
|
+
// one lives.
|
|
31
|
+
export const WINDOW_DAYS_DEFAULT = 7;
|
|
32
|
+
const CONCURRENCY = 8;
|
|
33
|
+
const MAX_RETRIES = 3;
|
|
34
|
+
const RETRY_BASE_MS = 300;
|
|
35
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
36
|
+
const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
|
|
37
|
+
/**
|
|
38
|
+
* `error.code` on the refusal a caller repairs by pointing the gate at a real
|
|
39
|
+
* lockfile. `check-licenses.mjs` exports the same string and
|
|
40
|
+
* `tests/architecture/published-gates.test.ts` holds the two equal, for the
|
|
41
|
+
* reason `WINDOW_DAYS_DEFAULT` is declared twice.
|
|
42
|
+
*/
|
|
43
|
+
export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
|
|
44
|
+
function refuseLockfileShape(message) {
|
|
45
|
+
const error = new Error(message);
|
|
46
|
+
error.code = LOCKFILE_SHAPE_ERROR;
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
// The one URL the public registry serves `name@version` from. A scoped name's
|
|
50
|
+
// tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
|
|
51
|
+
// `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
|
|
52
|
+
function registryTarballUrl(name, version) {
|
|
53
|
+
const basename = name.startsWith('@')
|
|
54
|
+
? name.slice(name.indexOf('/') + 1)
|
|
55
|
+
: name;
|
|
56
|
+
return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
|
|
57
|
+
}
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const args = {
|
|
60
|
+
lockfile: 'package-lock.json',
|
|
61
|
+
windowDays: WINDOW_DAYS_DEFAULT,
|
|
62
|
+
now: null,
|
|
63
|
+
cache: null,
|
|
64
|
+
};
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (arg === '--lockfile')
|
|
68
|
+
args.lockfile = argv[++i];
|
|
69
|
+
else if (arg === '--window-days')
|
|
70
|
+
args.windowDays = Number(argv[++i]);
|
|
71
|
+
else if (arg === '--now')
|
|
72
|
+
args.now = argv[++i];
|
|
73
|
+
else if (arg === '--cache')
|
|
74
|
+
args.cache = argv[++i];
|
|
75
|
+
else
|
|
76
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
77
|
+
}
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
// "node_modules/foo" -> "foo"; "node_modules/@scope/foo" -> "@scope/foo";
|
|
81
|
+
// "node_modules/a/node_modules/@scope/b" -> "@scope/b" (nested/duplicate installs).
|
|
82
|
+
function packageNameFromPath(pkgPath) {
|
|
83
|
+
const segments = pkgPath.split('node_modules/');
|
|
84
|
+
return segments[segments.length - 1].replace(/\/$/, '');
|
|
85
|
+
}
|
|
86
|
+
function registryUrlForName(name) {
|
|
87
|
+
const encoded = name.startsWith('@')
|
|
88
|
+
? `${name.split('/')[0]}/${encodeURIComponent(name.split('/')[1])}`
|
|
89
|
+
: encodeURIComponent(name);
|
|
90
|
+
return `${REGISTRY_PREFIX}${encoded}`;
|
|
91
|
+
}
|
|
92
|
+
// A non-retryable 4xx (other than 429) means the request itself is wrong - e.g. a 404 for a name that
|
|
93
|
+
// does not exist on the registry - and retrying just wastes the retry budget for no benefit. 5xx, 429,
|
|
94
|
+
// and network/timeout errors are transient and worth retrying with backoff.
|
|
95
|
+
class NonRetryableFetchError extends Error {
|
|
96
|
+
}
|
|
97
|
+
async function fetchWithRetry(url, attempts = MAX_RETRIES) {
|
|
98
|
+
let lastError;
|
|
99
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(url, {
|
|
102
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
if (res.ok)
|
|
105
|
+
return await res.json();
|
|
106
|
+
if (res.status !== 429 && res.status < 500) {
|
|
107
|
+
throw new NonRetryableFetchError(`HTTP ${res.status}`);
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`HTTP ${res.status}`);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
lastError = err;
|
|
113
|
+
if (err instanceof NonRetryableFetchError)
|
|
114
|
+
break;
|
|
115
|
+
if (attempt < attempts) {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastError;
|
|
121
|
+
}
|
|
122
|
+
// One registry request per unique package NAME (not per lockfile entry): the response carries a
|
|
123
|
+
// `time` map covering every published version, so every locked version of that package is checked
|
|
124
|
+
// from a single fetch. Exported so the cache generator reads the registry the same way the gate
|
|
125
|
+
// does, rather than carrying a second copy of the retry and URL rules.
|
|
126
|
+
export async function fetchTimeMap(name) {
|
|
127
|
+
const meta = await fetchWithRetry(registryUrlForName(name));
|
|
128
|
+
return meta.time ?? {};
|
|
129
|
+
}
|
|
130
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
131
|
+
const results = new Array(items.length);
|
|
132
|
+
let cursor = 0;
|
|
133
|
+
async function worker() {
|
|
134
|
+
while (cursor < items.length) {
|
|
135
|
+
const current = cursor++;
|
|
136
|
+
results[current] = await fn(items[current], current);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
function collectLockedEntries(lockfile, source) {
|
|
143
|
+
// Every entry is read from `packages`, which npm writes from lockfileVersion 2 onward. Defaulting
|
|
144
|
+
// it to {} turned an npm 6 lockfile, or a path naming something that is not a lockfile at all, into
|
|
145
|
+
// a run that reported success over zero entries: a gate that scanned nothing, in the words of a
|
|
146
|
+
// gate that passed.
|
|
147
|
+
const packages = lockfile?.packages;
|
|
148
|
+
if (packages === null ||
|
|
149
|
+
typeof packages !== 'object' ||
|
|
150
|
+
Array.isArray(packages)) {
|
|
151
|
+
throw refuseLockfileShape(`audit-lockfile-age: ${source} carries no "packages" object (lockfileVersion ${JSON.stringify(lockfile?.lockfileVersion ?? null)}); every entry is read from there, so this run would have passed over nothing. npm writes "packages" from lockfileVersion 2 onward.`);
|
|
152
|
+
}
|
|
153
|
+
return Object.entries(packages)
|
|
154
|
+
.filter(([pkgPath, meta]) => pkgPath !== '' && meta.version && !meta.link)
|
|
155
|
+
.map(([pkgPath, meta]) => ({
|
|
156
|
+
path: pkgPath,
|
|
157
|
+
// meta.name (present on aliased entries, e.g. `"node_modules/foo": {"name": "bar", ...}`
|
|
158
|
+
// for an `npm:bar@x` alias) is the package actually installed. Falling back to the
|
|
159
|
+
// path-derived name would audit "foo" - a name that was never fetched - against "bar"'s
|
|
160
|
+
// lockfile version, checking the wrong package entirely.
|
|
161
|
+
name: meta.name ?? packageNameFromPath(pkgPath),
|
|
162
|
+
version: meta.version,
|
|
163
|
+
resolved: meta.resolved,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* `readTimeMap` is the one effect this function performs, named so a caller can
|
|
168
|
+
* supply the registry's answers itself. It defaults to the real fetch, so a
|
|
169
|
+
* caller that wants the registry gets it by saying nothing, and a case that
|
|
170
|
+
* wants a fixed answer runs offline.
|
|
171
|
+
*
|
|
172
|
+
* `cache` is a map from "name@version" to a publication timestamp, and it is
|
|
173
|
+
* what keeps a gate that runs on every build off the network. Both of this
|
|
174
|
+
* audit's inputs make it sound with no staleness bound: a package's publication
|
|
175
|
+
* time is fixed the moment it is published, so a reading taken once is correct
|
|
176
|
+
* forever, and the predicate is monotone in time, so an entry that passes today
|
|
177
|
+
* passes every day after. The entries needing a live fetch are the ones the
|
|
178
|
+
* cache does not carry, which are exactly the dependencies a change added, and
|
|
179
|
+
* fail-closed holds unchanged for them.
|
|
180
|
+
*
|
|
181
|
+
* `exclude` is the package names exempt from the window and from the fetch,
|
|
182
|
+
* the counterpart of `.npmrc`'s `min-release-age-exclude`. Every entry under
|
|
183
|
+
* one of those names comes back in `excludedEntries` and still counts among
|
|
184
|
+
* `entries`; one that also fails the resolved-URL check comes back in both
|
|
185
|
+
* lists, because the exclusion never reached that check.
|
|
186
|
+
*
|
|
187
|
+
* `source` is the path this lockfile was read from, named in the refusal a
|
|
188
|
+
* document without a `packages` object earns.
|
|
189
|
+
*/
|
|
190
|
+
export async function auditLockfileAge({ lockfile, now, windowDays,
|
|
191
|
+
// The cast is for the TypeScript caller: a bare `[]` default reads as never[].
|
|
192
|
+
exclude = /** @type {readonly string[]} */ ([]), source = 'the lockfile', readTimeMap = fetchTimeMap, cache = {}, }) {
|
|
193
|
+
if (!Number.isFinite(windowDays) || windowDays < 1) {
|
|
194
|
+
throw new Error(`windowDays must be at least 1, got: ${windowDays}; a window of zero admits a package published this instant and still reports that every entry was published before the cutoff`);
|
|
195
|
+
}
|
|
196
|
+
// `new Set('left-pad')` is a set of eight characters that excludes nothing.
|
|
197
|
+
if (!Array.isArray(exclude) || exclude.some((n) => typeof n !== 'string')) {
|
|
198
|
+
throw new Error(`exclude must be an array of package names, got: ${JSON.stringify(exclude)}`);
|
|
199
|
+
}
|
|
200
|
+
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
201
|
+
const entries = collectLockedEntries(lockfile, source);
|
|
202
|
+
// An entry whose `resolved` is not the registry tarball for its own name and version did not come
|
|
203
|
+
// from where an age check against npmjs.org assumes. A lockfile edit can point `resolved` at a
|
|
204
|
+
// mirror, or at another package on the registry itself, while the name and version this gate reads
|
|
205
|
+
// still name the harmless one; `npm ci` fetches `resolved` and checks `integrity` against whatever
|
|
206
|
+
// comes back, so the substituted tarball installs. Pinning the host alone left that second shape
|
|
207
|
+
// open. Fail closed on the mismatch: the age of a package the install never fetches establishes nothing.
|
|
208
|
+
const offRegistryEntries = [];
|
|
209
|
+
const registryEntries = [];
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
if (entry.resolved === registryTarballUrl(entry.name, entry.version)) {
|
|
212
|
+
registryEntries.push(entry);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
offRegistryEntries.push(entry);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const cachedAt = (entry) => cache[`${entry.name}@${entry.version}`];
|
|
219
|
+
// An exclusion exempts a name from the window and from the fetch, and from
|
|
220
|
+
// nothing else. `min-release-age-exclude` says a package's young releases are
|
|
221
|
+
// accepted and says nothing about which tarball the install fetches, so an
|
|
222
|
+
// excluded entry is still held by the resolved-URL check above: a substituted
|
|
223
|
+
// `resolved` under an excluded name is the defect that check exists for. The
|
|
224
|
+
// exclusion is read over every entry, so a run prints it beside that failure.
|
|
225
|
+
const excludedNames = new Set(exclude);
|
|
226
|
+
const excludedEntries = entries.filter((entry) => excludedNames.has(entry.name));
|
|
227
|
+
const auditedEntries = registryEntries.filter((entry) => !excludedNames.has(entry.name));
|
|
228
|
+
// One request per unique package name, and only for a name carrying at least
|
|
229
|
+
// one version the cache does not answer. A name whose every locked version is
|
|
230
|
+
// cached, or excluded, is never asked for.
|
|
231
|
+
const uniqueNames = [
|
|
232
|
+
...new Set(auditedEntries
|
|
233
|
+
.filter((entry) => cachedAt(entry) === undefined)
|
|
234
|
+
.map((entry) => entry.name)),
|
|
235
|
+
];
|
|
236
|
+
const timeMaps = new Map();
|
|
237
|
+
const fetchFailures = new Set();
|
|
238
|
+
await mapWithConcurrency(uniqueNames, CONCURRENCY, async (name) => {
|
|
239
|
+
try {
|
|
240
|
+
timeMaps.set(name, await readTimeMap(name));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
fetchFailures.add(name);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
const youngEntries = [];
|
|
247
|
+
const unfetchableEntries = [];
|
|
248
|
+
for (const entry of auditedEntries) {
|
|
249
|
+
const fromCache = cachedAt(entry);
|
|
250
|
+
if (fromCache === undefined && fetchFailures.has(entry.name)) {
|
|
251
|
+
unfetchableEntries.push(entry);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const publishedAt = fromCache ?? timeMaps.get(entry.name)?.[entry.version];
|
|
255
|
+
if (!publishedAt) {
|
|
256
|
+
unfetchableEntries.push(entry);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const publishedDate = new Date(publishedAt);
|
|
260
|
+
if (Number.isNaN(publishedDate.getTime())) {
|
|
261
|
+
// An unparseable timestamp must not silently compare as "not young" (Date comparisons
|
|
262
|
+
// against an Invalid Date are always false) - that would fetch metadata, find it useless,
|
|
263
|
+
// and pass anyway. Treat it the same as metadata that could not be fetched at all.
|
|
264
|
+
unfetchableEntries.push(entry);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (publishedDate > cutoff) {
|
|
268
|
+
youngEntries.push({ ...entry, publishedAt });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
cutoff,
|
|
273
|
+
entries,
|
|
274
|
+
youngEntries,
|
|
275
|
+
unfetchableEntries,
|
|
276
|
+
offRegistryEntries,
|
|
277
|
+
excludedEntries,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The cache document, checked before a single timestamp is trusted. A malformed
|
|
282
|
+
* one is a refusal: a cache whose values are not timestamps would answer every
|
|
283
|
+
* lookup with something the audit reads as unparseable, and every entry would
|
|
284
|
+
* land in `unfetchableEntries` with no explanation of why.
|
|
285
|
+
*/
|
|
286
|
+
// `new Date(value)` alone accepts strings no publication record is ever written
|
|
287
|
+
// in, "12" parses as the year 2001, so the shape is checked first: an RFC3339
|
|
288
|
+
// date-time, which is what the npm registry's own `time` map writes and what
|
|
289
|
+
// `fetchTimeMap` reads back into the cache.
|
|
290
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
291
|
+
export function readPublishCache(document, source) {
|
|
292
|
+
if (document === null ||
|
|
293
|
+
typeof document !== 'object' ||
|
|
294
|
+
Array.isArray(document)) {
|
|
295
|
+
throw refuseLockfileShape(`${source} is not a JSON object; a publication cache maps "name@version" to a timestamp`);
|
|
296
|
+
}
|
|
297
|
+
for (const [key, value] of Object.entries(document)) {
|
|
298
|
+
if (typeof value !== 'string' ||
|
|
299
|
+
!RFC3339.test(value) ||
|
|
300
|
+
Number.isNaN(new Date(value).getTime())) {
|
|
301
|
+
throw refuseLockfileShape(`${source} holds ${JSON.stringify(value)} for "${key}", which is not an RFC3339 timestamp`);
|
|
302
|
+
}
|
|
303
|
+
if (!key.includes('@', 1)) {
|
|
304
|
+
throw refuseLockfileShape(`${source} holds the key "${key}", which is not a "name@version"`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return document;
|
|
308
|
+
}
|
|
309
|
+
/** The cache file, with the two ways reading it fails named rather than thrown raw. */
|
|
310
|
+
async function readCacheFile(path) {
|
|
311
|
+
let text;
|
|
312
|
+
try {
|
|
313
|
+
text = await readFile(path, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
throw refuseLockfileShape(`${path} could not be read: ${error.message}; --cache names it`);
|
|
317
|
+
}
|
|
318
|
+
let document;
|
|
319
|
+
try {
|
|
320
|
+
document = JSON.parse(text);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
throw refuseLockfileShape(`${path} is not valid JSON: ${error.message}`);
|
|
324
|
+
}
|
|
325
|
+
return readPublishCache(document, path);
|
|
326
|
+
}
|
|
327
|
+
async function main() {
|
|
328
|
+
const args = parseArgs(process.argv.slice(2));
|
|
329
|
+
const now = args.now ? new Date(args.now) : new Date();
|
|
330
|
+
if (Number.isNaN(now.getTime())) {
|
|
331
|
+
throw new Error(`--now is not a valid RFC3339 timestamp: ${args.now}`);
|
|
332
|
+
}
|
|
333
|
+
console.log(`Effective clock: ${now.toISOString()}`);
|
|
334
|
+
const lockfile = JSON.parse(await readFile(args.lockfile, 'utf8'));
|
|
335
|
+
// A cache the caller named and the tree does not have is a refusal rather
|
|
336
|
+
// than a silent full-fetch run: a mistyped path would read as a cache that
|
|
337
|
+
// happens to answer nothing, which is the shape a gate must never pass over.
|
|
338
|
+
const cache = args.cache === null ? {} : await readCacheFile(args.cache);
|
|
339
|
+
const { cutoff, entries, youngEntries, unfetchableEntries, offRegistryEntries, } = await auditLockfileAge({
|
|
340
|
+
lockfile,
|
|
341
|
+
now,
|
|
342
|
+
windowDays: args.windowDays,
|
|
343
|
+
source: args.lockfile,
|
|
344
|
+
cache,
|
|
345
|
+
});
|
|
346
|
+
if (youngEntries.length === 0 &&
|
|
347
|
+
unfetchableEntries.length === 0 &&
|
|
348
|
+
offRegistryEntries.length === 0) {
|
|
349
|
+
console.log(`Lockfile age audit passed: ${entries.length} entries, all published before ${cutoff.toISOString()}.`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (offRegistryEntries.length > 0) {
|
|
353
|
+
console.error(`\nFailed closed: ${offRegistryEntries.length} entrie(s) do not resolve to their own registry tarball:`);
|
|
354
|
+
for (const entry of offRegistryEntries) {
|
|
355
|
+
console.error(` - ${entry.name}@${entry.version} resolved=${JSON.stringify(entry.resolved ?? null)}, expected ${registryTarballUrl(entry.name, entry.version)} (${entry.path})`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (unfetchableEntries.length > 0) {
|
|
359
|
+
console.error(`\nFailed closed: could not fetch publish metadata for ${unfetchableEntries.length} entrie(s):`);
|
|
360
|
+
for (const entry of unfetchableEntries) {
|
|
361
|
+
console.error(` - ${entry.name}@${entry.version} (${entry.path})`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (youngEntries.length > 0) {
|
|
365
|
+
console.error(`\nAge violation: ${youngEntries.length} entrie(s) published inside the ${args.windowDays}-day window (cutoff ${cutoff.toISOString()}):`);
|
|
366
|
+
for (const entry of youngEntries) {
|
|
367
|
+
console.error(` - ${entry.name}@${entry.version} published ${entry.publishedAt} (${entry.path})`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
}
|
|
372
|
+
// pathToFileURL percent-encodes the same way import.meta.url does (spaces, non-ASCII, etc.); a raw
|
|
373
|
+
// `file://${process.argv[1]}` template comparison silently mismatches on such paths, so main() never
|
|
374
|
+
// runs and the script exits 0 with no output: a gate switched off while looking idle.
|
|
375
|
+
//
|
|
376
|
+
// process.argv[1] is undefined where there is no script path at all, which is every `node -e`, every
|
|
377
|
+
// `--input-type=module` evaluation, and the REPL. pathToFileURL throws ERR_INVALID_ARG_TYPE on
|
|
378
|
+
// undefined, so without this guard the module cannot be imported from any of them, and it ships in
|
|
379
|
+
// `dist/gates/` where a consumer does exactly that.
|
|
380
|
+
const entryPoint = process.argv[1];
|
|
381
|
+
if (entryPoint !== undefined &&
|
|
382
|
+
import.meta.url === pathToFileURL(entryPoint).href) {
|
|
383
|
+
main().catch((err) => {
|
|
384
|
+
// A refusal the caller repairs by pointing the gate at a real lockfile says so in one line; a
|
|
385
|
+
// stack trace for it is noise.
|
|
386
|
+
const detail = err.code === LOCKFILE_SHAPE_ERROR
|
|
387
|
+
? err.message
|
|
388
|
+
: (err.stack ?? String(err));
|
|
389
|
+
console.error(detail);
|
|
390
|
+
process.exitCode = 1;
|
|
391
|
+
});
|
|
392
|
+
}
|