upstream-radar 0.4.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.
Files changed (92) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +222 -0
  3. package/README.zh-CN.md +170 -0
  4. package/cordis.patch.yml +11 -0
  5. package/dist/src/cli.d.ts +3 -0
  6. package/dist/src/cli.d.ts.map +1 -0
  7. package/dist/src/cli.js +286 -0
  8. package/dist/src/cli.js.map +1 -0
  9. package/dist/src/compatibility.d.ts +12 -0
  10. package/dist/src/compatibility.d.ts.map +1 -0
  11. package/dist/src/compatibility.js +195 -0
  12. package/dist/src/compatibility.js.map +1 -0
  13. package/dist/src/dsh-analysis.d.ts +7 -0
  14. package/dist/src/dsh-analysis.d.ts.map +1 -0
  15. package/dist/src/dsh-analysis.js +53 -0
  16. package/dist/src/dsh-analysis.js.map +1 -0
  17. package/dist/src/dsh-plugin.d.ts +45 -0
  18. package/dist/src/dsh-plugin.d.ts.map +1 -0
  19. package/dist/src/dsh-plugin.js +121 -0
  20. package/dist/src/dsh-plugin.js.map +1 -0
  21. package/dist/src/graph.d.ts +14 -0
  22. package/dist/src/graph.d.ts.map +1 -0
  23. package/dist/src/graph.js +198 -0
  24. package/dist/src/graph.js.map +1 -0
  25. package/dist/src/index.d.ts +19 -0
  26. package/dist/src/index.d.ts.map +1 -0
  27. package/dist/src/index.js +19 -0
  28. package/dist/src/index.js.map +1 -0
  29. package/dist/src/inventory.d.ts +4 -0
  30. package/dist/src/inventory.d.ts.map +1 -0
  31. package/dist/src/inventory.js +182 -0
  32. package/dist/src/inventory.js.map +1 -0
  33. package/dist/src/npm-release.d.ts +24 -0
  34. package/dist/src/npm-release.d.ts.map +1 -0
  35. package/dist/src/npm-release.js +131 -0
  36. package/dist/src/npm-release.js.map +1 -0
  37. package/dist/src/npm.d.ts +36 -0
  38. package/dist/src/npm.d.ts.map +1 -0
  39. package/dist/src/npm.js +803 -0
  40. package/dist/src/npm.js.map +1 -0
  41. package/dist/src/osv.d.ts +18 -0
  42. package/dist/src/osv.d.ts.map +1 -0
  43. package/dist/src/osv.js +238 -0
  44. package/dist/src/osv.js.map +1 -0
  45. package/dist/src/policy.d.ts +5 -0
  46. package/dist/src/policy.d.ts.map +1 -0
  47. package/dist/src/policy.js +32 -0
  48. package/dist/src/policy.js.map +1 -0
  49. package/dist/src/radar-render.d.ts +4 -0
  50. package/dist/src/radar-render.d.ts.map +1 -0
  51. package/dist/src/radar-render.js +50 -0
  52. package/dist/src/radar-render.js.map +1 -0
  53. package/dist/src/radar-state.d.ts +5 -0
  54. package/dist/src/radar-state.d.ts.map +1 -0
  55. package/dist/src/radar-state.js +87 -0
  56. package/dist/src/radar-state.js.map +1 -0
  57. package/dist/src/radar-types.d.ts +163 -0
  58. package/dist/src/radar-types.d.ts.map +1 -0
  59. package/dist/src/radar-types.js +7 -0
  60. package/dist/src/radar-types.js.map +1 -0
  61. package/dist/src/radar.d.ts +24 -0
  62. package/dist/src/radar.d.ts.map +1 -0
  63. package/dist/src/radar.js +234 -0
  64. package/dist/src/radar.js.map +1 -0
  65. package/dist/src/render.d.ts +3 -0
  66. package/dist/src/render.d.ts.map +1 -0
  67. package/dist/src/render.js +58 -0
  68. package/dist/src/render.js.map +1 -0
  69. package/dist/src/scan.d.ts +8 -0
  70. package/dist/src/scan.d.ts.map +1 -0
  71. package/dist/src/scan.js +293 -0
  72. package/dist/src/scan.js.map +1 -0
  73. package/dist/src/semver.d.ts +13 -0
  74. package/dist/src/semver.d.ts.map +1 -0
  75. package/dist/src/semver.js +136 -0
  76. package/dist/src/semver.js.map +1 -0
  77. package/dist/src/tar.d.ts +25 -0
  78. package/dist/src/tar.d.ts.map +1 -0
  79. package/dist/src/tar.js +327 -0
  80. package/dist/src/tar.js.map +1 -0
  81. package/dist/src/types.d.ts +111 -0
  82. package/dist/src/types.d.ts.map +1 -0
  83. package/dist/src/types.js +2 -0
  84. package/dist/src/types.js.map +1 -0
  85. package/dist/src/version.d.ts +2 -0
  86. package/dist/src/version.d.ts.map +1 -0
  87. package/dist/src/version.js +2 -0
  88. package/dist/src/version.js.map +1 -0
  89. package/docs/assets/upstream-radar-hero-mobile.jpg +0 -0
  90. package/docs/assets/upstream-radar-hero.jpg +0 -0
  91. package/package.json +84 -0
  92. package/schemas/analysis-result.schema.json +39 -0
@@ -0,0 +1,803 @@
1
+ import { createHash, createPublicKey, timingSafeEqual, verify as verifySignature, } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve, sep } from 'node:path';
6
+ import process from 'node:process';
7
+ import { decideVerdict, stricterVerdict } from './policy.js';
8
+ import { parseNpmLockGraph } from './graph.js';
9
+ import { scanDirectory } from './scan.js';
10
+ import { parseNpmTarball } from './tar.js';
11
+ import { REPORT_SCHEMA, } from './types.js';
12
+ import { TOOL_VERSION } from './version.js';
13
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org/';
14
+ const MAX_PACKUMENT_BYTES = 16 * 1024 * 1024;
15
+ const MAX_TARBALL_BYTES = 64 * 1024 * 1024;
16
+ const MAX_PROCESS_OUTPUT_BYTES = 32 * 1024 * 1024;
17
+ const MISSING_PUBLISH_TIME_CUTOFF = '2015-01-01T00:00:00.000Z';
18
+ const EXACT_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
19
+ function asRecord(value) {
20
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
21
+ return undefined;
22
+ return value;
23
+ }
24
+ function asString(value) {
25
+ return typeof value === 'string' ? value : undefined;
26
+ }
27
+ function asNumber(value) {
28
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
29
+ }
30
+ function makeFinding(code, severity, summary, detail, evidence, remediation) {
31
+ const result = { code, severity, summary, detail };
32
+ if (evidence !== undefined)
33
+ result.evidence = evidence;
34
+ if (remediation !== undefined)
35
+ result.remediation = remediation;
36
+ return result;
37
+ }
38
+ function sortFindings(findings) {
39
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
40
+ findings.sort((left, right) => order[left.severity] - order[right.severity] || (left.code < right.code ? -1 : left.code > right.code ? 1 : 0));
41
+ }
42
+ function normalizeRegistry(input) {
43
+ const url = new URL(input);
44
+ if (url.protocol !== 'https:')
45
+ throw new Error('npm registry must use HTTPS');
46
+ if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
47
+ throw new Error('npm registry URL must not contain credentials, a query string or a fragment');
48
+ }
49
+ if (!url.pathname.endsWith('/'))
50
+ url.pathname += '/';
51
+ return url.toString();
52
+ }
53
+ function publicUrl(input) {
54
+ const url = new URL(input);
55
+ url.username = '';
56
+ url.password = '';
57
+ url.search = '';
58
+ url.hash = '';
59
+ return url.toString();
60
+ }
61
+ export function parseNpmSpec(input) {
62
+ if (!input.startsWith('npm:'))
63
+ throw new Error('npm package spec must start with npm:');
64
+ const value = input.slice(4);
65
+ const separator = value.lastIndexOf('@');
66
+ if (separator <= 0)
67
+ throw new Error('npm package spec must include an exact version');
68
+ const name = value.slice(0, separator);
69
+ const version = value.slice(separator + 1);
70
+ if (Buffer.byteLength(name) > 214)
71
+ throw new Error('npm package name exceeds 214 bytes');
72
+ if (Buffer.byteLength(version) > 256)
73
+ throw new Error('npm package version exceeds 256 bytes');
74
+ const validName = name.startsWith('@')
75
+ ? /^@[a-z0-9._~-]+\/[a-z0-9._~-]+$/i.test(name)
76
+ : /^[a-z0-9._~-]+$/i.test(name);
77
+ if (!validName)
78
+ throw new Error(`invalid npm package name: ${name}`);
79
+ if (!EXACT_VERSION.test(version)) {
80
+ throw new Error(`npm package version must be exact, not a tag or range: ${version}`);
81
+ }
82
+ return { name, version, canonical: `npm:${name}@${version}` };
83
+ }
84
+ async function fetchBuffer(fetcher, url, maxBytes, timeoutMs, accept) {
85
+ const parsed = new URL(url);
86
+ if (parsed.protocol !== 'https:')
87
+ throw new Error(`refusing non-HTTPS download from ${parsed.origin}${parsed.pathname}`);
88
+ const response = await fetcher(parsed, {
89
+ headers: {
90
+ accept,
91
+ 'user-agent': `upstream-radar/${TOOL_VERSION}`,
92
+ },
93
+ redirect: 'follow',
94
+ signal: AbortSignal.timeout(timeoutMs),
95
+ });
96
+ if (!response.ok)
97
+ throw new Error(`HTTP ${response.status} while fetching ${parsed.origin}${parsed.pathname}`);
98
+ if (new URL(response.url || parsed.toString()).protocol !== 'https:')
99
+ throw new Error('download redirected to a non-HTTPS URL');
100
+ const declaredLength = response.headers.get('content-length');
101
+ if (declaredLength !== null && Number(declaredLength) > maxBytes) {
102
+ throw new Error(`response exceeds ${maxBytes} byte limit`);
103
+ }
104
+ if (response.body === null)
105
+ return Buffer.alloc(0);
106
+ const chunks = [];
107
+ const reader = response.body.getReader();
108
+ let total = 0;
109
+ while (true) {
110
+ const next = await reader.read();
111
+ if (next.done)
112
+ break;
113
+ const chunk = Buffer.from(next.value);
114
+ total += chunk.length;
115
+ if (total > maxBytes) {
116
+ await reader.cancel('response size limit exceeded');
117
+ throw new Error(`response exceeds ${maxBytes} byte limit`);
118
+ }
119
+ chunks.push(chunk);
120
+ }
121
+ return Buffer.concat(chunks, total);
122
+ }
123
+ async function fetchJson(fetcher, url, maxBytes, timeoutMs) {
124
+ const contents = await fetchBuffer(fetcher, url, maxBytes, timeoutMs, 'application/json');
125
+ try {
126
+ return JSON.parse(contents.toString('utf8'));
127
+ }
128
+ catch {
129
+ throw new Error(`registry returned invalid JSON for ${new URL(url).pathname}`);
130
+ }
131
+ }
132
+ function parseVersionMetadata(packument, spec) {
133
+ const root = asRecord(packument);
134
+ const versions = asRecord(root?.versions);
135
+ const times = asRecord(root?.time);
136
+ const rawVersion = asRecord(versions?.[spec.version]);
137
+ if (rawVersion === undefined)
138
+ throw new Error(`npm version not found: ${spec.name}@${spec.version}`);
139
+ const name = asString(rawVersion.name);
140
+ const version = asString(rawVersion.version);
141
+ const dist = asRecord(rawVersion.dist);
142
+ const integrity = asString(dist?.integrity);
143
+ const tarball = asString(dist?.tarball);
144
+ if (name !== spec.name || version !== spec.version || integrity === undefined || tarball === undefined) {
145
+ throw new Error('npm registry metadata does not identify the requested exact artifact');
146
+ }
147
+ const signatures = [];
148
+ if (Array.isArray(dist?.signatures)) {
149
+ for (const rawSignature of dist.signatures) {
150
+ const signature = asRecord(rawSignature);
151
+ const keyid = asString(signature?.keyid);
152
+ const sig = asString(signature?.sig);
153
+ if (keyid !== undefined && sig !== undefined)
154
+ signatures.push({ keyid, sig });
155
+ }
156
+ }
157
+ const attestationsRecord = asRecord(dist?.attestations);
158
+ const provenanceRecord = asRecord(attestationsRecord?.provenance);
159
+ const predicateType = asString(provenanceRecord?.predicateType);
160
+ const attestationUrl = asString(attestationsRecord?.url);
161
+ const shasum = asString(dist?.shasum);
162
+ const publishedAt = asString(times?.[spec.version]);
163
+ const attestations = attestationsRecord === undefined
164
+ ? undefined
165
+ : {
166
+ ...(attestationUrl === undefined ? {} : { url: attestationUrl }),
167
+ ...(provenanceRecord === undefined
168
+ ? {}
169
+ : { provenance: predicateType === undefined ? {} : { predicateType } }),
170
+ };
171
+ return {
172
+ name,
173
+ version,
174
+ ...(publishedAt === undefined ? {} : { publishedAt }),
175
+ dist: {
176
+ integrity,
177
+ tarball,
178
+ signatures,
179
+ ...(shasum === undefined ? {} : { shasum }),
180
+ ...(attestations === undefined ? {} : { attestations }),
181
+ },
182
+ };
183
+ }
184
+ export function verifyIntegrity(contents, integrity) {
185
+ const supported = new Map([['sha512', 4], ['sha384', 3], ['sha256', 2], ['sha1', 1]]);
186
+ const candidates = integrity.trim().split(/\s+/).flatMap((token) => {
187
+ const separator = token.indexOf('-');
188
+ if (separator <= 0)
189
+ return [];
190
+ const algorithm = token.slice(0, separator).toLowerCase();
191
+ const expected = token.slice(separator + 1).split('?')[0] ?? '';
192
+ const rank = supported.get(algorithm);
193
+ return rank === undefined || expected === '' ? [] : [{ algorithm, expected, rank }];
194
+ }).sort((left, right) => right.rank - left.rank);
195
+ const selected = candidates[0];
196
+ if (selected === undefined)
197
+ throw new Error('npm integrity contains no supported digest');
198
+ const actualBuffer = createHash(selected.algorithm).update(contents).digest();
199
+ const expectedBuffer = Buffer.from(selected.expected, 'base64');
200
+ const valid = expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
201
+ return {
202
+ status: valid ? 'verified' : 'invalid',
203
+ algorithm: selected.algorithm,
204
+ expected: selected.expected,
205
+ actual: actualBuffer.toString('base64'),
206
+ };
207
+ }
208
+ function parseRegistryKeys(value) {
209
+ const root = asRecord(value);
210
+ if (!Array.isArray(root?.keys))
211
+ return [];
212
+ const keys = [];
213
+ for (const rawKey of root.keys) {
214
+ const key = asRecord(rawKey);
215
+ const keyid = asString(key?.keyid);
216
+ const keytype = asString(key?.keytype);
217
+ const scheme = asString(key?.scheme);
218
+ const material = asString(key?.key);
219
+ const expires = key?.expires === null ? null : asString(key?.expires);
220
+ if (keyid !== undefined && keytype !== undefined && scheme !== undefined && material !== undefined && expires !== undefined) {
221
+ keys.push({ keyid, keytype, scheme, key: material, expires });
222
+ }
223
+ }
224
+ return keys;
225
+ }
226
+ export function verifyRegistrySignatures(name, version, integrity, signatures, keys, publishedAt) {
227
+ if (signatures.length === 0)
228
+ return { status: 'missing', keyIds: [] };
229
+ const message = Buffer.from(`${name}@${version}:${integrity}`, 'utf8');
230
+ const keyIds = signatures.map(signature => signature.keyid);
231
+ const publishedTime = Date.parse(publishedAt ?? MISSING_PUBLISH_TIME_CUTOFF);
232
+ if (!Number.isFinite(publishedTime))
233
+ return { status: 'invalid', keyIds };
234
+ for (const signature of signatures) {
235
+ const key = keys.find(candidate => candidate.keyid === signature.keyid);
236
+ if (key === undefined || key.keytype !== 'ecdsa-sha2-nistp256' || key.scheme !== 'ecdsa-sha2-nistp256') {
237
+ return { status: 'invalid', keyIds };
238
+ }
239
+ if (key.expires !== null) {
240
+ const expiry = Date.parse(key.expires);
241
+ if (Number.isFinite(expiry) && publishedTime >= expiry)
242
+ return { status: 'invalid', keyIds };
243
+ }
244
+ try {
245
+ const der = Buffer.from(key.key, 'base64');
246
+ const publicKey = createPublicKey({ key: der, format: 'der', type: 'spki' });
247
+ if (!verifySignature('sha256', message, publicKey, Buffer.from(signature.sig, 'base64')))
248
+ return { status: 'invalid', keyIds };
249
+ }
250
+ catch {
251
+ return { status: 'invalid', keyIds };
252
+ }
253
+ }
254
+ return { status: 'verified', keyIds };
255
+ }
256
+ async function materializeTarball(archive) {
257
+ const root = await mkdtemp(join(tmpdir(), 'upstream-radar-artifact-'));
258
+ try {
259
+ for (const entry of archive.entries) {
260
+ if (entry.type !== 'file' || entry.contents === undefined)
261
+ continue;
262
+ const destination = resolve(root, entry.path);
263
+ const fromRoot = destination.slice(root.length);
264
+ if (!(destination === root || fromRoot.startsWith(sep)))
265
+ throw new Error('validated archive path escaped materialization root');
266
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
267
+ await writeFile(destination, entry.contents, { flag: 'wx', mode: 0o600 });
268
+ }
269
+ return root;
270
+ }
271
+ catch (error) {
272
+ await rm(root, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
273
+ throw error;
274
+ }
275
+ }
276
+ function safeEnvironment(root) {
277
+ const allowed = [
278
+ 'PATH', 'Path', 'SystemRoot', 'WINDIR', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL',
279
+ 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY',
280
+ ];
281
+ const environment = {};
282
+ for (const name of allowed) {
283
+ const value = process.env[name];
284
+ if (value !== undefined)
285
+ environment[name] = value;
286
+ }
287
+ environment.NPM_CONFIG_CACHE = join(root, 'npm-cache');
288
+ environment.NPM_CONFIG_USERCONFIG = join(root, 'controlled.npmrc');
289
+ environment.NPM_CONFIG_GLOBALCONFIG = join(root, 'controlled-global.npmrc');
290
+ environment.NPM_CONFIG_IGNORE_SCRIPTS = 'true';
291
+ environment.NPM_CONFIG_AUDIT = 'false';
292
+ environment.NPM_CONFIG_FUND = 'false';
293
+ environment.NPM_CONFIG_UPDATE_NOTIFIER = 'false';
294
+ environment.GIT_CONFIG_NOSYSTEM = '1';
295
+ environment.GIT_CONFIG_GLOBAL = join(root, 'controlled.gitconfig');
296
+ environment.GIT_TERMINAL_PROMPT = '0';
297
+ return environment;
298
+ }
299
+ function runProcess(command, args, cwd, environment, timeoutMs) {
300
+ return new Promise((resolveResult) => {
301
+ const detached = process.platform !== 'win32';
302
+ const child = spawn(command, args, { cwd, env: environment, stdio: ['ignore', 'pipe', 'pipe'], shell: false, detached });
303
+ const stdout = [];
304
+ const stderr = [];
305
+ let stdoutBytes = 0;
306
+ let stderrBytes = 0;
307
+ let outputExceeded = false;
308
+ let timedOut = false;
309
+ let settled = false;
310
+ let timer;
311
+ const terminate = () => {
312
+ try {
313
+ if (detached && child.pid !== undefined)
314
+ process.kill(-child.pid, 'SIGKILL');
315
+ else
316
+ child.kill('SIGKILL');
317
+ }
318
+ catch {
319
+ child.kill('SIGKILL');
320
+ }
321
+ };
322
+ const finish = (code) => {
323
+ if (settled)
324
+ return;
325
+ settled = true;
326
+ if (timer !== undefined)
327
+ clearTimeout(timer);
328
+ resolveResult({
329
+ code,
330
+ stdout: Buffer.concat(stdout, stdoutBytes).toString('utf8'),
331
+ stderr: Buffer.concat(stderr, stderrBytes).toString('utf8'),
332
+ timedOut,
333
+ outputExceeded,
334
+ });
335
+ };
336
+ const collect = (target, chunk, stream) => {
337
+ if (stdoutBytes + stderrBytes + chunk.length > MAX_PROCESS_OUTPUT_BYTES) {
338
+ outputExceeded = true;
339
+ terminate();
340
+ return;
341
+ }
342
+ if (stream === 'stdout')
343
+ stdoutBytes += chunk.length;
344
+ else
345
+ stderrBytes += chunk.length;
346
+ target.push(chunk);
347
+ };
348
+ child.stdout.on('data', (chunk) => collect(stdout, chunk, 'stdout'));
349
+ child.stderr.on('data', (chunk) => collect(stderr, chunk, 'stderr'));
350
+ child.once('error', () => finish(127));
351
+ child.once('close', code => finish(code ?? 1));
352
+ timer = setTimeout(() => {
353
+ timedOut = true;
354
+ terminate();
355
+ }, timeoutMs);
356
+ });
357
+ }
358
+ function parseJsonOutput(output) {
359
+ const trimmed = output.trim();
360
+ if (trimmed === '')
361
+ return undefined;
362
+ try {
363
+ return JSON.parse(trimmed);
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ }
369
+ function packageLabels(value) {
370
+ if (!Array.isArray(value))
371
+ return [];
372
+ return value.slice(0, 100).map((entry) => {
373
+ if (typeof entry === 'string')
374
+ return entry;
375
+ const record = asRecord(entry);
376
+ const name = asString(record?.name) ?? '(unknown)';
377
+ const version = asString(record?.version);
378
+ return version === undefined ? name : `${name}@${version}`;
379
+ });
380
+ }
381
+ function vulnerabilitySummary(audit) {
382
+ const root = asRecord(audit);
383
+ const metadata = asRecord(root?.metadata);
384
+ const raw = asRecord(metadata?.vulnerabilities);
385
+ if (raw === undefined)
386
+ return null;
387
+ const summary = {
388
+ info: asNumber(raw.info) ?? 0,
389
+ low: asNumber(raw.low) ?? 0,
390
+ moderate: asNumber(raw.moderate) ?? 0,
391
+ high: asNumber(raw.high) ?? 0,
392
+ critical: asNumber(raw.critical) ?? 0,
393
+ total: asNumber(raw.total) ?? 0,
394
+ };
395
+ return summary;
396
+ }
397
+ function extractProvenance(signaturesAudit, spec, declared, verificationSucceeded) {
398
+ if (!declared)
399
+ return { status: 'missing' };
400
+ const root = asRecord(signaturesAudit);
401
+ const invalid = packageLabels(root?.invalid);
402
+ if (invalid.includes(`${spec.name}@${spec.version}`) || invalid.includes(spec.name))
403
+ return { status: 'invalid' };
404
+ if (!verificationSucceeded)
405
+ return { status: 'failed' };
406
+ if (!Array.isArray(root?.verified))
407
+ return { status: 'verified' };
408
+ const target = root.verified.map(asRecord).find((entry) => (asString(entry?.name) === spec.name && asString(entry?.version) === spec.version));
409
+ if (target === undefined || !Array.isArray(target.attestationBundles))
410
+ return { status: 'failed' };
411
+ const slsa = target.attestationBundles.map(asRecord).find(entry => asString(entry?.predicateType) === 'https://slsa.dev/provenance/v1');
412
+ const bundle = asRecord(slsa?.bundle);
413
+ const envelope = asRecord(bundle?.dsseEnvelope);
414
+ const payload = asString(envelope?.payload);
415
+ if (payload === undefined)
416
+ return { status: 'failed' };
417
+ try {
418
+ const statement = asRecord(JSON.parse(Buffer.from(payload, 'base64').toString('utf8')));
419
+ const predicate = asRecord(statement?.predicate);
420
+ const buildDefinition = asRecord(predicate?.buildDefinition);
421
+ const externalParameters = asRecord(buildDefinition?.externalParameters);
422
+ const workflow = asRecord(externalParameters?.workflow);
423
+ const runDetails = asRecord(predicate?.runDetails);
424
+ const builder = asRecord(runDetails?.builder);
425
+ const dependencies = Array.isArray(buildDefinition?.resolvedDependencies) ? buildDefinition.resolvedDependencies : [];
426
+ const source = dependencies.map(asRecord).find(item => asRecord(item?.digest)?.gitCommit !== undefined);
427
+ const sourceDigest = asRecord(source?.digest);
428
+ const sourceRepository = asString(workflow?.repository);
429
+ const sourceRef = asString(workflow?.ref);
430
+ const workflowPath = asString(workflow?.path);
431
+ const sourceCommit = asString(sourceDigest?.gitCommit);
432
+ const builderId = asString(builder?.id);
433
+ return {
434
+ status: 'verified',
435
+ predicateType: 'https://slsa.dev/provenance/v1',
436
+ ...(sourceRepository === undefined ? {} : { sourceRepository }),
437
+ ...(sourceRef === undefined ? {} : { sourceRef }),
438
+ ...(sourceCommit === undefined ? {} : { sourceCommit }),
439
+ ...(workflowPath === undefined ? {} : { workflow: workflowPath }),
440
+ ...(builderId === undefined ? {} : { builder: builderId }),
441
+ };
442
+ }
443
+ catch {
444
+ return { status: 'failed' };
445
+ }
446
+ }
447
+ async function deepAuditNpmPackage(spec, registry, provenanceDeclared, timeoutMs) {
448
+ const root = await mkdtemp(join(tmpdir(), 'upstream-radar-npm-audit-'));
449
+ const failed = (error) => ({
450
+ dependencyAudit: {
451
+ status: 'failed',
452
+ packages: null,
453
+ invalidSignatures: [],
454
+ missingSignatures: [],
455
+ vulnerabilities: null,
456
+ error,
457
+ },
458
+ provenance: { status: provenanceDeclared ? 'failed' : 'missing' },
459
+ });
460
+ try {
461
+ await writeFile(join(root, 'package.json'), `${JSON.stringify({
462
+ name: 'upstream-radar-quarantine',
463
+ version: '0.0.0',
464
+ private: true,
465
+ dependencies: { [spec.name]: spec.version },
466
+ }, null, 2)}\n`, { mode: 0o600 });
467
+ await writeFile(join(root, 'controlled.npmrc'), `registry=${registry}\nignore-scripts=true\naudit=false\nfund=false\nupdate-notifier=false\n`, { mode: 0o600 });
468
+ await writeFile(join(root, 'controlled-global.npmrc'), '', { mode: 0o600 });
469
+ await writeFile(join(root, 'controlled.gitconfig'), '', { mode: 0o600 });
470
+ const environment = safeEnvironment(root);
471
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
472
+ const install = await runProcess(npm, [
473
+ 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--save-exact', '--loglevel=error',
474
+ '--registry', registry,
475
+ ], root, environment, timeoutMs);
476
+ if (install.code !== 0 || install.timedOut || install.outputExceeded) {
477
+ const reason = install.timedOut
478
+ ? 'npm dependency resolution timed out'
479
+ : install.outputExceeded
480
+ ? 'npm dependency resolution exceeded output budget'
481
+ : `npm dependency resolution failed with exit code ${install.code}`;
482
+ return failed(reason);
483
+ }
484
+ const lockfile = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'));
485
+ const dependencyGraph = parseNpmLockGraph(lockfile, spec);
486
+ const graphDigest = dependencyGraph.digest;
487
+ if (graphDigest === undefined)
488
+ throw new Error('resolved dependency graph has no digest');
489
+ let signatures = await runProcess(npm, [
490
+ 'audit', 'signatures', '--json', '--include-attestations', '--registry', registry,
491
+ ], root, environment, timeoutMs);
492
+ let signaturesJson = parseJsonOutput(signatures.stdout);
493
+ let signaturesRoot = asRecord(signaturesJson);
494
+ const firstAttemptHasResults = Array.isArray(signaturesRoot?.verified)
495
+ || Array.isArray(signaturesRoot?.invalid)
496
+ || Array.isArray(signaturesRoot?.missing);
497
+ if (signaturesJson === undefined || (signatures.code !== 0 && !firstAttemptHasResults)) {
498
+ signatures = await runProcess(npm, ['audit', 'signatures', '--json', '--registry', registry], root, environment, timeoutMs);
499
+ signaturesJson = parseJsonOutput(signatures.stdout);
500
+ signaturesRoot = asRecord(signaturesJson);
501
+ }
502
+ if (signaturesJson === undefined || signatures.timedOut || signatures.outputExceeded) {
503
+ return failed('npm signature/provenance verification did not return bounded JSON');
504
+ }
505
+ const invalidSignatures = packageLabels(signaturesRoot?.invalid);
506
+ const missingSignatures = packageLabels(signaturesRoot?.missing);
507
+ const provenance = extractProvenance(signaturesJson, spec, provenanceDeclared, signatures.code === 0);
508
+ if (signatures.code !== 0 && invalidSignatures.length === 0 && missingSignatures.length === 0) {
509
+ return {
510
+ dependencyAudit: {
511
+ status: 'failed',
512
+ packages: dependencyGraph.nodes.length,
513
+ graphDigest,
514
+ graph: dependencyGraph,
515
+ invalidSignatures,
516
+ missingSignatures,
517
+ vulnerabilities: null,
518
+ error: `npm signature verification failed with exit code ${signatures.code}`,
519
+ },
520
+ provenance,
521
+ };
522
+ }
523
+ const audit = await runProcess(npm, ['audit', '--json', '--registry', registry], root, environment, timeoutMs);
524
+ const auditJson = parseJsonOutput(audit.stdout);
525
+ if (auditJson === undefined || audit.timedOut || audit.outputExceeded) {
526
+ return {
527
+ dependencyAudit: {
528
+ status: 'failed',
529
+ packages: dependencyGraph.nodes.length,
530
+ graphDigest,
531
+ graph: dependencyGraph,
532
+ invalidSignatures,
533
+ missingSignatures,
534
+ vulnerabilities: null,
535
+ error: 'npm vulnerability audit did not return bounded JSON',
536
+ },
537
+ provenance,
538
+ };
539
+ }
540
+ const vulnerabilities = vulnerabilitySummary(auditJson);
541
+ if (vulnerabilities === null) {
542
+ return {
543
+ dependencyAudit: {
544
+ status: 'failed',
545
+ packages: dependencyGraph.nodes.length,
546
+ graphDigest,
547
+ graph: dependencyGraph,
548
+ invalidSignatures,
549
+ missingSignatures,
550
+ vulnerabilities: null,
551
+ error: 'npm vulnerability audit returned JSON without a vulnerability summary',
552
+ },
553
+ provenance,
554
+ };
555
+ }
556
+ const status = invalidSignatures.length > 0 || missingSignatures.length > 0 || (vulnerabilities?.total ?? 0) > 0
557
+ ? 'findings'
558
+ : signatures.code === 0 ? 'verified' : 'findings';
559
+ return {
560
+ dependencyAudit: {
561
+ status,
562
+ packages: dependencyGraph.nodes.length,
563
+ graphDigest,
564
+ graph: dependencyGraph,
565
+ invalidSignatures,
566
+ missingSignatures,
567
+ vulnerabilities,
568
+ },
569
+ provenance,
570
+ };
571
+ }
572
+ catch {
573
+ return failed('deep npm audit failed before producing complete evidence');
574
+ }
575
+ finally {
576
+ await rm(root, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
577
+ }
578
+ }
579
+ function emptyDependencyAudit() {
580
+ return {
581
+ status: 'not-run',
582
+ packages: null,
583
+ invalidSignatures: [],
584
+ missingSignatures: [],
585
+ vulnerabilities: null,
586
+ };
587
+ }
588
+ function failedArtifactReport(spec, artifactDigest, npmEvidence, findings) {
589
+ sortFindings(findings);
590
+ const riskVerdict = decideVerdict(findings);
591
+ return {
592
+ schema: REPORT_SCHEMA,
593
+ tool: { name: 'upstream-radar', version: TOOL_VERSION },
594
+ target: { kind: 'npm', name: spec.name, version: spec.version, artifactDigest, spec: spec.canonical },
595
+ dsh: { isBundle: false },
596
+ evidence: {
597
+ filesScanned: 0,
598
+ bytesHashed: 0,
599
+ lockfiles: [],
600
+ packageManager: null,
601
+ lifecycleScripts: [],
602
+ dependencies: [],
603
+ npm: npmEvidence,
604
+ },
605
+ coverage: {
606
+ staticSource: 'incomplete',
607
+ artifactIntegrity: npmEvidence.integrity.status,
608
+ registrySignature: npmEvidence.registrySignature.status,
609
+ dependencyResolution: 'manifest-only',
610
+ provenance: npmEvidence.provenance.status,
611
+ sourceArtifactMatch: 'not-checked',
612
+ sandboxDetonation: 'not-run',
613
+ },
614
+ findings,
615
+ riskVerdict,
616
+ coverageVerdict: 'incomplete',
617
+ verdict: stricterVerdict(riskVerdict, 'review'),
618
+ };
619
+ }
620
+ function addNpmEvidenceFindings(evidence, findings) {
621
+ if (evidence.registrySignature.status === 'missing') {
622
+ findings.push(makeFinding('npm-registry-signature-missing', 'high', 'npm artifact has no registry signature', 'The exact package integrity cannot be authenticated against a registry signing key.'));
623
+ }
624
+ else if (evidence.registrySignature.status === 'invalid') {
625
+ findings.push(makeFinding('npm-registry-signature-invalid', 'critical', 'npm registry signature is invalid', 'The registry signature does not verify for the requested package, version and integrity string.'));
626
+ }
627
+ else if (evidence.registrySignature.status === 'failed') {
628
+ findings.push(makeFinding('npm-registry-signature-check-failed', 'high', 'npm registry signature could not be checked', 'The registry signing keys were unavailable or could not be parsed, so authenticity remains unverified.'));
629
+ }
630
+ if (evidence.provenance.status === 'missing') {
631
+ findings.push(makeFinding('npm-provenance-missing', 'medium', 'npm artifact has no build provenance', 'The published bytes are not cryptographically linked to a declared source commit and build workflow.'));
632
+ }
633
+ else if (evidence.provenance.status === 'invalid') {
634
+ findings.push(makeFinding('npm-provenance-invalid', 'critical', 'npm provenance verification failed', 'The provenance attestation is invalid for the requested package artifact.'));
635
+ }
636
+ else if (evidence.provenance.status === 'failed') {
637
+ findings.push(makeFinding('npm-provenance-check-failed', 'high', 'npm provenance could not be verified', 'The declared provenance was not converted into verified source and build evidence.'));
638
+ }
639
+ else if (evidence.provenance.status === 'present-unverified') {
640
+ findings.push(makeFinding('npm-provenance-not-checked', 'medium', 'npm provenance is present but was not verified', 'Run deep inspection before relying on the package\'s declared source and build identity.'));
641
+ }
642
+ if (evidence.dependencyAudit.status === 'failed') {
643
+ findings.push(makeFinding('dependency-audit-failed', 'high', 'Resolved dependency audit did not complete', evidence.dependencyAudit.error ?? 'The dependency graph lacks complete signature and vulnerability evidence.'));
644
+ }
645
+ if (evidence.dependencyAudit.invalidSignatures.length > 0) {
646
+ findings.push(makeFinding('dependency-signature-invalid', 'critical', 'Resolved dependencies include invalid signatures', 'At least one dependency failed npm signature or attestation verification.', { packages: evidence.dependencyAudit.invalidSignatures }));
647
+ }
648
+ if (evidence.dependencyAudit.missingSignatures.length > 0) {
649
+ findings.push(makeFinding('dependency-signature-missing', 'high', 'Resolved dependencies include unsigned packages', 'At least one dependency has no verifiable registry signature.', { packages: evidence.dependencyAudit.missingSignatures }));
650
+ }
651
+ const vulnerabilities = evidence.dependencyAudit.vulnerabilities;
652
+ if (vulnerabilities !== null && vulnerabilities.total > 0) {
653
+ const severity = vulnerabilities.critical > 0
654
+ ? 'critical'
655
+ : vulnerabilities.high > 0
656
+ ? 'high'
657
+ : 'medium';
658
+ findings.push(makeFinding('dependency-known-vulnerabilities', severity, 'Resolved dependency graph contains known vulnerabilities', 'Registry advisory data reports vulnerabilities in the exact graph resolved for this review.', {
659
+ total: vulnerabilities.total,
660
+ critical: vulnerabilities.critical,
661
+ high: vulnerabilities.high,
662
+ moderate: vulnerabilities.moderate,
663
+ low: vulnerabilities.low,
664
+ }));
665
+ }
666
+ }
667
+ export async function inspectNpmPackage(input, options = {}) {
668
+ const spec = parseNpmSpec(input);
669
+ const registry = normalizeRegistry(options.registry ?? DEFAULT_REGISTRY);
670
+ const fetcher = options.fetch ?? globalThis.fetch;
671
+ const timeoutMs = options.timeoutMs ?? 30_000;
672
+ const packagePath = encodeURIComponent(spec.name);
673
+ const packument = await fetchJson(fetcher, new URL(packagePath, registry).toString(), MAX_PACKUMENT_BYTES, timeoutMs);
674
+ const metadata = parseVersionMetadata(packument, spec);
675
+ const tarball = await fetchBuffer(fetcher, metadata.dist.tarball, MAX_TARBALL_BYTES, timeoutMs, 'application/octet-stream');
676
+ const integrity = verifyIntegrity(tarball, metadata.dist.integrity);
677
+ const artifactDigest = `sha256:${createHash('sha256').update(tarball).digest('hex')}`;
678
+ let registrySignature = { status: 'not-checked', keyIds: [] };
679
+ try {
680
+ const keysJson = await fetchJson(fetcher, new URL('-/npm/v1/keys', registry).toString(), 1024 * 1024, timeoutMs);
681
+ registrySignature = verifyRegistrySignatures(spec.name, spec.version, metadata.dist.integrity, metadata.dist.signatures, parseRegistryKeys(keysJson), metadata.publishedAt);
682
+ }
683
+ catch {
684
+ registrySignature = { status: 'failed', keyIds: metadata.dist.signatures.map(signature => signature.keyid) };
685
+ }
686
+ const provenanceDeclared = metadata.dist.attestations?.provenance !== undefined;
687
+ const initialProvenance = provenanceDeclared
688
+ ? {
689
+ status: 'present-unverified',
690
+ ...(metadata.dist.attestations?.provenance?.predicateType === undefined
691
+ ? {}
692
+ : { predicateType: metadata.dist.attestations.provenance.predicateType }),
693
+ }
694
+ : { status: 'missing' };
695
+ const baseNpmEvidence = {
696
+ registry,
697
+ tarball: publicUrl(metadata.dist.tarball),
698
+ compressedBytes: tarball.length,
699
+ unpackedBytes: 0,
700
+ integrity,
701
+ registrySignature,
702
+ provenance: initialProvenance,
703
+ dependencyAudit: emptyDependencyAudit(),
704
+ };
705
+ const earlyFindings = [];
706
+ if (integrity.status === 'invalid') {
707
+ earlyFindings.push(makeFinding('npm-integrity-mismatch', 'critical', 'Downloaded npm tarball does not match registry integrity', 'The bytes fetched for this exact version differ from the digest declared by the registry.', { algorithm: integrity.algorithm, expected: integrity.expected, actual: integrity.actual }));
708
+ addNpmEvidenceFindings(baseNpmEvidence, earlyFindings);
709
+ return failedArtifactReport(spec, artifactDigest, baseNpmEvidence, earlyFindings);
710
+ }
711
+ if (metadata.dist.shasum !== undefined) {
712
+ const actualShasum = createHash('sha1').update(tarball).digest('hex');
713
+ if (actualShasum !== metadata.dist.shasum) {
714
+ earlyFindings.push(makeFinding('npm-shasum-mismatch', 'critical', 'Downloaded npm tarball does not match registry shasum', 'The legacy SHA-1 registry checksum disagrees with the downloaded artifact.', { expected: metadata.dist.shasum, actual: actualShasum }));
715
+ }
716
+ }
717
+ let archive;
718
+ try {
719
+ archive = parseNpmTarball(tarball);
720
+ }
721
+ catch (error) {
722
+ earlyFindings.push(makeFinding('npm-archive-invalid', 'critical', 'npm artifact cannot be parsed safely', error instanceof Error ? error.message : String(error)));
723
+ addNpmEvidenceFindings(baseNpmEvidence, earlyFindings);
724
+ return failedArtifactReport(spec, artifactDigest, baseNpmEvidence, earlyFindings);
725
+ }
726
+ baseNpmEvidence.unpackedBytes = archive.unpackedBytes;
727
+ if (archive.findings.some(finding => finding.severity === 'critical')) {
728
+ const findings = [...earlyFindings, ...archive.findings];
729
+ addNpmEvidenceFindings(baseNpmEvidence, findings);
730
+ return failedArtifactReport(spec, artifactDigest, baseNpmEvidence, findings);
731
+ }
732
+ let deep;
733
+ if (options.deep === true) {
734
+ deep = await deepAuditNpmPackage(spec, registry, provenanceDeclared, Math.max(timeoutMs, 60_000));
735
+ baseNpmEvidence.dependencyAudit = deep.dependencyAudit;
736
+ baseNpmEvidence.provenance = deep.provenance;
737
+ }
738
+ let materialized;
739
+ try {
740
+ materialized = await materializeTarball(archive);
741
+ }
742
+ catch {
743
+ const findings = [
744
+ ...earlyFindings,
745
+ ...archive.findings,
746
+ makeFinding('npm-static-scan-materialization-failed', 'high', 'Published artifact could not be materialized for static inspection', 'The scanner failed closed without executing or trusting the package contents.'),
747
+ ];
748
+ addNpmEvidenceFindings(baseNpmEvidence, findings);
749
+ return failedArtifactReport(spec, artifactDigest, baseNpmEvidence, findings);
750
+ }
751
+ try {
752
+ let report;
753
+ try {
754
+ report = await scanDirectory(materialized, {
755
+ dependencyGraphResolved: deep?.dependencyAudit.status === 'verified' || deep?.dependencyAudit.status === 'findings',
756
+ });
757
+ }
758
+ catch {
759
+ const findings = [
760
+ ...earlyFindings,
761
+ ...archive.findings,
762
+ makeFinding('npm-package-manifest-invalid', 'critical', 'Published npm artifact has no usable package.json', 'The tarball package.json is missing, not a regular file, too large, or invalid JSON.'),
763
+ ];
764
+ addNpmEvidenceFindings(baseNpmEvidence, findings);
765
+ return failedArtifactReport(spec, artifactDigest, baseNpmEvidence, findings);
766
+ }
767
+ const packagedName = report.target.name;
768
+ const packagedVersion = report.target.version;
769
+ report.tool.version = TOOL_VERSION;
770
+ report.target = {
771
+ kind: 'npm',
772
+ name: spec.name,
773
+ version: spec.version,
774
+ artifactDigest,
775
+ treeDigest: archive.treeDigest,
776
+ spec: spec.canonical,
777
+ };
778
+ report.evidence.npm = baseNpmEvidence;
779
+ report.coverage.artifactIntegrity = integrity.status;
780
+ report.coverage.registrySignature = registrySignature.status;
781
+ report.coverage.dependencyResolution = deep?.dependencyAudit.status === 'verified' || deep?.dependencyAudit.status === 'findings'
782
+ ? 'resolved'
783
+ : 'manifest-only';
784
+ report.coverage.provenance = baseNpmEvidence.provenance.status;
785
+ report.findings.push(...earlyFindings, ...archive.findings);
786
+ if (packagedName !== spec.name || packagedVersion !== spec.version) {
787
+ report.findings.push(makeFinding('npm-manifest-identity-mismatch', 'critical', 'Published package manifest identity differs from registry metadata', 'The package.json inside the tarball does not match the exact name and version requested from the registry.', {
788
+ requested: `${spec.name}@${spec.version}`,
789
+ packaged: `${packagedName}@${packagedVersion ?? '(missing)'}`,
790
+ }));
791
+ }
792
+ addNpmEvidenceFindings(baseNpmEvidence, report.findings);
793
+ sortFindings(report.findings);
794
+ report.riskVerdict = decideVerdict(report.findings);
795
+ report.coverageVerdict = 'incomplete';
796
+ report.verdict = stricterVerdict(report.riskVerdict, 'review');
797
+ return report;
798
+ }
799
+ finally {
800
+ await rm(materialized, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
801
+ }
802
+ }
803
+ //# sourceMappingURL=npm.js.map