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 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
- const args = process.argv.slice(2);
7
- const has = (f) => args.includes(f);
8
- const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
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
- console.log(`pkgradar - content-based supply-chain scanner for npm / pnpm / yarn / bun
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
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
44
- const e = (code) => useColor ? `\x1b[${code}m` : '';
45
- const wrap = (code) => (s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : `${s}`;
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: wrap('1'), dim: wrap('2'), red: wrap('31'), grn: wrap('32'), ylw: wrap('33'),
48
- blu: wrap('34'), mag: wrap('35'), cyan: wrap('36'), gray: wrap('90'),
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
- // severity badge: white text on a coloured background, padded
51
- const badge = (sev) => {
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 = ` ${sev} `.padEnd(10);
55
- return useColor ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : `[${sev}]`.padEnd(10);
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
- function rule(ch = '─', n = 64) { return C.gray(ch.repeat(n)); }
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 started = Date.now();
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
- console.error(C.red('pkgradar error: ') + (err && err.stack || err));
193
+ spin.stop();
194
+ process.stderr.write(C.red('pkgradar error: ') + (err && err.stack || err) + '\n');
76
195
  process.exit(2);
77
196
  }
78
- const secs = ((Date.now() - started) / 1000).toFixed(1);
197
+ spin.stop();
198
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
79
199
 
80
200
  if (has('--json')) {
81
- console.log(JSON.stringify({ ...res, durationSeconds: Number(secs) }, null, 2));
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
- console.log('');
94
- console.log(` ${C.bold('pkgradar')} ${C.gray('v' + require('../package.json').version)} ${C.gray('content-based supply-chain scan')}`);
95
- console.log(` ${C.gray('project')} ${homeShort(res.cwd)} ${C.gray(res.stores.length + ' stores, ' + secs + 's')}`);
96
- console.log('');
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
- const nameW = Math.max(...res.stores.map((s) => s.kind.length), 6);
100
- for (const s of res.stores) {
101
- console.log(` ${C.cyan(s.kind.padEnd(nameW))} ${C.dim(String(s.packages).padStart(5) + ' pkgs')} ${C.gray(homeShort(s.root))}`);
102
- }
103
- console.log(` ${C.gray('total:')} ${C.bold(res.totalPackages)} ${C.gray('unique package@version,')} ${C.bold(res.totalScanned)} ${C.gray('locations inspected')}`);
104
- if (res.osv && res.osv.error) console.log(` ${C.ylw('OSV lookup failed:')} ${C.dim(res.osv.error)}`);
105
- if (!res.osv && !has('--online')) console.log(` ${C.gray('tip: add')} ${C.dim('--online')} ${C.gray('to also cross-check known advisories on OSV.dev')}`);
106
- console.log('');
107
-
108
- // ---- verdict line ----
109
- if (!hits) {
110
- console.log(` ${C.grn('')} ${C.bold('Verdict:')} ${C.grn('clean')} ${C.gray('- nothing tripped the heuristics at')} ${C.dim(minSevName)} ${C.gray('and above')}`);
111
- console.log(` ${C.gray(' (this inspects installed bytes; a clean run is not a proof of safety)')}`);
112
- console.log('');
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 summaryBits = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].filter((k) => counts[k]).map((k) => SEV_TEXT[k](`${counts[k]} ${k.toLowerCase()}`));
116
- const dot = worst === 'CRITICAL' || worst === 'HIGH' ? C.red('●') : worst === 'MEDIUM' ? C.ylw('●') : C.gray('●');
117
- console.log(` ${dot} ${C.bold('Verdict:')} ${C.bold(hits + (hits === 1 ? ' finding' : ' findings'))} ${C.gray('(')}${summaryBits.join(C.gray(', '))}${C.gray(')')}`);
118
- if (localFindings.length && osvFindings.length) {
119
- console.log(` ${C.gray(' ' + localFindings.length + ' from inspecting installed code, ' + osvFindings.length + ' known advisories (OSV.dev)')}`);
120
- }
121
- console.log('');
122
-
123
- // ---- findings, grouped by severity, local first then OSV ----
124
- const ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
125
- const printGroup = (title, list) => {
126
- if (!list.length) return;
127
- console.log(` ${rule()}`);
128
- console.log(` ${C.bold(title)}`);
129
- console.log('');
130
- list.sort((a, b) => b.sevNum - a.sevNum || a.package.localeCompare(b.package));
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
- printGroup('From inspecting installed code', localFindings);
141
- printGroup('Known advisories (OSV.dev)', osvFindings);
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
- // ---- next steps ----
144
- console.log(` ${rule()}`);
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
- console.log(` ${C.red('•')} Treat ${C.bold('CRITICAL / HIGH "installed code" findings')} as suspect: do not run install`);
148
- console.log(` scripts, remove the package, clear the relevant cache, and rotate any npm /`);
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
- console.log(` ${C.gray('•')} Check a flagged version against the registry: ${C.dim('npm view <pkg> versions')} ${C.gray('and look at')}`);
152
- console.log(` ${C.gray('publish dates and provenance.')}`);
153
- if (osvFindings.length) console.log(` ${C.gray('•')} OSV findings are the usual "known CVE in a dependency" set: ${C.dim('npm audit fix')} ${C.gray('/ bump.')}`);
154
- if (!has('--online')) console.log(` ${C.gray('•')} Re-run with ${C.dim('--online')} ${C.gray('to add OSV.dev advisory matching by exact version.')}`);
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 security
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
- for (const t of [...NET_TOKENS, ...EXEC_TOKENS, ...ENCODE_TOKENS]) if (cmd.includes(t)) hits.add(t);
65
- for (const t of SECRET_NAMES) if (cmd.toUpperCase().includes(t)) hits.add(t);
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 worth a glance, not an alarm
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, ) can't
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 = []; // weak reasons only matter in combination
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 precise (package, version) matching,
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 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.
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 m = Object.fromEntries(vec.split('/').map((p) => p.split(':')).filter((a) => a.length === 2));
33
- const impactHigh = ['C', 'I', 'A'].filter((k) => m[k] === 'H').length;
34
- const network = m.AV === 'N';
35
- const noPriv = m.PR === 'N' || m.PR === undefined;
36
- const noUI = m.UI === 'N' || m.UI === undefined;
37
- if (impactHigh >= 2 && network && noPriv && noUI) return 'CRITICAL';
38
- if (impactHigh >= 1 && network && noPriv) return 'HIGH';
39
- if (impactHigh >= 1) return 'MEDIUM';
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 b = bucketFromCvssVector(s.score);
62
- if (b) return b;
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
- // pkgs: [{name, version}]. Returns Map "name@version" -> [{ id, severity, summary }]
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 one */ }
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
- const seen = new Map(); // "name@version@dir" -> pkg (dedupe)
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
- // allowlist downgrades benign-looking findings but NEVER suppresses a hard worm
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
- const map = await queryOSV(allPkgList);
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 map) {
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 g = sh('npm root -g'); if (g) add('npm-global', g, 'npm global packages');
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 yc = sh('yarn cache dir'); if (yc) add('yarn-cache', yc, 'yarn v1 cache');
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.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"