pkgradar 0.1.2 → 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
@@ -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
@@ -49,32 +62,63 @@ 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
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
+ */
58
86
  function sevCell(sev, width) {
59
87
  const bg = { CRITICAL: '41', HIGH: '45', MEDIUM: '43', LOW: '100', INFO: '100' }[sev] || '100';
60
88
  const fg = sev === 'MEDIUM' ? '30' : '97';
61
89
  const label = sev.padStart((width + sev.length) >> 1).padEnd(width);
62
90
  return COLOR ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : label;
63
91
  }
92
+
93
+ /** Maps severity name to a colouring function for plain-text severity mentions. */
64
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
+ */
65
101
  const homeShort = (p) => (p && p.startsWith(os.homedir())) ? '~' + p.slice(os.homedir().length) : p;
66
102
 
67
103
  // ----------------------------------------------------------------------------
68
104
  // foreground spinner (writes to stderr so stdout stays pipe-clean)
69
105
  // ----------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Creates a TTY spinner that writes to stderr.
109
+ * @returns {{start: Function, set: Function, stop: Function}}
110
+ */
70
111
  function makeSpinner() {
71
112
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
72
113
  const on = process.stderr.isTTY && !process.env.NO_COLOR;
73
114
  let i = 0, msg = 'starting', timer = null;
74
115
  const render = () => process.stderr.write(`\r\x1b[2K${C.cyan(frames[i = (i + 1) % frames.length])} ${C.gray(msg + ' …')}`);
75
116
  return {
117
+ /** Start the spinner interval. */
76
118
  start() { if (!on) return; timer = setInterval(render, 80); render(); },
119
+ /** Update the status message shown next to the spinner. @param {string} m */
77
120
  set(m) { msg = m; },
121
+ /** Stop the spinner and clear the line. */
78
122
  stop() { if (timer) clearInterval(timer); if (on) process.stderr.write('\r\x1b[2K'); },
79
123
  };
80
124
  }
@@ -82,13 +126,28 @@ function makeSpinner() {
82
126
  // ----------------------------------------------------------------------------
83
127
  // table renderer
84
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
+ */
85
136
  const clip = (s, n) => { s = String(s == null ? '' : s); return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'; };
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
+ */
86
144
  const padTo = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
87
145
 
88
146
  /**
89
147
  * 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
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).
92
151
  */
93
152
  function table(cols, rows) {
94
153
  const B = COLOR ? C.gray : (s) => s;
@@ -100,7 +159,7 @@ function table(cols, rows) {
100
159
  for (const row of rows) {
101
160
  const cells = cols.map((c) => {
102
161
  const v = clip(row[c.key], c.width);
103
- if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
162
+ if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
104
163
  const txt = padTo(v, c.width);
105
164
  return ' ' + (c.color ? c.color(txt) : txt) + ' ';
106
165
  });
@@ -113,6 +172,7 @@ function table(cols, rows) {
113
172
  // ----------------------------------------------------------------------------
114
173
  // main
115
174
  // ----------------------------------------------------------------------------
175
+ /** Entry point: parses CLI flags, calls scan(), then renders results to stdout. */
116
176
  (async () => {
117
177
  const spin = makeSpinner();
118
178
  spin.start();
@@ -145,6 +205,8 @@ function table(cols, rows) {
145
205
  const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
146
206
  for (const f of res.findings) counts[f.severity]++;
147
207
  const worst = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].find((k) => counts[k]) || null;
208
+
209
+ // Shorthand to write a line to stdout.
148
210
  const p = (...a) => process.stdout.write(a.join(' ') + '\n');
149
211
 
150
212
  // ---- header ----
@@ -186,7 +248,7 @@ function table(cols, rows) {
186
248
  for (const f of sorted) {
187
249
  p(` ${sevCell(f.severity, 10)} ${C.bold(f.package + '@' + f.version)} ${C.gray(f.store)} ${C.cyan(f.code)}`);
188
250
  p(` ${f.message}`);
189
- if (f.evidence) p(` ${C.gray(' ' + f.evidence)}`);
251
+ if (f.evidence) p(` ${C.gray('-> ' + f.evidence)}`);
190
252
  if (f.note) p(` ${C.gray('note: ' + f.note)}`);
191
253
  if (f.dir) p(` ${C.gray(homeShort(f.dir))}`);
192
254
  p('');
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.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"