pkgradar 0.1.2 → 0.1.4

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
@@ -8,8 +8,21 @@ const PKG = require('../package.json');
8
8
  // args
9
9
  // ----------------------------------------------------------------------------
10
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
+ */
11
17
  const has = (f) => argv.includes(f);
12
- const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
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; };
13
26
 
14
27
  if (has('-h') || has('--help')) {
15
28
  process.stdout.write(`pkgradar - content-based supply-chain scanner for npm / pnpm / yarn / bun
@@ -24,7 +37,7 @@ USAGE
24
37
  OPTIONS
25
38
  --online also cross-reference OSV.dev for known advisories (network)
26
39
  --json machine-readable output
27
- --full expanded per-finding output instead of the summary table
40
+ --compact one terse line per finding instead of full blocks
28
41
  --min-sev LEVEL report findings at or above LEVEL
29
42
  critical | high | medium | low | info [default: medium]
30
43
  --stores LIST comma list to limit which stores are scanned
@@ -49,32 +62,50 @@ const minSev = SEV_FROM_NAME[minSevName] ?? SEV.MEDIUM;
49
62
  // colour helpers
50
63
  // ----------------------------------------------------------------------------
51
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
+ */
52
72
  const paint = (code) => (s) => COLOR ? `\x1b[${code}m${s}\x1b[0m` : `${s}`;
73
+
53
74
  const C = {
54
75
  bold: paint('1'), dim: paint('2'), red: paint('31'), grn: paint('32'), ylw: paint('33'),
55
76
  blu: paint('34'), mag: paint('35'), cyan: paint('36'), gray: paint('90'),
56
77
  };
57
- // coloured " CRITICAL " cell: white-on-colour, fixed width
58
- function sevCell(sev, width) {
59
- const bg = { CRITICAL: '41', HIGH: '45', MEDIUM: '43', LOW: '100', INFO: '100' }[sev] || '100';
60
- const fg = sev === 'MEDIUM' ? '30' : '97';
61
- const label = sev.padStart((width + sev.length) >> 1).padEnd(width);
62
- return COLOR ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : label;
63
- }
78
+
79
+
80
+ /** Maps severity name to a colouring function for plain-text severity mentions. */
64
81
  const SEV_TEXT = { CRITICAL: C.red, HIGH: C.mag, MEDIUM: C.ylw, LOW: C.gray, INFO: C.gray };
82
+
83
+ /**
84
+ * Replaces the home-directory prefix of a path with "~" for compact display.
85
+ * @param {string|null} p - Absolute path, or null/undefined.
86
+ * @returns {string|null}
87
+ */
65
88
  const homeShort = (p) => (p && p.startsWith(os.homedir())) ? '~' + p.slice(os.homedir().length) : p;
66
89
 
67
90
  // ----------------------------------------------------------------------------
68
91
  // foreground spinner (writes to stderr so stdout stays pipe-clean)
69
92
  // ----------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Creates a TTY spinner that writes to stderr.
96
+ * @returns {{start: Function, set: Function, stop: Function}}
97
+ */
70
98
  function makeSpinner() {
71
99
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
72
100
  const on = process.stderr.isTTY && !process.env.NO_COLOR;
73
101
  let i = 0, msg = 'starting', timer = null;
74
102
  const render = () => process.stderr.write(`\r\x1b[2K${C.cyan(frames[i = (i + 1) % frames.length])} ${C.gray(msg + ' …')}`);
75
103
  return {
104
+ /** Start the spinner interval. */
76
105
  start() { if (!on) return; timer = setInterval(render, 80); render(); },
106
+ /** Update the status message shown next to the spinner. @param {string} m */
77
107
  set(m) { msg = m; },
108
+ /** Stop the spinner and clear the line. */
78
109
  stop() { if (timer) clearInterval(timer); if (on) process.stderr.write('\r\x1b[2K'); },
79
110
  };
80
111
  }
@@ -82,13 +113,28 @@ function makeSpinner() {
82
113
  // ----------------------------------------------------------------------------
83
114
  // table renderer
84
115
  // ----------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Clip string s to at most n chars, appending "…" if truncated.
119
+ * @param {*} s - Value to stringify and clip.
120
+ * @param {number} n - Maximum character count.
121
+ * @returns {string}
122
+ */
85
123
  const clip = (s, n) => { s = String(s == null ? '' : s); return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'; };
124
+
125
+ /**
126
+ * Pad string s with trailing spaces to exactly n characters.
127
+ * @param {string} s
128
+ * @param {number} n
129
+ * @returns {string}
130
+ */
86
131
  const padTo = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
87
132
 
88
133
  /**
89
134
  * Render an array of row objects as a box-drawn table.
90
- * @param {{key:string,label:string,width:number,color?:Function,raw?:boolean}[]} cols
91
- * @param {object[]} rows
135
+ * @param {{key:string, label:string, width:number, color?:Function, raw?:boolean}[]} cols - Column definitions.
136
+ * @param {object[]} rows - Row data objects keyed by col.key.
137
+ * @returns {string} Rendered table string (no trailing newline).
92
138
  */
93
139
  function table(cols, rows) {
94
140
  const B = COLOR ? C.gray : (s) => s;
@@ -100,7 +146,7 @@ function table(cols, rows) {
100
146
  for (const row of rows) {
101
147
  const cells = cols.map((c) => {
102
148
  const v = clip(row[c.key], c.width);
103
- if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
149
+ if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
104
150
  const txt = padTo(v, c.width);
105
151
  return ' ' + (c.color ? c.color(txt) : txt) + ' ';
106
152
  });
@@ -110,9 +156,45 @@ function table(cols, rows) {
110
156
  return out.join('\n');
111
157
  }
112
158
 
159
+ /**
160
+ * Word-wrap a plain string to a column width, returning an array of lines.
161
+ * Tokens longer than the width are hard-split so nothing overflows.
162
+ * @param {string} text - Text to wrap (no ANSI codes).
163
+ * @param {number} width - Max characters per line.
164
+ * @returns {string[]}
165
+ */
166
+ function wrap(text, width) {
167
+ const words = String(text || '').split(/\s+/).filter(Boolean);
168
+ const lines = [];
169
+ let cur = '';
170
+ for (let w of words) {
171
+ while (w.length > width) { if (cur) { lines.push(cur); cur = ''; } lines.push(w.slice(0, width)); w = w.slice(width); }
172
+ if (!cur) cur = w;
173
+ else if (cur.length + 1 + w.length <= width) cur += ' ' + w;
174
+ else { lines.push(cur); cur = w; }
175
+ }
176
+ if (cur) lines.push(cur);
177
+ return lines.length ? lines : [''];
178
+ }
179
+
180
+ /** One-line plain-language explanation of why each finding type matters. */
181
+ const WHY = {
182
+ 'lifecycle-hook': 'install hooks run automatically on npm/yarn/pnpm install; this one does something a normal build step would not',
183
+ 'worm-ioc-file': 'this filename is a known artifact of the Shai-Hulud worm family',
184
+ 'suspicious-js': 'code that decodes and then executes a payload, or hard-coded worm exfiltration constants',
185
+ 'embedded-gh-workflow': 'harmless on its own; just noting the package ships a CI workflow file',
186
+ 'malicious-gh-workflow': 'a bundled GitHub Actions workflow that pipes a remote script straight into a shell',
187
+ 'odd-bin': 'a bin entry pointing outside the package or at a shell script is unusual for a dependency',
188
+ 'osv-advisory': 'a publicly known vulnerability affects this exact version; check whether a patched release exists',
189
+ };
190
+
191
+ /** ●-marker colour by severity, used at the start of each finding block. */
192
+ const SEV_DOT = { CRITICAL: C.red('●'), HIGH: C.mag('●'), MEDIUM: C.ylw('●'), LOW: C.gray('●'), INFO: C.gray('○') };
193
+
113
194
  // ----------------------------------------------------------------------------
114
195
  // main
115
196
  // ----------------------------------------------------------------------------
197
+ /** Entry point: parses CLI flags, calls scan(), then renders results to stdout. */
116
198
  (async () => {
117
199
  const spin = makeSpinner();
118
200
  spin.start();
@@ -145,6 +227,8 @@ function table(cols, rows) {
145
227
  const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
146
228
  for (const f of res.findings) counts[f.severity]++;
147
229
  const worst = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].find((k) => counts[k]) || null;
230
+
231
+ // Shorthand to write a line to stdout.
148
232
  const p = (...a) => process.stdout.write(a.join(' ') + '\n');
149
233
 
150
234
  // ---- header ----
@@ -180,41 +264,41 @@ function table(cols, rows) {
180
264
  p('');
181
265
 
182
266
  // ---- findings ----
267
+ // Grouped by severity, one readable block per finding. A fixed-width table would
268
+ // truncate the detail text, so we wrap it to the terminal instead.
183
269
  const sorted = res.findings.slice().sort((a, b) => b.sevNum - a.sevNum || a.code.localeCompare(b.code) || a.package.localeCompare(b.package));
270
+ const term = Math.max(60, Math.min(process.stdout.columns || 100, 120));
271
+ const ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
184
272
 
185
- if (has('--full')) {
273
+ if (has('--compact')) {
274
+ // one terse line per finding
275
+ const pkgW = Math.min(34, Math.max(...sorted.map((f) => (f.package + '@' + f.version).length)));
186
276
  for (const f of sorted) {
187
- p(` ${sevCell(f.severity, 10)} ${C.bold(f.package + '@' + f.version)} ${C.gray(f.store)} ${C.cyan(f.code)}`);
188
- p(` ${f.message}`);
189
- if (f.evidence) p(` ${C.gray('→ ' + f.evidence)}`);
190
- if (f.note) p(` ${C.gray('note: ' + f.note)}`);
191
- if (f.dir) p(` ${C.gray(homeShort(f.dir))}`);
192
- p('');
277
+ p(` ${SEV_DOT[f.severity]} ${SEV_TEXT[f.severity](f.severity.padEnd(8))} ${C.bold(padTo(clip(f.package + '@' + f.version, pkgW), pkgW))} ${C.cyan(padTo(f.code, 21))} ${C.gray(clip(f.evidence || f.message, term - pkgW - 36))}`);
193
278
  }
194
279
  } else {
195
- const term = process.stdout.columns || 120;
196
- const W_PKG = 28, W_TYPE = 21, W_WHERE = 16;
197
- const W_DETAIL = Math.max(24, term - (2 + 3 + 10 + W_PKG + W_TYPE + W_WHERE + 3 * 5 + 1));
198
- const SHOW = 60;
199
- const rows = sorted.slice(0, SHOW).map((f) => ({
200
- sev: sevCell(f.severity, 10),
201
- pkg: f.package + '@' + f.version,
202
- type: f.code,
203
- where: f.store,
204
- detail: (f.evidence ? f.evidence + ' ' : '') + f.message,
205
- }));
206
- p(table(
207
- [
208
- { key: 'sev', label: 'SEVERITY', width: 10, raw: true },
209
- { key: 'pkg', label: 'PACKAGE', width: W_PKG, color: C.bold },
210
- { key: 'type', label: 'TYPE', width: W_TYPE, color: C.cyan },
211
- { key: 'where', label: 'WHERE', width: W_WHERE, color: C.dim },
212
- { key: 'detail', label: 'DETAIL', width: W_DETAIL, color: C.gray },
213
- ],
214
- rows,
215
- ));
216
- if (sorted.length > SHOW) p(` ${C.gray('... and ' + (sorted.length - SHOW) + ' more (use --json for the full list)')}`);
217
- p(` ${C.gray('use')} ${C.dim('--full')} ${C.gray('for untruncated details and on-disk paths')}`);
280
+ const FW = term - 13; // width available for wrapped field text
281
+ /** Print a labelled, word-wrapped field under a finding (label only on the first line). */
282
+ const field = (key, text, paint) => {
283
+ if (text == null || text === '') return;
284
+ wrap(text, FW).forEach((ln, i) => p(` ${C.gray((i === 0 ? key : '').padEnd(9))} ${paint ? paint(ln) : ln}`));
285
+ };
286
+ for (const sev of ORDER) {
287
+ const group = sorted.filter((f) => f.severity === sev);
288
+ if (!group.length) continue;
289
+ p(` ${SEV_TEXT[sev](C.bold(sev))} ${C.gray('· ' + group.length)}`);
290
+ p(` ${C.gray('─'.repeat(term - 2))}`);
291
+ for (const f of group) {
292
+ p(` ${SEV_DOT[sev]} ${C.bold(f.package + '@' + f.version)} ${C.cyan(f.code)} ${C.gray('in ' + f.store)}`);
293
+ field('what', f.message);
294
+ field('evidence', f.evidence, C.gray);
295
+ field('why', WHY[f.code], C.gray);
296
+ field('note', f.note, C.gray);
297
+ if (f.dir) p(` ${C.gray('path'.padEnd(9))} ${C.gray(homeShort(f.dir))}`);
298
+ p('');
299
+ }
300
+ }
301
+ p(` ${C.gray('use')} ${C.dim('--compact')} ${C.gray('for a one-line-per-finding view, or')} ${C.dim('--json')} ${C.gray('for machine output')}`);
218
302
  }
219
303
  p('');
220
304
 
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,28 +3,52 @@ 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
 
27
49
  const tick = typeof opts.onPhase === 'function' ? opts.onPhase : () => {};
50
+
51
+ /** Yields to the event loop so spinner frames can render between heavy iterations. */
28
52
  const yieldToLoop = () => new Promise((r) => setImmediate(r));
29
53
 
30
54
  for (const store of stores) {
@@ -43,7 +67,7 @@ async function scan(opts = {}) {
43
67
  const allow = !opts.noAllowlist && isAllowlisted(pkg.name);
44
68
  for (const f of runAll(pkg)) {
45
69
  let sev = f.sev, note;
46
- // allowlist downgrades benign-looking findings but NEVER suppresses a hard worm
70
+ // Allowlist downgrades benign-looking findings, but NEVER suppresses a hard worm
47
71
  // IOC (worm-ioc-file / CRITICAL suspicious-js), since that's the compromise case.
48
72
  if (allow && sev < SEV.CRITICAL) { sev = SEV.INFO; note = 'allowlisted package, heuristic likely benign; review only if context warrants'; }
49
73
  if (sev < (opts.minSev ?? SEV.MEDIUM)) continue;
@@ -60,12 +84,13 @@ async function scan(opts = {}) {
60
84
  if (opts.online) {
61
85
  try {
62
86
  tick('cross-checking OSV.dev advisories');
63
- const map = await queryOSV(allPkgList);
87
+ const osvMap = await queryOSV(allPkgList);
64
88
  osv = [];
65
89
  const SEV_RANK = { CRITICAL: SEV.CRITICAL, HIGH: SEV.HIGH, MEDIUM: SEV.MEDIUM, LOW: SEV.LOW };
66
- for (const [nv, vulns] of map) {
90
+ for (const [nv, vulns] of osvMap) {
67
91
  const [name, version] = splitNV(nv);
68
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.
69
94
  const bySev = {};
70
95
  for (const v of vulns) bySev[v.severity] = (bySev[v.severity] || 0) + 1;
71
96
  const breakdown = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].filter((k) => bySev[k]).map((k) => `${bySev[k]} ${k.toLowerCase()}`).join(', ');
@@ -101,6 +126,12 @@ async function scan(opts = {}) {
101
126
  return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings: deduped, osv };
102
127
  }
103
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
+ */
104
135
  function splitNV(nv) { const i = nv.lastIndexOf('@'); return [nv.slice(0, i), nv.slice(i + 1)]; }
105
136
 
106
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.2",
3
+ "version": "0.1.4",
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"