pkgradar 0.1.1 → 0.1.3
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/bin/pkgradar.js +214 -77
- package/lib/checks.js +56 -6
- package/lib/osv.js +45 -16
- package/lib/scan.js +42 -4
- package/lib/stores.js +56 -2
- package/package.json +1 -1
package/bin/pkgradar.js
CHANGED
|
@@ -2,13 +2,30 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { scan, SEV } = require('../lib/scan');
|
|
5
|
+
const PKG = require('../package.json');
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// ----------------------------------------------------------------------------
|
|
8
|
+
// args
|
|
9
|
+
// ----------------------------------------------------------------------------
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the given flag is present in argv.
|
|
14
|
+
* @param {string} f - Flag string, e.g. "--online".
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
const has = (f) => argv.includes(f);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns the value of a flag argument, or a default if absent.
|
|
21
|
+
* @param {string} f - Flag string, e.g. "--min-sev".
|
|
22
|
+
* @param {string} def - Default value when flag is missing or has no argument.
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
const val = (f, def) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
|
|
9
26
|
|
|
10
27
|
if (has('-h') || has('--help')) {
|
|
11
|
-
|
|
28
|
+
process.stdout.write(`pkgradar - content-based supply-chain scanner for npm / pnpm / yarn / bun
|
|
12
29
|
|
|
13
30
|
It opens the package files you actually installed and looks for what malware
|
|
14
31
|
does (install hooks, obfuscated payloads, known worm artifacts), instead of
|
|
@@ -20,6 +37,7 @@ USAGE
|
|
|
20
37
|
OPTIONS
|
|
21
38
|
--online also cross-reference OSV.dev for known advisories (network)
|
|
22
39
|
--json machine-readable output
|
|
40
|
+
--full expanded per-finding output instead of the summary table
|
|
23
41
|
--min-sev LEVEL report findings at or above LEVEL
|
|
24
42
|
critical | high | medium | low | info [default: medium]
|
|
25
43
|
--stores LIST comma list to limit which stores are scanned
|
|
@@ -40,28 +58,127 @@ const SEV_FROM_NAME = { critical: SEV.CRITICAL, high: SEV.HIGH, medium: SEV.MEDI
|
|
|
40
58
|
const minSevName = (val('--min-sev', 'medium') || 'medium').toLowerCase();
|
|
41
59
|
const minSev = SEV_FROM_NAME[minSevName] ?? SEV.MEDIUM;
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
// ----------------------------------------------------------------------------
|
|
62
|
+
// colour helpers
|
|
63
|
+
// ----------------------------------------------------------------------------
|
|
64
|
+
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns a function that wraps a string in the given ANSI escape code.
|
|
68
|
+
* Falls back to a no-op when COLOR is disabled.
|
|
69
|
+
* @param {string} code - ANSI code fragment, e.g. "31" for red.
|
|
70
|
+
* @returns {(s: string) => string}
|
|
71
|
+
*/
|
|
72
|
+
const paint = (code) => (s) => COLOR ? `\x1b[${code}m${s}\x1b[0m` : `${s}`;
|
|
73
|
+
|
|
46
74
|
const C = {
|
|
47
|
-
bold:
|
|
48
|
-
blu:
|
|
75
|
+
bold: paint('1'), dim: paint('2'), red: paint('31'), grn: paint('32'), ylw: paint('33'),
|
|
76
|
+
blu: paint('34'), mag: paint('35'), cyan: paint('36'), gray: paint('90'),
|
|
49
77
|
};
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns a fixed-width, coloured severity cell string for use inside a table.
|
|
81
|
+
* Uses white-on-colour background in TTY mode.
|
|
82
|
+
* @param {string} sev - Severity name: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO".
|
|
83
|
+
* @param {number} width - Total cell width in characters.
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function sevCell(sev, width) {
|
|
52
87
|
const bg = { CRITICAL: '41', HIGH: '45', MEDIUM: '43', LOW: '100', INFO: '100' }[sev] || '100';
|
|
53
88
|
const fg = sev === 'MEDIUM' ? '30' : '97';
|
|
54
|
-
const label =
|
|
55
|
-
return
|
|
56
|
-
}
|
|
89
|
+
const label = sev.padStart((width + sev.length) >> 1).padEnd(width);
|
|
90
|
+
return COLOR ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : label;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Maps severity name to a colouring function for plain-text severity mentions. */
|
|
57
94
|
const SEV_TEXT = { CRITICAL: C.red, HIGH: C.mag, MEDIUM: C.ylw, LOW: C.gray, INFO: C.gray };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replaces the home-directory prefix of a path with "~" for compact display.
|
|
98
|
+
* @param {string|null} p - Absolute path, or null/undefined.
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
58
101
|
const homeShort = (p) => (p && p.startsWith(os.homedir())) ? '~' + p.slice(os.homedir().length) : p;
|
|
59
102
|
|
|
60
|
-
|
|
103
|
+
// ----------------------------------------------------------------------------
|
|
104
|
+
// foreground spinner (writes to stderr so stdout stays pipe-clean)
|
|
105
|
+
// ----------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Creates a TTY spinner that writes to stderr.
|
|
109
|
+
* @returns {{start: Function, set: Function, stop: Function}}
|
|
110
|
+
*/
|
|
111
|
+
function makeSpinner() {
|
|
112
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
113
|
+
const on = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
114
|
+
let i = 0, msg = 'starting', timer = null;
|
|
115
|
+
const render = () => process.stderr.write(`\r\x1b[2K${C.cyan(frames[i = (i + 1) % frames.length])} ${C.gray(msg + ' …')}`);
|
|
116
|
+
return {
|
|
117
|
+
/** Start the spinner interval. */
|
|
118
|
+
start() { if (!on) return; timer = setInterval(render, 80); render(); },
|
|
119
|
+
/** Update the status message shown next to the spinner. @param {string} m */
|
|
120
|
+
set(m) { msg = m; },
|
|
121
|
+
/** Stop the spinner and clear the line. */
|
|
122
|
+
stop() { if (timer) clearInterval(timer); if (on) process.stderr.write('\r\x1b[2K'); },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ----------------------------------------------------------------------------
|
|
127
|
+
// table renderer
|
|
128
|
+
// ----------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Clip string s to at most n chars, appending "…" if truncated.
|
|
132
|
+
* @param {*} s - Value to stringify and clip.
|
|
133
|
+
* @param {number} n - Maximum character count.
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
const clip = (s, n) => { s = String(s == null ? '' : s); return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'; };
|
|
61
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Pad string s with trailing spaces to exactly n characters.
|
|
140
|
+
* @param {string} s
|
|
141
|
+
* @param {number} n
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
const padTo = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render an array of row objects as a box-drawn table.
|
|
148
|
+
* @param {{key:string, label:string, width:number, color?:Function, raw?:boolean}[]} cols - Column definitions.
|
|
149
|
+
* @param {object[]} rows - Row data objects keyed by col.key.
|
|
150
|
+
* @returns {string} Rendered table string (no trailing newline).
|
|
151
|
+
*/
|
|
152
|
+
function table(cols, rows) {
|
|
153
|
+
const B = COLOR ? C.gray : (s) => s;
|
|
154
|
+
const line = (l, m, r) => B(l + cols.map((c) => '─'.repeat(c.width + 2)).join(m) + r);
|
|
155
|
+
const out = [];
|
|
156
|
+
out.push(' ' + line('┌', '┬', '┐'));
|
|
157
|
+
out.push(' ' + B('│') + cols.map((c) => ' ' + C.bold(padTo(clip(c.label, c.width), c.width)) + ' ').join(B('│')) + B('│'));
|
|
158
|
+
out.push(' ' + line('├', '┼', '┤'));
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const cells = cols.map((c) => {
|
|
161
|
+
const v = clip(row[c.key], c.width);
|
|
162
|
+
if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
|
|
163
|
+
const txt = padTo(v, c.width);
|
|
164
|
+
return ' ' + (c.color ? c.color(txt) : txt) + ' ';
|
|
165
|
+
});
|
|
166
|
+
out.push(' ' + B('│') + cells.join(B('│')) + B('│'));
|
|
167
|
+
}
|
|
168
|
+
out.push(' ' + line('└', '┴', '┘'));
|
|
169
|
+
return out.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ----------------------------------------------------------------------------
|
|
173
|
+
// main
|
|
174
|
+
// ----------------------------------------------------------------------------
|
|
175
|
+
/** Entry point: parses CLI flags, calls scan(), then renders results to stdout. */
|
|
62
176
|
(async () => {
|
|
177
|
+
const spin = makeSpinner();
|
|
178
|
+
spin.start();
|
|
179
|
+
spin.set('discovering package stores');
|
|
63
180
|
let res;
|
|
64
|
-
const
|
|
181
|
+
const t0 = Date.now();
|
|
65
182
|
try {
|
|
66
183
|
res = await scan({
|
|
67
184
|
cwd: val('--cwd', process.cwd()),
|
|
@@ -70,89 +187,109 @@ function rule(ch = '─', n = 64) { return C.gray(ch.repeat(n)); }
|
|
|
70
187
|
maxDepth: parseInt(val('--max-depth', '12'), 10) || 12,
|
|
71
188
|
stores: (val('--stores', '') || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
72
189
|
noAllowlist: has('--no-allowlist'),
|
|
190
|
+
onPhase: (m) => spin.set(m),
|
|
73
191
|
});
|
|
74
192
|
} catch (err) {
|
|
75
|
-
|
|
193
|
+
spin.stop();
|
|
194
|
+
process.stderr.write(C.red('pkgradar error: ') + (err && err.stack || err) + '\n');
|
|
76
195
|
process.exit(2);
|
|
77
196
|
}
|
|
78
|
-
|
|
197
|
+
spin.stop();
|
|
198
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
79
199
|
|
|
80
200
|
if (has('--json')) {
|
|
81
|
-
|
|
201
|
+
process.stdout.write(JSON.stringify({ ...res, durationSeconds: Number(secs) }, null, 2) + '\n');
|
|
82
202
|
process.exit(res.findings.some((f) => f.sevNum >= minSev) ? 1 : 0);
|
|
83
203
|
}
|
|
84
204
|
|
|
85
205
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
|
|
86
206
|
for (const f of res.findings) counts[f.severity]++;
|
|
87
|
-
const localFindings = res.findings.filter((f) => f.code !== 'osv-advisory');
|
|
88
|
-
const osvFindings = res.findings.filter((f) => f.code === 'osv-advisory');
|
|
89
|
-
const hits = res.findings.length;
|
|
90
207
|
const worst = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].find((k) => counts[k]) || null;
|
|
91
208
|
|
|
209
|
+
// Shorthand to write a line to stdout.
|
|
210
|
+
const p = (...a) => process.stdout.write(a.join(' ') + '\n');
|
|
211
|
+
|
|
92
212
|
// ---- header ----
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
213
|
+
p('');
|
|
214
|
+
p(` ${C.bold('pkgradar')} ${C.gray('v' + PKG.version)} ${C.gray('content-based supply-chain scan')} ${C.gray(secs + 's')}`);
|
|
215
|
+
p(` ${C.gray('project')} ${homeShort(res.cwd)}`);
|
|
216
|
+
p('');
|
|
97
217
|
|
|
98
218
|
// ---- stores table ----
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
219
|
+
p(table(
|
|
220
|
+
[
|
|
221
|
+
{ key: 'kind', label: 'STORE', width: 14, color: C.cyan },
|
|
222
|
+
{ key: 'pkgs', label: 'PKGS', width: 6, color: C.dim },
|
|
223
|
+
{ key: 'root', label: 'PATH', width: Math.max(28, (process.stdout.columns || 120) - 32) },
|
|
224
|
+
],
|
|
225
|
+
res.stores.map((s) => ({ kind: s.kind, pkgs: String(s.packages), root: homeShort(s.root) })),
|
|
226
|
+
));
|
|
227
|
+
p(` ${C.gray('total:')} ${C.bold(res.totalPackages)} ${C.gray('unique package@version,')} ${C.bold(res.totalScanned)} ${C.gray('locations inspected')}`);
|
|
228
|
+
if (res.osv && res.osv.error) p(` ${C.ylw('OSV lookup failed:')} ${C.dim(res.osv.error)}`);
|
|
229
|
+
else if (!has('--online')) p(` ${C.gray('tip: add')} ${C.dim('--online')} ${C.gray('to cross-check known advisories on OSV.dev')}`);
|
|
230
|
+
p('');
|
|
231
|
+
|
|
232
|
+
// ---- verdict ----
|
|
233
|
+
if (!res.findings.length) {
|
|
234
|
+
p(` ${C.grn('●')} ${C.bold('Verdict:')} ${C.grn('clean')} ${C.gray('nothing tripped the heuristics at ' + minSevName + ' and above')}`);
|
|
235
|
+
p(` ${C.gray(' (this inspects installed bytes; a clean run is not a proof of safety)')}`);
|
|
236
|
+
p('');
|
|
113
237
|
process.exit(0);
|
|
114
238
|
}
|
|
115
|
-
const
|
|
116
|
-
const dot = worst === 'CRITICAL' || worst === 'HIGH' ? C.red('●') : worst === 'MEDIUM' ? C.ylw('●') : C.gray('●');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for (const f of list) {
|
|
132
|
-
console.log(` ${badge(f.severity)} ${C.bold(f.package + '@' + f.version)} ${C.gray(f.store)} ${C.cyan(f.code)}`);
|
|
133
|
-
console.log(` ${f.message}`);
|
|
134
|
-
if (f.evidence) console.log(` ${C.gray('→ ' + f.evidence)}`);
|
|
135
|
-
if (f.note) console.log(` ${C.gray('note: ' + f.note)}`);
|
|
136
|
-
if (f.dir) console.log(` ${C.gray(homeShort(f.dir))}`);
|
|
137
|
-
console.log('');
|
|
239
|
+
const bits = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].filter((k) => counts[k]).map((k) => SEV_TEXT[k](`${counts[k]} ${k.toLowerCase()}`));
|
|
240
|
+
const dot = (worst === 'CRITICAL' || worst === 'HIGH') ? C.red('●') : worst === 'MEDIUM' ? C.ylw('●') : C.gray('●');
|
|
241
|
+
p(` ${dot} ${C.bold('Verdict:')} ${C.bold(res.findings.length + ' ' + (res.findings.length === 1 ? 'finding' : 'findings'))} ${C.gray('(')}${bits.join(C.gray(', '))}${C.gray(')')}`);
|
|
242
|
+
p('');
|
|
243
|
+
|
|
244
|
+
// ---- findings ----
|
|
245
|
+
const sorted = res.findings.slice().sort((a, b) => b.sevNum - a.sevNum || a.code.localeCompare(b.code) || a.package.localeCompare(b.package));
|
|
246
|
+
|
|
247
|
+
if (has('--full')) {
|
|
248
|
+
for (const f of sorted) {
|
|
249
|
+
p(` ${sevCell(f.severity, 10)} ${C.bold(f.package + '@' + f.version)} ${C.gray(f.store)} ${C.cyan(f.code)}`);
|
|
250
|
+
p(` ${f.message}`);
|
|
251
|
+
if (f.evidence) p(` ${C.gray('-> ' + f.evidence)}`);
|
|
252
|
+
if (f.note) p(` ${C.gray('note: ' + f.note)}`);
|
|
253
|
+
if (f.dir) p(` ${C.gray(homeShort(f.dir))}`);
|
|
254
|
+
p('');
|
|
138
255
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
256
|
+
} else {
|
|
257
|
+
const term = process.stdout.columns || 120;
|
|
258
|
+
const W_PKG = 28, W_TYPE = 21, W_WHERE = 16;
|
|
259
|
+
const W_DETAIL = Math.max(24, term - (2 + 3 + 10 + W_PKG + W_TYPE + W_WHERE + 3 * 5 + 1));
|
|
260
|
+
const SHOW = 60;
|
|
261
|
+
const rows = sorted.slice(0, SHOW).map((f) => ({
|
|
262
|
+
sev: sevCell(f.severity, 10),
|
|
263
|
+
pkg: f.package + '@' + f.version,
|
|
264
|
+
type: f.code,
|
|
265
|
+
where: f.store,
|
|
266
|
+
detail: (f.evidence ? f.evidence + ' ' : '') + f.message,
|
|
267
|
+
}));
|
|
268
|
+
p(table(
|
|
269
|
+
[
|
|
270
|
+
{ key: 'sev', label: 'SEVERITY', width: 10, raw: true },
|
|
271
|
+
{ key: 'pkg', label: 'PACKAGE', width: W_PKG, color: C.bold },
|
|
272
|
+
{ key: 'type', label: 'TYPE', width: W_TYPE, color: C.cyan },
|
|
273
|
+
{ key: 'where', label: 'WHERE', width: W_WHERE, color: C.dim },
|
|
274
|
+
{ key: 'detail', label: 'DETAIL', width: W_DETAIL, color: C.gray },
|
|
275
|
+
],
|
|
276
|
+
rows,
|
|
277
|
+
));
|
|
278
|
+
if (sorted.length > SHOW) p(` ${C.gray('... and ' + (sorted.length - SHOW) + ' more (use --json for the full list)')}`);
|
|
279
|
+
p(` ${C.gray('use')} ${C.dim('--full')} ${C.gray('for untruncated details and on-disk paths')}`);
|
|
280
|
+
}
|
|
281
|
+
p('');
|
|
142
282
|
|
|
143
|
-
// ----
|
|
144
|
-
|
|
145
|
-
console.log(` ${C.bold('What to do')}`);
|
|
283
|
+
// ---- what to do ----
|
|
284
|
+
p(` ${C.bold('What to do')}`);
|
|
146
285
|
if (counts.CRITICAL || counts.HIGH) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
console.log(` GitHub / cloud tokens this machine has held.`);
|
|
286
|
+
p(` ${C.red('•')} Treat ${C.bold('CRITICAL / HIGH "installed code" findings')} as suspect: don't run install`);
|
|
287
|
+
p(` scripts, remove the package, clear the relevant cache, rotate npm / GitHub / cloud tokens.`);
|
|
150
288
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
console.log('');
|
|
289
|
+
p(` ${C.gray('•')} Check a flagged version against the registry: ${C.dim('npm view <pkg> versions')} ${C.gray('(publish dates, provenance).')}`);
|
|
290
|
+
if (sorted.some((f) => f.code === 'osv-advisory')) p(` ${C.gray('•')} OSV rows are the usual "known CVE in a dependency" set: ${C.dim('npm audit fix')} ${C.gray('/ bump.')}`);
|
|
291
|
+
if (!has('--online')) p(` ${C.gray('•')} Re-run with ${C.dim('--online')} ${C.gray('to add OSV.dev advisory matching by exact version.')}`);
|
|
292
|
+
p('');
|
|
156
293
|
|
|
157
294
|
process.exit(res.findings.some((f) => f.sevNum >= minSev) ? 1 : 0);
|
|
158
295
|
})();
|
package/lib/checks.js
CHANGED
|
@@ -14,7 +14,7 @@ const WORM_FILENAMES = [
|
|
|
14
14
|
'truffleSecrets.json', 'actionsSecrets.json',
|
|
15
15
|
];
|
|
16
16
|
// High-confidence exfiltration endpoints / payload constants seen in Shai-Hulud-family
|
|
17
|
-
// worms. A mention of the string "shai-hulud" alone is NOT here on purpose
|
|
17
|
+
// worms. A mention of the string "shai-hulud" alone is NOT here on purpose -- security
|
|
18
18
|
// tools (including this one) legitimately contain that word.
|
|
19
19
|
const WORM_HARD_MARKERS = [
|
|
20
20
|
'webhook.site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7',
|
|
@@ -37,6 +37,13 @@ const NET_TOKENS = ['curl ', 'wget ', 'http://', 'https://', 'fetch(', 'net.conn
|
|
|
37
37
|
const EXEC_TOKENS = ['child_process', 'execSync', 'spawnSync', 'exec(', 'spawn(', 'node -e', 'node --eval', 'eval(', 'Function(', 'vm.runInThisContext'];
|
|
38
38
|
const ENCODE_TOKENS = ['base64 -d', "from('", 'Buffer.from(', 'atob(', 'fromCharCode', 'unescape('];
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Reads a file safely, returning its text and metadata.
|
|
42
|
+
* Truncates to `max` bytes if the file is larger.
|
|
43
|
+
* @param {string} p - Absolute path to the file.
|
|
44
|
+
* @param {number} [max=2_000_000] - Maximum bytes to read.
|
|
45
|
+
* @returns {{ truncated: boolean, text: string, size: number } | null} File contents, or null on error.
|
|
46
|
+
*/
|
|
40
47
|
function readSafe(p, max = 2_000_000) {
|
|
41
48
|
try {
|
|
42
49
|
const st = fs.statSync(p);
|
|
@@ -45,7 +52,25 @@ function readSafe(p, max = 2_000_000) {
|
|
|
45
52
|
} catch { return null; }
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Checks whether a token string appears in a source string.
|
|
57
|
+
* Iterates over a list of tokens and collects all matches.
|
|
58
|
+
* @param {string[]} tokens - List of substrings to look for.
|
|
59
|
+
* @param {string} src - Source string to search.
|
|
60
|
+
* @param {Set<string>} hits - Set to accumulate matching tokens into.
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
function collectTokenHits(tokens, src, hits) {
|
|
64
|
+
for (const t of tokens) if (src.includes(t)) hits.add(t);
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
// 1. Lifecycle install hooks (the #1 detonation vector)
|
|
68
|
+
/**
|
|
69
|
+
* Checks package.json lifecycle hooks for suspicious behaviour.
|
|
70
|
+
* Only inspects hooks that fire when a package is installed as a registry dependency.
|
|
71
|
+
* @param {{ manifest: { scripts?: Record<string, string> } }} pkg - Package object.
|
|
72
|
+
* @returns {{ sev: number, code: string, msg: string, evidence: string }[]} Array of findings.
|
|
73
|
+
*/
|
|
49
74
|
function checkLifecycleHooks(pkg) {
|
|
50
75
|
const out = [];
|
|
51
76
|
const s = (pkg.manifest && pkg.manifest.scripts) || {};
|
|
@@ -61,8 +86,8 @@ function checkLifecycleHooks(pkg) {
|
|
|
61
86
|
|| /^(node\s+)?(scripts\/)?install(\.js)?$/.test(c)
|
|
62
87
|
|| /^(ng-?ccc?|ngcc)\b/.test(c);
|
|
63
88
|
const hits = new Set();
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
collectTokenHits([...NET_TOKENS, ...EXEC_TOKENS, ...ENCODE_TOKENS], cmd, hits);
|
|
90
|
+
collectTokenHits(SECRET_NAMES, cmd.toUpperCase(), hits);
|
|
66
91
|
if (cmd.includes('.npmrc') || cmd.includes('/.ssh/') || cmd.includes('.aws/credentials')) hits.add('credential-file');
|
|
67
92
|
const piped = /\b(curl|wget|fetch)\b[^|;&]*[|;&]+\s*(ba|z|d)?sh\b/.test(cmd) || /\|\s*node\b/.test(cmd);
|
|
68
93
|
|
|
@@ -70,13 +95,19 @@ function checkLifecycleHooks(pkg) {
|
|
|
70
95
|
if (hits.size >= 3 || piped) sev = SEV.CRITICAL;
|
|
71
96
|
else if (hits.size >= 1) sev = SEV.HIGH;
|
|
72
97
|
else if (benign) sev = SEV.INFO;
|
|
73
|
-
else sev = SEV.LOW; // an unrecognised install hook
|
|
98
|
+
else sev = SEV.LOW; // an unrecognised install hook -- worth a glance, not an alarm
|
|
74
99
|
out.push({ sev, code: 'lifecycle-hook', msg: `${h} script${hits.size ? ', touches: ' + [...hits].join(', ') : (benign ? ' (recognised build tool)' : ' (unrecognised command)')}`, evidence: `"${h}": ${cmd.slice(0, 240)}` });
|
|
75
100
|
}
|
|
76
101
|
return out;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
// 2. Worm IOC files sitting inside the package
|
|
105
|
+
/**
|
|
106
|
+
* Walks the package directory tree looking for known worm artifact filenames
|
|
107
|
+
* and embedded GitHub Actions workflow files.
|
|
108
|
+
* @param {{ dir: string }} pkg - Package object with a resolved directory path.
|
|
109
|
+
* @returns {{ sev: number, code: string, msg: string, evidence: string }[]} Array of findings.
|
|
110
|
+
*/
|
|
80
111
|
function checkWormFiles(pkg) {
|
|
81
112
|
if (!pkg.dir) return [];
|
|
82
113
|
const out = [];
|
|
@@ -107,11 +138,18 @@ function checkWormFiles(pkg) {
|
|
|
107
138
|
}
|
|
108
139
|
|
|
109
140
|
// 3. Obfuscated / packed JS payload heuristics on the package's own JS
|
|
141
|
+
/**
|
|
142
|
+
* Scans JS files in the package for obfuscation patterns, worm IOC strings,
|
|
143
|
+
* and suspicious combinations of credential access and network calls.
|
|
144
|
+
* Applies per-file and per-package byte budgets to bound scan time.
|
|
145
|
+
* @param {{ dir: string }} pkg - Package object with a resolved directory path.
|
|
146
|
+
* @returns {{ sev: number, code: string, msg: string, evidence: string }[]} Array of findings.
|
|
147
|
+
*/
|
|
110
148
|
function checkObfuscation(pkg) {
|
|
111
149
|
if (!pkg.dir) return [];
|
|
112
150
|
const out = [];
|
|
113
151
|
// Collect candidate JS files, then scan them under a strict per-package budget so a
|
|
114
|
-
// package full of huge vendored bundles (typescript.js, lighthouse bundles,
|
|
152
|
+
// package full of huge vendored bundles (typescript.js, lighthouse bundles, ...) can't
|
|
115
153
|
// make a scan run for minutes.
|
|
116
154
|
const files = [];
|
|
117
155
|
const stack = [pkg.dir];
|
|
@@ -152,7 +190,7 @@ function checkObfuscation(pkg) {
|
|
|
152
190
|
pkgByteBudget -= st.size;
|
|
153
191
|
}
|
|
154
192
|
const strong = []; // each strong reason alone is reportable
|
|
155
|
-
const weak = [];
|
|
193
|
+
const weak = []; // weak reasons only matter in combination
|
|
156
194
|
let critical = false;
|
|
157
195
|
|
|
158
196
|
// --- "executable payload" context: dynamic code execution over decoded data, or
|
|
@@ -206,6 +244,12 @@ function checkObfuscation(pkg) {
|
|
|
206
244
|
}
|
|
207
245
|
|
|
208
246
|
// 4. Manifest oddities (cheap, offline)
|
|
247
|
+
/**
|
|
248
|
+
* Inspects package.json for structural anomalies that warrant attention.
|
|
249
|
+
* Currently checks bin entries that point outside the package or to shell scripts.
|
|
250
|
+
* @param {{ manifest: object, name?: string }} pkg - Package object.
|
|
251
|
+
* @returns {{ sev: number, code: string, msg: string, evidence: string }[]} Array of findings.
|
|
252
|
+
*/
|
|
209
253
|
function checkManifestAnomalies(pkg) {
|
|
210
254
|
const out = [];
|
|
211
255
|
const m = pkg.manifest || {};
|
|
@@ -224,6 +268,12 @@ function checkManifestAnomalies(pkg) {
|
|
|
224
268
|
let SELF = null;
|
|
225
269
|
try { SELF = require('../package.json').name; } catch { /* ignore */ }
|
|
226
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Runs all checks against a single package and returns the combined findings.
|
|
273
|
+
* Skips pkgradar's own package to avoid false positives from IOC strings in this file.
|
|
274
|
+
* @param {{ name?: string, version?: string, dir?: string, store?: string, manifest?: object }} pkg - Package to inspect.
|
|
275
|
+
* @returns {{ sev: number, code: string, msg: string, evidence: string }[]} Combined findings from all checks.
|
|
276
|
+
*/
|
|
227
277
|
function runAll(pkg) {
|
|
228
278
|
// Don't scan our own package: this file necessarily contains the IOC strings it
|
|
229
279
|
// searches for, so scanning a cached copy of pkgradar would flag pkgradar.
|
package/lib/osv.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Optional online cross-reference against OSV.dev
|
|
2
|
+
// Optional online cross-reference against OSV.dev. Precise (package, version) matching,
|
|
3
3
|
// no API key. Used only when --online is passed. Two phases:
|
|
4
|
-
// 1. /v1/querybatch
|
|
5
|
-
// 2. /v1/query
|
|
6
|
-
//
|
|
4
|
+
// 1. /v1/querybatch fast: which (name@version) have advisories at all
|
|
5
|
+
// 2. /v1/query for just those packages, fetch full vuln objects so we can
|
|
6
|
+
// read each advisory's real severity instead of blanket-HIGH.
|
|
7
7
|
const https = require('https');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Sends an HTTPS request and resolves with the parsed JSON response body.
|
|
11
|
+
* Rejects on HTTP 4xx/5xx, JSON parse error, network error, or 15 s timeout.
|
|
12
|
+
* @param {string} method - HTTP method (e.g. 'POST').
|
|
13
|
+
* @param {string} host - Hostname (e.g. 'api.osv.dev').
|
|
14
|
+
* @param {string} pathname - URL path (e.g. '/v1/querybatch').
|
|
15
|
+
* @param {object|null} body - Request body, serialised as JSON, or null for no body.
|
|
16
|
+
* @returns {Promise<object>} Parsed response JSON.
|
|
17
|
+
*/
|
|
9
18
|
function request(method, host, pathname, body) {
|
|
10
19
|
return new Promise((resolve, reject) => {
|
|
11
20
|
const data = body ? Buffer.from(JSON.stringify(body)) : null;
|
|
@@ -27,19 +36,32 @@ function request(method, host, pathname, body) {
|
|
|
27
36
|
|
|
28
37
|
// CVSS v3 vector -> approximate numeric base score is non-trivial; instead bucket by the
|
|
29
38
|
// vector's impact + exploitability shape. Good enough to pick LOW/MEDIUM/HIGH/CRITICAL.
|
|
39
|
+
/**
|
|
40
|
+
* Approximates a severity bucket from a CVSS v3 vector string without full scoring math.
|
|
41
|
+
* Examines Confidentiality/Integrity/Availability impact and network/privilege/UI metrics.
|
|
42
|
+
* @param {string} vec - CVSS v3 vector string (e.g. 'CVSS:3.1/AV:N/AC:L/...').
|
|
43
|
+
* @returns {'CRITICAL'|'HIGH'|'MEDIUM'|'LOW'|null} Severity bucket, or null if unparseable.
|
|
44
|
+
*/
|
|
30
45
|
function bucketFromCvssVector(vec) {
|
|
31
46
|
if (!vec || typeof vec !== 'string') return null;
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const network =
|
|
35
|
-
const noPriv =
|
|
36
|
-
const noUI =
|
|
37
|
-
if (
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
47
|
+
const metrics = Object.fromEntries(vec.split('/').map((p) => p.split(':')).filter((a) => a.length === 2));
|
|
48
|
+
const impactHighCount = ['C', 'I', 'A'].filter((k) => metrics[k] === 'H').length;
|
|
49
|
+
const network = metrics.AV === 'N';
|
|
50
|
+
const noPriv = metrics.PR === 'N' || metrics.PR === undefined;
|
|
51
|
+
const noUI = metrics.UI === 'N' || metrics.UI === undefined;
|
|
52
|
+
if (impactHighCount >= 2 && network && noPriv && noUI) return 'CRITICAL';
|
|
53
|
+
if (impactHighCount >= 1 && network && noPriv) return 'HIGH';
|
|
54
|
+
if (impactHighCount >= 1) return 'MEDIUM';
|
|
40
55
|
return 'LOW';
|
|
41
56
|
}
|
|
42
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Derives a severity label for a single OSV vulnerability object.
|
|
60
|
+
* Tries database_specific.severity first, then numeric score, then CVSS vector bucketing.
|
|
61
|
+
* Defaults to MEDIUM when the data is ambiguous to avoid both over- and under-claiming.
|
|
62
|
+
* @param {object} v - OSV vulnerability object.
|
|
63
|
+
* @returns {'CRITICAL'|'HIGH'|'MEDIUM'|'LOW'}
|
|
64
|
+
*/
|
|
43
65
|
function severityOfVuln(v) {
|
|
44
66
|
const ds = v.database_specific && (v.database_specific.severity || v.database_specific.cvss);
|
|
45
67
|
if (typeof ds === 'string') {
|
|
@@ -58,14 +80,21 @@ function severityOfVuln(v) {
|
|
|
58
80
|
if (num >= 4) return 'MEDIUM';
|
|
59
81
|
return 'LOW';
|
|
60
82
|
}
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
83
|
+
const bucket = bucketFromCvssVector(s.score);
|
|
84
|
+
if (bucket) return bucket;
|
|
63
85
|
}
|
|
64
86
|
}
|
|
65
87
|
return 'MEDIUM'; // unknown -> don't over- or under-claim
|
|
66
88
|
}
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Queries OSV.dev for known advisories for a list of npm packages.
|
|
92
|
+
* Phase 1: batched querybatch to find which packages have any advisories.
|
|
93
|
+
* Phase 2: parallel per-package queries (up to 8 concurrent) to fetch full details.
|
|
94
|
+
* @param {{name:string, version:string}[]} pkgs - Packages to look up.
|
|
95
|
+
* @returns {Promise<Map<string, {id:string, severity:string, summary:string}[]>>}
|
|
96
|
+
* Map from "name@version" to list of advisory summaries.
|
|
97
|
+
*/
|
|
69
98
|
async function queryOSV(pkgs) {
|
|
70
99
|
const uniq = new Map();
|
|
71
100
|
for (const p of pkgs) if (p.name && p.version) uniq.set(`${p.name}@${p.version}`, p);
|
|
@@ -90,7 +119,7 @@ async function queryOSV(pkgs) {
|
|
|
90
119
|
const resp = await request('POST', 'api.osv.dev', '/v1/query', { package: { ecosystem: 'npm', name: p.name }, version: p.version });
|
|
91
120
|
const vulns = (resp.vulns || []).map((v) => ({ id: v.id, severity: severityOfVuln(v), summary: v.summary || '' }));
|
|
92
121
|
if (vulns.length) result.set(`${p.name}@${p.version}`, vulns);
|
|
93
|
-
} catch { /* skip this
|
|
122
|
+
} catch { /* skip this package if its individual query fails */ }
|
|
94
123
|
}
|
|
95
124
|
};
|
|
96
125
|
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, worker));
|
package/lib/scan.js
CHANGED
|
@@ -3,32 +3,62 @@ const { discoverStores, packagesInStore } = require('./stores');
|
|
|
3
3
|
const { runAll, SEV } = require('./checks');
|
|
4
4
|
const { queryOSV } = require('./osv');
|
|
5
5
|
|
|
6
|
+
/** Maps numeric severity level to display name. */
|
|
6
7
|
const SEV_NAME = { 4: 'CRITICAL', 3: 'HIGH', 2: 'MEDIUM', 1: 'LOW', 0: 'INFO' };
|
|
7
8
|
|
|
9
|
+
// Load the optional allowlist. Names ending with "/*" are treated as scope prefixes.
|
|
8
10
|
let ALLOW_EXACT = new Set(), ALLOW_SCOPES = [];
|
|
9
11
|
try {
|
|
10
12
|
const a = require('../data/allowlist.json');
|
|
11
13
|
for (const n of a.names || []) { if (n.endsWith('/*')) ALLOW_SCOPES.push(n.slice(0, -1)); else ALLOW_EXACT.add(n); }
|
|
12
14
|
} catch { /* allowlist optional */ }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns true if the given package name is on the static allowlist.
|
|
18
|
+
* @param {string} name - npm package name
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
13
21
|
function isAllowlisted(name) {
|
|
14
22
|
if (ALLOW_EXACT.has(name)) return true;
|
|
15
23
|
return ALLOW_SCOPES.some((s) => name.startsWith(s));
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Scan installed packages for supply-chain indicators of compromise.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {string} [opts.cwd] - Project root to scan (defaults to process.cwd()).
|
|
31
|
+
* @param {boolean} [opts.online] - If true, cross-check OSV.dev for known advisories.
|
|
32
|
+
* @param {number} [opts.minSev] - Minimum severity number to include (SEV.* constants).
|
|
33
|
+
* @param {number} [opts.maxDepth] - Max node_modules nesting depth passed to packagesInStore.
|
|
34
|
+
* @param {string[]} [opts.stores] - Allowlist of store kinds to scan; empty means all.
|
|
35
|
+
* @param {boolean} [opts.noAllowlist] - If true, skip allowlist downgrade logic.
|
|
36
|
+
* @param {Function} [opts.onPhase] - Progress callback receiving a status string.
|
|
37
|
+
* @returns {Promise<{cwd:string, stores:object[], totalPackages:number, totalScanned:number, findings:object[], osv:object|null}>}
|
|
38
|
+
*/
|
|
18
39
|
async function scan(opts = {}) {
|
|
19
40
|
const cwd = opts.cwd || process.cwd();
|
|
20
41
|
const stores = discoverStores(cwd);
|
|
21
42
|
const kindFilter = Array.isArray(opts.stores) && opts.stores.length ? new Set(opts.stores) : null;
|
|
22
43
|
const storeStats = [];
|
|
23
|
-
|
|
44
|
+
// Keyed by "name@version@dir" to deduplicate packages seen in the same location.
|
|
45
|
+
const seen = new Map();
|
|
24
46
|
const findings = [];
|
|
25
47
|
const allPkgList = [];
|
|
26
48
|
|
|
49
|
+
const tick = typeof opts.onPhase === 'function' ? opts.onPhase : () => {};
|
|
50
|
+
|
|
51
|
+
/** Yields to the event loop so spinner frames can render between heavy iterations. */
|
|
52
|
+
const yieldToLoop = () => new Promise((r) => setImmediate(r));
|
|
53
|
+
|
|
27
54
|
for (const store of stores) {
|
|
28
55
|
if (kindFilter && !kindFilter.has(store.kind)) continue;
|
|
56
|
+
tick(`scanning ${store.kind}`);
|
|
57
|
+
await yieldToLoop();
|
|
29
58
|
let count = 0;
|
|
30
59
|
for (const pkg of packagesInStore(store, { maxDepth: opts.maxDepth })) {
|
|
31
60
|
count++;
|
|
61
|
+
if (count % 100 === 0) { tick(`scanning ${store.kind} (${count} packages)`); await yieldToLoop(); }
|
|
32
62
|
const key = `${pkg.name}@${pkg.version}@${pkg.dir || store.root}`;
|
|
33
63
|
if (seen.has(key)) continue;
|
|
34
64
|
seen.set(key, pkg);
|
|
@@ -37,7 +67,7 @@ async function scan(opts = {}) {
|
|
|
37
67
|
const allow = !opts.noAllowlist && isAllowlisted(pkg.name);
|
|
38
68
|
for (const f of runAll(pkg)) {
|
|
39
69
|
let sev = f.sev, note;
|
|
40
|
-
//
|
|
70
|
+
// Allowlist downgrades benign-looking findings, but NEVER suppresses a hard worm
|
|
41
71
|
// IOC (worm-ioc-file / CRITICAL suspicious-js), since that's the compromise case.
|
|
42
72
|
if (allow && sev < SEV.CRITICAL) { sev = SEV.INFO; note = 'allowlisted package, heuristic likely benign; review only if context warrants'; }
|
|
43
73
|
if (sev < (opts.minSev ?? SEV.MEDIUM)) continue;
|
|
@@ -53,12 +83,14 @@ async function scan(opts = {}) {
|
|
|
53
83
|
let osv = null;
|
|
54
84
|
if (opts.online) {
|
|
55
85
|
try {
|
|
56
|
-
|
|
86
|
+
tick('cross-checking OSV.dev advisories');
|
|
87
|
+
const osvMap = await queryOSV(allPkgList);
|
|
57
88
|
osv = [];
|
|
58
89
|
const SEV_RANK = { CRITICAL: SEV.CRITICAL, HIGH: SEV.HIGH, MEDIUM: SEV.MEDIUM, LOW: SEV.LOW };
|
|
59
|
-
for (const [nv, vulns] of
|
|
90
|
+
for (const [nv, vulns] of osvMap) {
|
|
60
91
|
const [name, version] = splitNV(nv);
|
|
61
92
|
const top = vulns.reduce((m, v) => Math.max(m, SEV_RANK[v.severity] ?? SEV.MEDIUM), SEV.LOW);
|
|
93
|
+
// Build a "3 high, 1 medium" style breakdown string.
|
|
62
94
|
const bySev = {};
|
|
63
95
|
for (const v of vulns) bySev[v.severity] = (bySev[v.severity] || 0) + 1;
|
|
64
96
|
const breakdown = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].filter((k) => bySev[k]).map((k) => `${bySev[k]} ${k.toLowerCase()}`).join(', ');
|
|
@@ -94,6 +126,12 @@ async function scan(opts = {}) {
|
|
|
94
126
|
return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings: deduped, osv };
|
|
95
127
|
}
|
|
96
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Split a "name@version" key produced by queryOSV back into its parts.
|
|
131
|
+
* Uses the last "@" so scoped names like "@scope/pkg@1.0.0" work correctly.
|
|
132
|
+
* @param {string} nv - Combined name-at-version string.
|
|
133
|
+
* @returns {[string, string]} Tuple of [name, version].
|
|
134
|
+
*/
|
|
97
135
|
function splitNV(nv) { const i = nv.lastIndexOf('@'); return [nv.slice(0, i), nv.slice(i + 1)]; }
|
|
98
136
|
|
|
99
137
|
module.exports = { scan, SEV };
|
package/lib/stores.js
CHANGED
|
@@ -6,10 +6,33 @@ const path = require('path');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const cp = require('child_process');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Returns true if the path exists and is accessible, false otherwise.
|
|
11
|
+
* @param {string} p - Filesystem path to test.
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
9
14
|
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns directory entries for a path, or an empty array on any error.
|
|
18
|
+
* @param {string} p - Directory path.
|
|
19
|
+
* @returns {fs.Dirent[]}
|
|
20
|
+
*/
|
|
10
21
|
const readdir = (p) => { try { return fs.readdirSync(p, { withFileTypes: true }); } catch { return []; } };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs a shell command and returns its trimmed stdout, or '' on failure.
|
|
25
|
+
* @param {string} cmd - Shell command to execute.
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
11
28
|
const sh = (cmd) => { try { return cp.execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return ''; } };
|
|
12
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Finds all package-manager store roots relevant to the given working directory.
|
|
32
|
+
* Checks project-local node_modules, npm/pnpm/yarn/bun global and cache locations.
|
|
33
|
+
* @param {string} cwd - Project root directory.
|
|
34
|
+
* @returns {{kind:string, root:string, note:string}[]} List of discovered stores.
|
|
35
|
+
*/
|
|
13
36
|
function discoverStores(cwd) {
|
|
14
37
|
const home = os.homedir();
|
|
15
38
|
const out = [];
|
|
@@ -17,7 +40,7 @@ function discoverStores(cwd) {
|
|
|
17
40
|
|
|
18
41
|
add('project', path.join(cwd, 'node_modules'), 'current project node_modules');
|
|
19
42
|
add('pnpm-project', path.join(cwd, 'node_modules', '.pnpm'), 'pnpm in-project store');
|
|
20
|
-
const
|
|
43
|
+
const npmGlobal = sh('npm root -g'); if (npmGlobal) add('npm-global', npmGlobal, 'npm global packages');
|
|
21
44
|
add('npx-cache', path.join(home, '.npm', '_npx'), 'npx ephemeral installs');
|
|
22
45
|
add('bun-cache', path.join(home, '.bun', 'install', 'cache'), 'bun global cache');
|
|
23
46
|
add('bun-cache', path.join(home, '.cache', 'bun', 'install', 'cache'), 'bun cache (xdg)');
|
|
@@ -26,26 +49,47 @@ function discoverStores(cwd) {
|
|
|
26
49
|
path.join(home, '.local', 'share', 'pnpm', 'store'),
|
|
27
50
|
process.env.PNPM_HOME && path.join(process.env.PNPM_HOME, 'store'),
|
|
28
51
|
].filter(Boolean)) add('pnpm-store', p, 'pnpm global content store (count only)');
|
|
29
|
-
const
|
|
52
|
+
const yarnCache = sh('yarn cache dir'); if (yarnCache) add('yarn-cache', yarnCache, 'yarn v1 cache');
|
|
30
53
|
add('yarn-berry', path.join(cwd, '.yarn', 'cache'), 'yarn berry zip cache (names only)');
|
|
31
54
|
return out;
|
|
32
55
|
}
|
|
33
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Reads and parses the package.json inside a directory.
|
|
59
|
+
* @param {string} dir - Directory that should contain package.json.
|
|
60
|
+
* @returns {object|null} Parsed manifest, or null if missing or unparseable.
|
|
61
|
+
*/
|
|
34
62
|
function readManifest(dir) {
|
|
35
63
|
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
|
|
36
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Builds a pkg object from a directory and a store, or null if no valid manifest.
|
|
68
|
+
* @param {string} dir - Extracted package directory.
|
|
69
|
+
* @param {{kind:string, root:string, note:string}} store - Parent store object.
|
|
70
|
+
* @returns {{name:string, version:string, dir:string, store:object, manifest:object}|null}
|
|
71
|
+
*/
|
|
37
72
|
function pkgFromDir(dir, store) {
|
|
38
73
|
const m = readManifest(dir);
|
|
39
74
|
if (!m) return null;
|
|
40
75
|
return { name: m.name || path.basename(dir), version: m.version || '', dir, store, manifest: m };
|
|
41
76
|
}
|
|
42
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Recursively yields pkg objects from a node_modules tree, handling scoped packages.
|
|
80
|
+
* @param {string} nmDir - node_modules directory to walk.
|
|
81
|
+
* @param {{kind:string, root:string, note:string}} store - Owning store.
|
|
82
|
+
* @param {number} maxDepth - Maximum recursion depth for nested node_modules.
|
|
83
|
+
* @param {number} [depth=0] - Current recursion depth.
|
|
84
|
+
* @yields {{name:string, version:string, dir:string, store:object, manifest:object}}
|
|
85
|
+
*/
|
|
43
86
|
function* walkTree(nmDir, store, maxDepth, depth = 0) {
|
|
44
87
|
if (depth > maxDepth || !exists(nmDir)) return;
|
|
45
88
|
for (const ent of readdir(nmDir)) {
|
|
46
89
|
if (ent.name.startsWith('.')) continue;
|
|
47
90
|
const full = path.join(nmDir, ent.name);
|
|
48
91
|
if (ent.name.startsWith('@')) {
|
|
92
|
+
// scoped packages: one more level for "@scope/pkg-name"
|
|
49
93
|
for (const sub of readdir(full)) {
|
|
50
94
|
const d = path.join(full, sub.name);
|
|
51
95
|
const p = pkgFromDir(d, store); if (p) yield p;
|
|
@@ -58,6 +102,14 @@ function* walkTree(nmDir, store, maxDepth, depth = 0) {
|
|
|
58
102
|
}
|
|
59
103
|
}
|
|
60
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Yields every pkg found in the given store, adapting traversal to the store kind.
|
|
107
|
+
* Handles bun-cache, yarn-berry (zip names only), pnpm-store (skipped, content-addressed),
|
|
108
|
+
* npx-cache, pnpm-project, and conventional node_modules trees.
|
|
109
|
+
* @param {{kind:string, root:string, note:string}} store - Store to enumerate.
|
|
110
|
+
* @param {{maxDepth?:number}} [opts={}] - Options; maxDepth limits node_modules nesting.
|
|
111
|
+
* @yields {{name:string, version:string, dir:string|null, store:object, manifest?:object}}
|
|
112
|
+
*/
|
|
61
113
|
function* packagesInStore(store, opts = {}) {
|
|
62
114
|
const maxDepth = opts.maxDepth || 12;
|
|
63
115
|
switch (store.kind) {
|
|
@@ -72,6 +124,7 @@ function* packagesInStore(store, opts = {}) {
|
|
|
72
124
|
return;
|
|
73
125
|
}
|
|
74
126
|
case 'yarn-berry': {
|
|
127
|
+
// zip archives: parse name and version from filename, no extracted tree to walk
|
|
75
128
|
for (const ent of readdir(store.root)) {
|
|
76
129
|
if (ent.isFile() && ent.name.endsWith('.zip')) {
|
|
77
130
|
const m = ent.name.match(/^(.*)-npm-(.+)-[a-f0-9]{8,}\.zip$/);
|
|
@@ -83,6 +136,7 @@ function* packagesInStore(store, opts = {}) {
|
|
|
83
136
|
case 'pnpm-store': return; // content-addressed blobs; not extracted trees
|
|
84
137
|
case 'npx-cache':
|
|
85
138
|
case 'pnpm-project': {
|
|
139
|
+
// each subdirectory is a separate install hash; node_modules lives one level in
|
|
86
140
|
for (const ent of readdir(store.root)) {
|
|
87
141
|
if (ent.isDirectory()) yield* walkTree(path.join(store.root, ent.name, 'node_modules'), store, maxDepth);
|
|
88
142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pkgradar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Content-based supply-chain scanner for npm/pnpm/yarn/bun: inspects the bytes you actually installed (lifecycle hooks, obfuscated payloads, worm IOCs) instead of just matching package names against an advisory list.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pkgradar": "bin/pkgradar.js"
|