supply-chain-attack 0.1.1 → 0.1.7

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/scanner.js ADDED
@@ -0,0 +1,808 @@
1
+ 'use strict';
2
+
3
+ const childProcess = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { flattenAdvisories, SNAPSHOT_DATE } = require('./advisories');
8
+ const { queryOsv } = require('./live-osv');
9
+ const {
10
+ parsePackageLock,
11
+ parseShrinkwrap,
12
+ parsePackageJson,
13
+ parseYarnLock,
14
+ parsePnpmLock,
15
+ binsFromPackageJson,
16
+ readJson,
17
+ } = require('./parsers');
18
+
19
+ const OFFLINE_ROWS = flattenAdvisories();
20
+ const OFFLINE_INDEX = buildOfflineIndex(OFFLINE_ROWS);
21
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
22
+ const MANIFEST_PROBE_BYTES = 64 * 1024;
23
+ const SEMVERISH = '\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?';
24
+ const SEMVERISH_RE = new RegExp(`^${SEMVERISH}$`);
25
+
26
+ const INSTALL_FILE_PARSERS = [
27
+ parsePackageLock,
28
+ parseShrinkwrap,
29
+ parsePackageJson,
30
+ parseYarnLock,
31
+ parsePnpmLock,
32
+ ];
33
+
34
+ const LOCATION_SCANNERS = {
35
+ node_modules: scanNodeModulesLocation,
36
+ 'npm-cache': scanNpmCacheLocation,
37
+ 'install-root': scanInstallRootLocation,
38
+ 'manifest-store': scanManifestStoreLocation,
39
+ 'yarn-cache': scanYarnCacheLocation,
40
+ 'bun-cache': scanBunCacheLocation,
41
+ 'python-site': scanPythonSiteLocation,
42
+ 'python-venvs': scanPythonVenvsLocation,
43
+ };
44
+
45
+ const WALK_SKIP_DIRECTORIES = new Set(['.git', '.hg', '.svn', '.DS_Store', '.bin']);
46
+
47
+ const IOC_MARKERS = [
48
+ 'trufflehog',
49
+ 'github_token',
50
+ 'npm_token',
51
+ 'router_runtime',
52
+ 'setup.mjs',
53
+ 'claude',
54
+ 'exfiltrat',
55
+ 'process.env',
56
+ ];
57
+
58
+ const IOC_CANDIDATES = [
59
+ '.claude/router_runtime.js',
60
+ '.claude/setup.mjs',
61
+ '.vscode/setup.mjs',
62
+ 'router_runtime.js',
63
+ 'setup.mjs',
64
+ 'bun_environment.js',
65
+ '.github/workflows/codeql_analysis.yml',
66
+ ];
67
+
68
+ async function scanMachine(options = {}) {
69
+ const locations = options.locations || discoverMachineLocations();
70
+ const packages = dedupePackages(collectMachinePackages(locations, options));
71
+ const binaryMap = collectPackageBinaryMap(packages);
72
+ const findings = matchOffline(packages, binaryMap);
73
+ const iocs = scanMachineIocs();
74
+
75
+ let live = { enabled: false, findings: [], error: null };
76
+ if (options.live) {
77
+ live = await queryOsv(packages, { timeoutMs: options.liveTimeoutMs });
78
+ mergeLiveFindings(findings, live.findings || [], binaryMap);
79
+ }
80
+
81
+ return {
82
+ machine: os.hostname() || 'local machine',
83
+ home: os.homedir(),
84
+ locations,
85
+ packages,
86
+ findings: dedupeFindings(findings),
87
+ iocs,
88
+ live,
89
+ snapshotDate: SNAPSHOT_DATE,
90
+ advisoryArtifactCount: OFFLINE_ROWS.length,
91
+ };
92
+ }
93
+
94
+ function discoverMachineLocations() {
95
+ const home = os.homedir();
96
+ return dedupeLocations([
97
+ location('npm global', 'node_modules', commandOutput('npm', ['root', '-g'])),
98
+ ...locations('npm cache', 'npm-cache', [
99
+ process.env.npm_config_cache,
100
+ commandOutput('npm', ['config', 'get', 'cache']),
101
+ home && path.join(home, '.npm'),
102
+ ]),
103
+ location('pnpm global', 'node_modules', commandOutput('pnpm', ['root', '-g'])),
104
+ ...locations('pnpm store', 'manifest-store', [
105
+ process.env.PNPM_STORE_PATH,
106
+ commandOutput('pnpm', ['store', 'path']),
107
+ home && path.join(home, 'Library', 'pnpm', 'store'),
108
+ home && path.join(home, '.local', 'share', 'pnpm', 'store'),
109
+ home && path.join(home, '.pnpm-store'),
110
+ ]),
111
+ location('yarn global', 'install-root', commandOutput('yarn', ['global', 'dir'])),
112
+ ...locations('yarn cache', 'yarn-cache', [
113
+ commandOutput('yarn', ['cache', 'dir']),
114
+ home && path.join(home, 'Library', 'Caches', 'Yarn'),
115
+ home && path.join(home, '.cache', 'yarn'),
116
+ ]),
117
+ location('bun global', 'node_modules', home && path.join(home, '.bun', 'install', 'global', 'node_modules')),
118
+ location('bun cache', 'bun-cache', home && path.join(home, '.bun', 'install', 'cache')),
119
+ ...locations('python user site', 'python-site', [
120
+ commandOutput('python3', ['-m', 'site', '--user-site']),
121
+ commandOutput('python', ['-m', 'site', '--user-site']),
122
+ ]),
123
+ location('pipx venvs', 'python-venvs', home && path.join(home, '.local', 'pipx', 'venvs')),
124
+ ]);
125
+ }
126
+
127
+ function collectMachinePackages(locations, options = {}) {
128
+ return locations.flatMap((item) => {
129
+ const scanner = LOCATION_SCANNERS[item.kind];
130
+ return scanner ? scanner(item, options) : [];
131
+ });
132
+ }
133
+
134
+ function scanNodeModulesLocation(locationInfo) {
135
+ return collectNodeModulesPackages(locationInfo.path, locationInfo.label);
136
+ }
137
+
138
+ function scanNpmCacheLocation(locationInfo) {
139
+ return [
140
+ ...collectNpmCacheIndexPackages(locationInfo.path, locationInfo.label),
141
+ ...collectNpxCachePackages(locationInfo.path, locationInfo.label),
142
+ ];
143
+ }
144
+
145
+ function scanInstallRootLocation(locationInfo) {
146
+ return collectInstallRootPackages(locationInfo.path, locationInfo.label);
147
+ }
148
+
149
+ function scanManifestStoreLocation(locationInfo, options = {}) {
150
+ const pnpmIndexPackages = collectPnpmStoreIndexPackages(locationInfo.path, locationInfo.label);
151
+ if (pnpmIndexPackages) return pnpmIndexPackages;
152
+
153
+ return collectManifestPackages(locationInfo.path, locationInfo.label, {
154
+ maxBytes: options.maxManifestBytes || MAX_MANIFEST_BYTES,
155
+ });
156
+ }
157
+
158
+ function scanYarnCacheLocation(locationInfo, options = {}) {
159
+ return [
160
+ ...collectManifestPackages(locationInfo.path, locationInfo.label, {
161
+ onlyPackageJson: true,
162
+ maxBytes: options.maxManifestBytes || MAX_MANIFEST_BYTES,
163
+ }),
164
+ ...collectYarnBerryZipPackages(locationInfo.path, locationInfo.label),
165
+ ];
166
+ }
167
+
168
+ function scanBunCacheLocation(locationInfo) {
169
+ return collectBunCacheNamePackages(locationInfo.path, locationInfo.label);
170
+ }
171
+
172
+ function scanPythonSiteLocation(locationInfo) {
173
+ return collectPythonSitePackages(locationInfo.path, locationInfo.label);
174
+ }
175
+
176
+ function scanPythonVenvsLocation(locationInfo) {
177
+ const packages = [];
178
+ for (const venv of safeReaddirEntries(locationInfo.path)) {
179
+ if (!venv.isDirectory()) continue;
180
+ packages.push(...collectPythonVenvPackages(path.join(locationInfo.path, venv.name), locationInfo.label));
181
+ }
182
+ return packages;
183
+ }
184
+
185
+ function collectInstallRootPackages(root, label) {
186
+ const parsed = INSTALL_FILE_PARSERS.flatMap((parser) => parser(root)).map((item) => ({
187
+ ...item,
188
+ source: item.source ? displayPath(path.join(root, item.source)) : displayPath(root),
189
+ location: label,
190
+ }));
191
+ return [
192
+ ...parsed,
193
+ ...collectNodeModulesPackages(path.join(root, 'node_modules'), label),
194
+ ];
195
+ }
196
+
197
+ function collectNodeModulesPackages(modulesRoot, label) {
198
+ const packages = [];
199
+ for (const packageJson of listNodePackageJsonsDeep(modulesRoot)) {
200
+ const info = readJson(packageJson);
201
+ if (!isNpmManifest(info)) continue;
202
+ packages.push(machinePackage('npm', info.name, info.version, label, displayPath(packageJson), {
203
+ bins: binsFromPackageJson(info),
204
+ packageJson: info,
205
+ }));
206
+ }
207
+ return packages;
208
+ }
209
+
210
+ function collectNpxCachePackages(cacheRoot, label) {
211
+ const npxRoot = path.join(cacheRoot, '_npx');
212
+ if (!safeStat(npxRoot).isDirectory()) return [];
213
+
214
+ const packages = [];
215
+ for (const entry of safeReaddirEntries(npxRoot)) {
216
+ if (!entry.isDirectory()) continue;
217
+ packages.push(...collectInstallRootPackages(path.join(npxRoot, entry.name), `${label} _npx`));
218
+ }
219
+ return packages;
220
+ }
221
+
222
+ function collectNpmCacheIndexPackages(cacheRoot, label) {
223
+ const cacacheRoot = path.join(cacheRoot, '_cacache');
224
+ if (!safeStat(cacacheRoot).isDirectory()) return [];
225
+
226
+ const packages = [];
227
+ for (const entry of safeReaddirEntries(cacacheRoot)) {
228
+ if (!entry.isDirectory() || !/^index-v\d+$/i.test(entry.name)) continue;
229
+ walkFiles(path.join(cacacheRoot, entry.name), (filePath) => {
230
+ for (const line of safeReadText(filePath, 512 * 1024).split(/\r?\n/)) {
231
+ const parsed = npmCacheLinePackage(line);
232
+ if (parsed) packages.push(machinePackage('npm', parsed.name, parsed.version, label, parsed.source));
233
+ }
234
+ });
235
+ }
236
+ return packages;
237
+ }
238
+
239
+ function collectPnpmStoreIndexPackages(storeRoot, label) {
240
+ const indexRoots = [];
241
+ const directIndex = path.join(storeRoot, 'index');
242
+ if (safeStat(directIndex).isDirectory()) indexRoots.push(directIndex);
243
+
244
+ for (const entry of safeReaddirEntries(storeRoot)) {
245
+ if (!entry.isDirectory() || !/^v\d+$/i.test(entry.name)) continue;
246
+ const indexRoot = path.join(storeRoot, entry.name, 'index');
247
+ if (safeStat(indexRoot).isDirectory()) indexRoots.push(indexRoot);
248
+ }
249
+
250
+ if (!indexRoots.length) return null;
251
+
252
+ const packages = [];
253
+ for (const indexRoot of indexRoots) {
254
+ for (const shard of safeReaddirEntries(indexRoot)) {
255
+ if (!shard.isDirectory()) continue;
256
+ const shardPath = path.join(indexRoot, shard.name);
257
+ for (const entry of safeReaddirEntries(shardPath)) {
258
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
259
+ const parsed = pnpmPackageFromIndexName(entry.name);
260
+ if (parsed) {
261
+ packages.push(machinePackage('npm', parsed.name, parsed.version, label, displayPath(path.join(shardPath, entry.name))));
262
+ }
263
+ }
264
+ }
265
+ }
266
+ return packages;
267
+ }
268
+
269
+ function pnpmPackageFromIndexName(fileName) {
270
+ const match = fileName.match(/^[a-f0-9]+-(.+)\.json$/i);
271
+ return match ? npmPackageFromNameVersion(match[1]) : null;
272
+ }
273
+
274
+ function npmCacheLinePackage(line) {
275
+ const tab = line.indexOf('\t');
276
+ if (tab === -1) return null;
277
+
278
+ const record = safeJsonParse(line.slice(tab + 1));
279
+ if (!record) return null;
280
+
281
+ const tarball = npmPackageFromTarballUrl(record.key) || npmPackageFromTarballUrl(record.metadata && record.metadata.url);
282
+ return tarball ? { ...tarball, source: tarball.url } : null;
283
+ }
284
+
285
+ function npmPackageFromTarballUrl(value) {
286
+ if (!value || typeof value !== 'string' || !value.includes('.tgz')) return null;
287
+
288
+ let url;
289
+ try {
290
+ url = new URL(value.startsWith('http') ? value : value.slice(value.indexOf('http')));
291
+ } catch {
292
+ return null;
293
+ }
294
+
295
+ const segments = url.pathname.split('/').filter(Boolean).map(decodeUrlSegment);
296
+ const dashIndex = segments.findIndex((segment) => segment === '-');
297
+ if (dashIndex <= 0 || dashIndex >= segments.length - 1) return null;
298
+
299
+ const name = segments.slice(0, dashIndex).join('/');
300
+ const version = versionFromTarballName(segments[segments.length - 1]);
301
+ if (!version || !isValidNpmPackageName(name)) return null;
302
+
303
+ return { name, version, url: url.href };
304
+ }
305
+
306
+ function versionFromTarballName(fileName) {
307
+ const match = fileName.match(/-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.tgz$/);
308
+ return match ? match[1] : null;
309
+ }
310
+
311
+ function collectManifestPackages(root, label, options = {}) {
312
+ const packages = [];
313
+ const maxBytes = options.maxBytes || MAX_MANIFEST_BYTES;
314
+
315
+ walkFiles(root, (filePath, entry) => {
316
+ if (options.onlyPackageJson && entry.name !== 'package.json') return;
317
+
318
+ const stat = safeStat(filePath);
319
+ if (!stat.isFile() || stat.size <= 2 || stat.size > maxBytes) return;
320
+
321
+ const probe = safeReadText(filePath, Math.min(MANIFEST_PROBE_BYTES, maxBytes));
322
+ if (!probe.includes('"name"') || !probe.includes('"version"')) return;
323
+
324
+ const text = stat.size <= probe.length ? probe : safeReadText(filePath, maxBytes);
325
+ const info = safeJsonParse(text);
326
+ if (!isNpmManifest(info)) return;
327
+
328
+ packages.push(machinePackage('npm', info.name, info.version, label, displayPath(filePath), {
329
+ bins: binsFromPackageJson(info),
330
+ packageJson: info,
331
+ }));
332
+ });
333
+
334
+ return packages;
335
+ }
336
+
337
+ function collectYarnBerryZipPackages(cacheRoot, label) {
338
+ const packages = [];
339
+ walkFiles(cacheRoot, (filePath, entry) => {
340
+ if (!entry.name.endsWith('.zip')) return;
341
+ const parsed = yarnBerryPackageFromFilename(entry.name);
342
+ if (parsed) packages.push(machinePackage('npm', parsed.name, parsed.version, label, displayPath(filePath)));
343
+ });
344
+ return packages;
345
+ }
346
+
347
+ function yarnBerryPackageFromFilename(fileName) {
348
+ const match = fileName.match(/^(.+)-npm-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)-[a-z0-9]+\.zip$/i);
349
+ if (!match) return null;
350
+
351
+ const name = match[1].startsWith('@') ? yarnScopedName(match[1]) : match[1];
352
+ if (!name || !isValidNpmPackageName(name)) return null;
353
+
354
+ return { name, version: match[2] };
355
+ }
356
+
357
+ function yarnScopedName(cacheName) {
358
+ const parts = cacheName.slice(1).split('-');
359
+ return parts.length < 2 ? null : `@${parts[0]}/${parts.slice(1).join('-')}`;
360
+ }
361
+
362
+ function collectBunCacheNamePackages(cacheRoot, label) {
363
+ const packages = [];
364
+ const seen = new Set();
365
+
366
+ for (const entry of safeReaddirEntries(cacheRoot)) {
367
+ if (!entry.isDirectory()) continue;
368
+ const entryPath = path.join(cacheRoot, entry.name);
369
+ addBunCachePackage(packages, seen, entry.name, entryPath, label);
370
+
371
+ for (const child of safeReaddirEntries(entryPath)) {
372
+ if (!child.isDirectory()) continue;
373
+ const childPath = path.join(entryPath, child.name);
374
+ addBunCachePackage(packages, seen, `${entry.name}/${child.name}`, childPath, label);
375
+
376
+ if (!entry.name.startsWith('@')) continue;
377
+ for (const versionEntry of safeReaddirEntries(childPath)) {
378
+ if (!versionEntry.isDirectory()) continue;
379
+ addBunCachePackage(packages, seen, `${entry.name}/${child.name}/${versionEntry.name}`, path.join(childPath, versionEntry.name), label);
380
+ }
381
+ }
382
+ }
383
+ return packages;
384
+ }
385
+
386
+ function addBunCachePackage(packages, seen, cacheName, sourcePath, label) {
387
+ const parsed = bunPackageFromCacheName(cacheName);
388
+ if (!parsed) return;
389
+
390
+ const key = packageKey('npm', parsed.name, parsed.version);
391
+ if (seen.has(key)) return;
392
+ seen.add(key);
393
+ packages.push(machinePackage('npm', parsed.name, parsed.version, label, displayPath(sourcePath)));
394
+ }
395
+
396
+ function collectPythonVenvPackages(venvRoot, label) {
397
+ const packages = [];
398
+ const libRoot = path.join(venvRoot, 'lib');
399
+ for (const pythonDir of safeReaddirEntries(libRoot)) {
400
+ if (!pythonDir.isDirectory() || !pythonDir.name.startsWith('python')) continue;
401
+ packages.push(...collectPythonSitePackages(path.join(libRoot, pythonDir.name, 'site-packages'), label));
402
+ }
403
+ return packages;
404
+ }
405
+
406
+ function collectPythonSitePackages(siteRoot, label) {
407
+ const packages = [];
408
+ walkFiles(siteRoot, (filePath, entry) => {
409
+ if (entry.name !== 'METADATA' && entry.name !== 'PKG-INFO') return;
410
+ const normalized = filePath.split(path.sep).join('/');
411
+ if (!normalized.includes('.dist-info/') && !normalized.includes('.egg-info/')) return;
412
+
413
+ const info = parsePythonPackageMetadata(safeReadText(filePath, 512 * 1024));
414
+ if (info) packages.push(machinePackage('pypi', info.name, info.version, label, displayPath(filePath)));
415
+ });
416
+ return packages;
417
+ }
418
+
419
+ function parsePythonPackageMetadata(text) {
420
+ const name = text.match(/^Name:\s*(.+)$/mi);
421
+ const version = text.match(/^Version:\s*(.+)$/mi);
422
+ if (!name || !version) return null;
423
+ return { name: name[1].trim(), version: version[1].trim() };
424
+ }
425
+
426
+ function bunPackageFromCacheName(name) {
427
+ return npmPackageFromNameVersion(name.replace(/@@@\d+$/i, ''));
428
+ }
429
+
430
+ function npmPackageFromNameVersion(value) {
431
+ const decoded = value.replace(/%2f/ig, '/');
432
+ const slash = decoded.lastIndexOf('/');
433
+ if (slash > 0) {
434
+ const name = normalizeEncodedNpmName(decoded.slice(0, slash));
435
+ const version = decoded.slice(slash + 1);
436
+ if (isValidNpmPackageName(name) && isSemverish(version)) return { name, version };
437
+ }
438
+
439
+ const at = decoded.lastIndexOf('@');
440
+ if (at <= 0) return null;
441
+
442
+ const name = normalizeEncodedNpmName(decoded.slice(0, at));
443
+ const version = decoded.slice(at + 1);
444
+ if (!isValidNpmPackageName(name) || !isSemverish(version)) return null;
445
+ return { name, version };
446
+ }
447
+
448
+ function normalizeEncodedNpmName(name) {
449
+ if (name.startsWith('@') && name.includes('+')) return name.replace('+', '/');
450
+ return name;
451
+ }
452
+
453
+ function listNodePackageJsonsDeep(modulesRoot) {
454
+ const result = [];
455
+ const seenModules = new Set();
456
+ const seenPackages = new Set();
457
+
458
+ function visitNodeModules(dir) {
459
+ const real = safeRealpath(dir);
460
+ if (!real || seenModules.has(real)) return;
461
+ seenModules.add(real);
462
+
463
+ for (const entry of safeReaddirEntries(dir)) {
464
+ const fullPath = path.join(dir, entry.name);
465
+ if (!isDirectoryLike(entry, fullPath) || entry.name === '.bin' || entry.name.startsWith('.cache')) continue;
466
+
467
+ if (entry.name.startsWith('@')) {
468
+ visitScope(fullPath);
469
+ continue;
470
+ }
471
+
472
+ visitPackage(fullPath);
473
+ }
474
+ }
475
+
476
+ function visitScope(scopeRoot) {
477
+ for (const scoped of safeReaddirEntries(scopeRoot)) {
478
+ const packageRoot = path.join(scopeRoot, scoped.name);
479
+ if (isDirectoryLike(scoped, packageRoot)) visitPackage(packageRoot);
480
+ }
481
+ }
482
+
483
+ function visitPackage(packageRoot) {
484
+ const real = safeRealpath(packageRoot);
485
+ if (!real || seenPackages.has(real)) return;
486
+ seenPackages.add(real);
487
+
488
+ const packageJson = path.join(packageRoot, 'package.json');
489
+ if (safeStat(packageJson).isFile()) result.push(packageJson);
490
+
491
+ const nestedModules = path.join(packageRoot, 'node_modules');
492
+ if (safeStat(nestedModules).isDirectory()) visitNodeModules(nestedModules);
493
+ }
494
+
495
+ if (safeStat(modulesRoot).isDirectory()) visitNodeModules(modulesRoot);
496
+ return result;
497
+ }
498
+
499
+ function buildOfflineIndex(rows) {
500
+ const index = new Map();
501
+ for (const row of rows) {
502
+ const key = packageKey(row.ecosystem, row.name, row.version);
503
+ const list = index.get(key) || [];
504
+ list.push(row);
505
+ index.set(key, list);
506
+ }
507
+ return index;
508
+ }
509
+
510
+ function matchOffline(packages, binaryMap) {
511
+ const findings = [];
512
+ for (const item of packages) {
513
+ const rows = OFFLINE_INDEX.get(packageKey(item.ecosystem, item.name, item.version));
514
+ if (!rows) continue;
515
+ for (const row of rows) findings.push(buildFinding(item, row, binaryMap));
516
+ }
517
+ return findings;
518
+ }
519
+
520
+ function mergeLiveFindings(findings, liveFindings, binaryMap) {
521
+ for (const row of liveFindings) findings.push(buildFinding(row, row, binaryMap));
522
+ }
523
+
524
+ function buildFinding(item, advisory, binaryMap) {
525
+ const key = packageKey(item.ecosystem, item.name, item.version);
526
+ const bins = new Set([
527
+ ...(item.bins || []),
528
+ ...(advisory.binaries || []),
529
+ ...(binaryMap.get(key) || []),
530
+ ]);
531
+
532
+ return {
533
+ ecosystem: item.ecosystem,
534
+ name: item.name,
535
+ version: item.version,
536
+ locations: item.locations || [item.location].filter(Boolean),
537
+ sources: item.sources || [item.source].filter(Boolean),
538
+ binaries: Array.from(bins).sort(),
539
+ advisory: {
540
+ id: advisory.advisoryId,
541
+ title: advisory.title,
542
+ severity: advisory.severity,
543
+ type: advisory.type,
544
+ source: advisory.source,
545
+ published: advisory.published,
546
+ summary: advisory.summary,
547
+ live: Boolean(advisory.live),
548
+ },
549
+ };
550
+ }
551
+
552
+ function dedupePackages(packages) {
553
+ const merged = new Map();
554
+ for (const item of packages) {
555
+ const key = packageKey(item.ecosystem, item.name, item.version);
556
+ const existing = merged.get(key);
557
+ if (!existing) {
558
+ merged.set(key, {
559
+ ...item,
560
+ locations: [item.location].filter(Boolean),
561
+ sources: [item.source].filter(Boolean),
562
+ bins: Array.from(new Set(item.bins || [])),
563
+ });
564
+ continue;
565
+ }
566
+
567
+ pushUnique(existing.locations, item.location);
568
+ pushUnique(existing.sources, item.source);
569
+ for (const bin of item.bins || []) pushUnique(existing.bins, bin);
570
+ if (item.packageJson && !existing.packageJson) existing.packageJson = item.packageJson;
571
+ }
572
+ return Array.from(merged.values());
573
+ }
574
+
575
+ function dedupeFindings(findings) {
576
+ const seen = new Set();
577
+ const result = [];
578
+
579
+ for (const finding of findings) {
580
+ const key = `${finding.ecosystem}:${finding.name}:${finding.version}:${finding.advisory.id}`;
581
+ if (seen.has(key)) continue;
582
+ seen.add(key);
583
+ result.push(finding);
584
+ }
585
+
586
+ result.sort((a, b) => {
587
+ const aBins = a.binaries.length ? 0 : 1;
588
+ const bBins = b.binaries.length ? 0 : 1;
589
+ if (aBins !== bBins) return aBins - bBins;
590
+ return `${a.ecosystem}:${a.name}`.localeCompare(`${b.ecosystem}:${b.name}`);
591
+ });
592
+ return result;
593
+ }
594
+
595
+ function collectPackageBinaryMap(packages) {
596
+ const map = new Map();
597
+ for (const item of packages) {
598
+ if (item.bins && item.bins.length) map.set(packageKey(item.ecosystem, item.name, item.version), item.bins);
599
+ }
600
+ return map;
601
+ }
602
+
603
+ function scanMachineIocs() {
604
+ const home = os.homedir();
605
+ if (!home) return [];
606
+
607
+ const findings = [];
608
+ for (const relativePath of IOC_CANDIDATES) {
609
+ const filePath = path.join(home, relativePath);
610
+ if (!safeStat(filePath).isFile()) continue;
611
+
612
+ const lower = safeReadText(filePath, 1024 * 1024).toLowerCase();
613
+ const markerCount = IOC_MARKERS.reduce((count, marker) => count + (lower.includes(marker) ? 1 : 0), 0);
614
+ if (markerCount < 2) continue;
615
+
616
+ findings.push({
617
+ path: displayPath(filePath),
618
+ reason: 'File name and contents resemble persistence or credential-exfiltration IOCs from recent npm/PyPI supply-chain campaigns.',
619
+ });
620
+ }
621
+ return findings;
622
+ }
623
+
624
+ function machinePackage(ecosystem, name, version, locationLabel, source, extra = {}) {
625
+ return {
626
+ ecosystem,
627
+ name: ecosystem === 'pypi' ? name.replace(/_/g, '-').toLowerCase() : name,
628
+ version: String(version).trim().replace(/^v/i, ''),
629
+ location: locationLabel,
630
+ source,
631
+ ...extra,
632
+ };
633
+ }
634
+
635
+ function location(label, kind, rawPath) {
636
+ const resolved = normalizePath(rawPath);
637
+ if (!resolved || !safeStat(resolved).isDirectory()) return null;
638
+ return { label, kind, path: resolved };
639
+ }
640
+
641
+ function locations(label, kind, rawPaths) {
642
+ return rawPaths.map((rawPath) => location(label, kind, rawPath)).filter(Boolean);
643
+ }
644
+
645
+ function dedupeLocations(items) {
646
+ const seen = new Set();
647
+ const result = [];
648
+
649
+ for (const item of items.filter(Boolean)) {
650
+ const real = safeRealpath(item.path) || item.path;
651
+ const key = `${item.kind}:${real}`;
652
+ if (seen.has(key)) continue;
653
+ seen.add(key);
654
+ result.push(item);
655
+ }
656
+
657
+ return result;
658
+ }
659
+
660
+ function commandOutput(command, args) {
661
+ try {
662
+ const output = childProcess.execFileSync(command, args, {
663
+ encoding: 'utf8',
664
+ stdio: ['ignore', 'pipe', 'ignore'],
665
+ timeout: 2500,
666
+ }).trim();
667
+ return output && output !== 'undefined' && output !== 'null' ? output : null;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
672
+
673
+ function walkFiles(root, visit) {
674
+ const stack = [root];
675
+ const seen = new Set();
676
+
677
+ while (stack.length) {
678
+ const dir = stack.pop();
679
+ const real = safeRealpath(dir);
680
+ if (!real || seen.has(real)) continue;
681
+ seen.add(real);
682
+
683
+ for (const entry of safeReaddirEntries(dir)) {
684
+ const fullPath = path.join(dir, entry.name);
685
+ if (entry.isDirectory()) {
686
+ if (!WALK_SKIP_DIRECTORIES.has(entry.name)) stack.push(fullPath);
687
+ continue;
688
+ }
689
+ if (entry.isFile()) visit(fullPath, entry);
690
+ }
691
+ }
692
+ }
693
+
694
+ function isDirectoryLike(entry, fullPath) {
695
+ return entry.isDirectory() || (entry.isSymbolicLink() && safeStat(fullPath).isDirectory());
696
+ }
697
+
698
+ function isNpmManifest(info) {
699
+ return Boolean(
700
+ info &&
701
+ typeof info.name === 'string' &&
702
+ typeof info.version === 'string' &&
703
+ isValidNpmPackageName(info.name) &&
704
+ /^[0-9][0-9A-Za-z.+-]*$/.test(info.version)
705
+ );
706
+ }
707
+
708
+ function isValidNpmPackageName(name) {
709
+ return /^(?:@[A-Za-z0-9._~-]+\/)?[A-Za-z0-9._~-]+$/.test(name);
710
+ }
711
+
712
+ function isSemverish(value) {
713
+ return SEMVERISH_RE.test(value);
714
+ }
715
+
716
+ function packageKey(ecosystem, name, version) {
717
+ return `${String(ecosystem).toLowerCase()}:${String(name).toLowerCase()}:${String(version)}`;
718
+ }
719
+
720
+ function pushUnique(items, item) {
721
+ if (item && !items.includes(item)) items.push(item);
722
+ }
723
+
724
+ function normalizePath(value) {
725
+ if (!value || typeof value !== 'string') return null;
726
+ const trimmed = value.trim();
727
+ if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return null;
728
+ if (trimmed.startsWith('~/')) return path.join(os.homedir(), trimmed.slice(2));
729
+ return path.resolve(trimmed);
730
+ }
731
+
732
+ function displayPath(filePath) {
733
+ const home = os.homedir();
734
+ if (home && filePath.startsWith(`${home}${path.sep}`)) return `~/${path.relative(home, filePath).split(path.sep).join('/')}`;
735
+ return filePath.split(path.sep).join('/');
736
+ }
737
+
738
+ function decodeUrlSegment(segment) {
739
+ try {
740
+ return decodeURIComponent(segment);
741
+ } catch {
742
+ return segment;
743
+ }
744
+ }
745
+
746
+ function safeJsonParse(text) {
747
+ try {
748
+ return JSON.parse(text);
749
+ } catch {
750
+ return null;
751
+ }
752
+ }
753
+
754
+ function safeReadText(filePath, maxBytes) {
755
+ let fd = null;
756
+ try {
757
+ fd = fs.openSync(filePath, 'r');
758
+ const stat = fs.fstatSync(fd);
759
+ if (!stat.isFile()) return '';
760
+
761
+ const length = Math.min(stat.size, maxBytes);
762
+ if (length <= 0) return '';
763
+
764
+ const buffer = Buffer.allocUnsafe(length);
765
+ const bytesRead = fs.readSync(fd, buffer, 0, length, 0);
766
+ return buffer.subarray(0, bytesRead).toString('utf8');
767
+ } catch {
768
+ return '';
769
+ } finally {
770
+ if (fd !== null) {
771
+ try {
772
+ fs.closeSync(fd);
773
+ } catch {}
774
+ }
775
+ }
776
+ }
777
+
778
+ function safeReaddirEntries(dir) {
779
+ try {
780
+ return fs.readdirSync(dir, { withFileTypes: true });
781
+ } catch {
782
+ return [];
783
+ }
784
+ }
785
+
786
+ function safeRealpath(filePath) {
787
+ try {
788
+ return fs.realpathSync(filePath);
789
+ } catch {
790
+ return null;
791
+ }
792
+ }
793
+
794
+ function safeStat(filePath) {
795
+ try {
796
+ return fs.statSync(filePath);
797
+ } catch {
798
+ return { isFile: () => false, isDirectory: () => false, size: 0 };
799
+ }
800
+ }
801
+
802
+ module.exports = {
803
+ scanMachine,
804
+ discoverMachineLocations,
805
+ collectMachinePackages,
806
+ npmPackageFromTarballUrl,
807
+ packageKey,
808
+ };