pkgradar 0.1.0 → 0.1.2

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,17 @@
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
+ 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; };
9
13
 
10
14
  if (has('-h') || has('--help')) {
11
- console.log(`pkgradar - content-based supply-chain scanner for npm / pnpm / yarn / bun
15
+ process.stdout.write(`pkgradar - content-based supply-chain scanner for npm / pnpm / yarn / bun
12
16
 
13
17
  It opens the package files you actually installed and looks for what malware
14
18
  does (install hooks, obfuscated payloads, known worm artifacts), instead of
@@ -20,6 +24,7 @@ USAGE
20
24
  OPTIONS
21
25
  --online also cross-reference OSV.dev for known advisories (network)
22
26
  --json machine-readable output
27
+ --full expanded per-finding output instead of the summary table
23
28
  --min-sev LEVEL report findings at or above LEVEL
24
29
  critical | high | medium | low | info [default: medium]
25
30
  --stores LIST comma list to limit which stores are scanned
@@ -40,28 +45,80 @@ const SEV_FROM_NAME = { critical: SEV.CRITICAL, high: SEV.HIGH, medium: SEV.MEDI
40
45
  const minSevName = (val('--min-sev', 'medium') || 'medium').toLowerCase();
41
46
  const minSev = SEV_FROM_NAME[minSevName] ?? SEV.MEDIUM;
42
47
 
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}`;
48
+ // ----------------------------------------------------------------------------
49
+ // colour helpers
50
+ // ----------------------------------------------------------------------------
51
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
52
+ const paint = (code) => (s) => COLOR ? `\x1b[${code}m${s}\x1b[0m` : `${s}`;
46
53
  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'),
54
+ bold: paint('1'), dim: paint('2'), red: paint('31'), grn: paint('32'), ylw: paint('33'),
55
+ blu: paint('34'), mag: paint('35'), cyan: paint('36'), gray: paint('90'),
49
56
  };
50
- // severity badge: white text on a coloured background, padded
51
- const badge = (sev) => {
57
+ // coloured " CRITICAL " cell: white-on-colour, fixed width
58
+ function sevCell(sev, width) {
52
59
  const bg = { CRITICAL: '41', HIGH: '45', MEDIUM: '43', LOW: '100', INFO: '100' }[sev] || '100';
53
60
  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
- };
61
+ const label = sev.padStart((width + sev.length) >> 1).padEnd(width);
62
+ return COLOR ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : label;
63
+ }
57
64
  const SEV_TEXT = { CRITICAL: C.red, HIGH: C.mag, MEDIUM: C.ylw, LOW: C.gray, INFO: C.gray };
58
65
  const homeShort = (p) => (p && p.startsWith(os.homedir())) ? '~' + p.slice(os.homedir().length) : p;
59
66
 
60
- function rule(ch = '─', n = 64) { return C.gray(ch.repeat(n)); }
67
+ // ----------------------------------------------------------------------------
68
+ // foreground spinner (writes to stderr so stdout stays pipe-clean)
69
+ // ----------------------------------------------------------------------------
70
+ function makeSpinner() {
71
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
72
+ const on = process.stderr.isTTY && !process.env.NO_COLOR;
73
+ let i = 0, msg = 'starting', timer = null;
74
+ const render = () => process.stderr.write(`\r\x1b[2K${C.cyan(frames[i = (i + 1) % frames.length])} ${C.gray(msg + ' …')}`);
75
+ return {
76
+ start() { if (!on) return; timer = setInterval(render, 80); render(); },
77
+ set(m) { msg = m; },
78
+ stop() { if (timer) clearInterval(timer); if (on) process.stderr.write('\r\x1b[2K'); },
79
+ };
80
+ }
81
+
82
+ // ----------------------------------------------------------------------------
83
+ // table renderer
84
+ // ----------------------------------------------------------------------------
85
+ const clip = (s, n) => { s = String(s == null ? '' : s); return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'; };
86
+ const padTo = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
87
+
88
+ /**
89
+ * 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
92
+ */
93
+ function table(cols, rows) {
94
+ const B = COLOR ? C.gray : (s) => s;
95
+ const line = (l, m, r) => B(l + cols.map((c) => '─'.repeat(c.width + 2)).join(m) + r);
96
+ const out = [];
97
+ out.push(' ' + line('┌', '┬', '┐'));
98
+ out.push(' ' + B('│') + cols.map((c) => ' ' + C.bold(padTo(clip(c.label, c.width), c.width)) + ' ').join(B('│')) + B('│'));
99
+ out.push(' ' + line('├', '┼', '┤'));
100
+ for (const row of rows) {
101
+ const cells = cols.map((c) => {
102
+ const v = clip(row[c.key], c.width);
103
+ if (c.raw) return ' ' + row[c.key] + ' '; // pre-formatted (already padded/coloured)
104
+ const txt = padTo(v, c.width);
105
+ return ' ' + (c.color ? c.color(txt) : txt) + ' ';
106
+ });
107
+ out.push(' ' + B('│') + cells.join(B('│')) + B('│'));
108
+ }
109
+ out.push(' ' + line('└', '┴', '┘'));
110
+ return out.join('\n');
111
+ }
61
112
 
113
+ // ----------------------------------------------------------------------------
114
+ // main
115
+ // ----------------------------------------------------------------------------
62
116
  (async () => {
117
+ const spin = makeSpinner();
118
+ spin.start();
119
+ spin.set('discovering package stores');
63
120
  let res;
64
- const started = Date.now();
121
+ const t0 = Date.now();
65
122
  try {
66
123
  res = await scan({
67
124
  cwd: val('--cwd', process.cwd()),
@@ -70,89 +127,107 @@ function rule(ch = '─', n = 64) { return C.gray(ch.repeat(n)); }
70
127
  maxDepth: parseInt(val('--max-depth', '12'), 10) || 12,
71
128
  stores: (val('--stores', '') || '').split(',').map((s) => s.trim()).filter(Boolean),
72
129
  noAllowlist: has('--no-allowlist'),
130
+ onPhase: (m) => spin.set(m),
73
131
  });
74
132
  } catch (err) {
75
- console.error(C.red('pkgradar error: ') + (err && err.stack || err));
133
+ spin.stop();
134
+ process.stderr.write(C.red('pkgradar error: ') + (err && err.stack || err) + '\n');
76
135
  process.exit(2);
77
136
  }
78
- const secs = ((Date.now() - started) / 1000).toFixed(1);
137
+ spin.stop();
138
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
79
139
 
80
140
  if (has('--json')) {
81
- console.log(JSON.stringify({ ...res, durationSeconds: Number(secs) }, null, 2));
141
+ process.stdout.write(JSON.stringify({ ...res, durationSeconds: Number(secs) }, null, 2) + '\n');
82
142
  process.exit(res.findings.some((f) => f.sevNum >= minSev) ? 1 : 0);
83
143
  }
84
144
 
85
145
  const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
86
146
  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
147
  const worst = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].find((k) => counts[k]) || null;
148
+ const p = (...a) => process.stdout.write(a.join(' ') + '\n');
91
149
 
92
150
  // ---- 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('');
151
+ p('');
152
+ p(` ${C.bold('pkgradar')} ${C.gray('v' + PKG.version)} ${C.gray('content-based supply-chain scan')} ${C.gray(secs + 's')}`);
153
+ p(` ${C.gray('project')} ${homeShort(res.cwd)}`);
154
+ p('');
97
155
 
98
156
  // ---- 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('');
157
+ p(table(
158
+ [
159
+ { key: 'kind', label: 'STORE', width: 14, color: C.cyan },
160
+ { key: 'pkgs', label: 'PKGS', width: 6, color: C.dim },
161
+ { key: 'root', label: 'PATH', width: Math.max(28, (process.stdout.columns || 120) - 32) },
162
+ ],
163
+ res.stores.map((s) => ({ kind: s.kind, pkgs: String(s.packages), root: homeShort(s.root) })),
164
+ ));
165
+ p(` ${C.gray('total:')} ${C.bold(res.totalPackages)} ${C.gray('unique package@version,')} ${C.bold(res.totalScanned)} ${C.gray('locations inspected')}`);
166
+ if (res.osv && res.osv.error) p(` ${C.ylw('OSV lookup failed:')} ${C.dim(res.osv.error)}`);
167
+ else if (!has('--online')) p(` ${C.gray('tip: add')} ${C.dim('--online')} ${C.gray('to cross-check known advisories on OSV.dev')}`);
168
+ p('');
169
+
170
+ // ---- verdict ----
171
+ if (!res.findings.length) {
172
+ p(` ${C.grn('●')} ${C.bold('Verdict:')} ${C.grn('clean')} ${C.gray('nothing tripped the heuristics at ' + minSevName + ' and above')}`);
173
+ p(` ${C.gray(' (this inspects installed bytes; a clean run is not a proof of safety)')}`);
174
+ p('');
113
175
  process.exit(0);
114
176
  }
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('');
177
+ const bits = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].filter((k) => counts[k]).map((k) => SEV_TEXT[k](`${counts[k]} ${k.toLowerCase()}`));
178
+ const dot = (worst === 'CRITICAL' || worst === 'HIGH') ? C.red('●') : worst === 'MEDIUM' ? C.ylw('●') : C.gray('●');
179
+ p(` ${dot} ${C.bold('Verdict:')} ${C.bold(res.findings.length + ' ' + (res.findings.length === 1 ? 'finding' : 'findings'))} ${C.gray('(')}${bits.join(C.gray(', '))}${C.gray(')')}`);
180
+ p('');
181
+
182
+ // ---- findings ----
183
+ const sorted = res.findings.slice().sort((a, b) => b.sevNum - a.sevNum || a.code.localeCompare(b.code) || a.package.localeCompare(b.package));
184
+
185
+ if (has('--full')) {
186
+ 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('');
138
193
  }
139
- };
140
- printGroup('From inspecting installed code', localFindings);
141
- printGroup('Known advisories (OSV.dev)', osvFindings);
194
+ } 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')}`);
218
+ }
219
+ p('');
142
220
 
143
- // ---- next steps ----
144
- console.log(` ${rule()}`);
145
- console.log(` ${C.bold('What to do')}`);
221
+ // ---- what to do ----
222
+ p(` ${C.bold('What to do')}`);
146
223
  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.`);
224
+ p(` ${C.red('•')} Treat ${C.bold('CRITICAL / HIGH "installed code" findings')} as suspect: don't run install`);
225
+ p(` scripts, remove the package, clear the relevant cache, rotate npm / GitHub / cloud tokens.`);
150
226
  }
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('');
227
+ p(` ${C.gray('•')} Check a flagged version against the registry: ${C.dim('npm view <pkg> versions')} ${C.gray('(publish dates, provenance).')}`);
228
+ 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.')}`);
229
+ if (!has('--online')) p(` ${C.gray('•')} Re-run with ${C.dim('--online')} ${C.gray('to add OSV.dev advisory matching by exact version.')}`);
230
+ p('');
156
231
 
157
232
  process.exit(res.findings.some((f) => f.sevNum >= minSev) ? 1 : 0);
158
233
  })();
package/lib/checks.js CHANGED
@@ -95,14 +95,11 @@ function checkWormFiles(pkg) {
95
95
  out.push({ sev: SEV.CRITICAL, code: 'worm-ioc-file', msg: `known worm artifact file: ${e.name}`, evidence: path.relative(pkg.dir, full) });
96
96
  }
97
97
  if (/(^|[\\/])\.github[\\/]workflows([\\/]|$)/.test(d) && /\.ya?ml$/i.test(low)) {
98
- // Many legit packages publish their whole repo (incl. .github). On its own this
99
- // is barely a signal, so it is INFO. A *malicious* workflow file would also be
100
- // caught by name (worm-ioc-file) or by content checks.
101
- const wf = readSafe(full, 200_000);
102
- const nasty = wf && /\b(curl|wget)\b[^|;&\n]*[|;&]+\s*(ba|z|d)?sh\b|secrets\.[A-Z_]+\b[\s\S]{0,80}(curl|nc |bash -c|http)/.test(wf.text);
103
- out.push(nasty
104
- ? { sev: SEV.CRITICAL, code: 'malicious-gh-workflow', msg: `bundled GitHub Actions workflow runs a piped shell download / exfiltrates secrets`, evidence: path.relative(pkg.dir, full) }
105
- : { sev: SEV.INFO, code: 'embedded-gh-workflow', msg: `package ships a GitHub Actions workflow (${e.name})`, evidence: path.relative(pkg.dir, full) });
98
+ // Lots of legit packages publish their whole repo, .github included, and plenty
99
+ // of normal CI workflows pipe an installer into a shell. On its own this is not a
100
+ // signal, so it is INFO only. A genuinely malicious workflow file with a known
101
+ // name is already caught by worm-ioc-file.
102
+ out.push({ sev: SEV.INFO, code: 'embedded-gh-workflow', msg: `package ships a GitHub Actions workflow (${e.name})`, evidence: path.relative(pkg.dir, full) });
106
103
  }
107
104
  }
108
105
  }
@@ -158,21 +155,28 @@ function checkObfuscation(pkg) {
158
155
  const weak = []; // weak reasons only matter in combination
159
156
  let critical = false;
160
157
 
161
- // --- hard worm markers: any one => critical ---
162
- for (const m of WORM_HARD_MARKERS) if (t.includes(m)) { strong.push(`worm IOC string: ${m}`); critical = true; }
163
- // --- soft worm markers: need >=3 distinct in one file (so a tool's advisory text won't trip it) ---
164
- const softHits = WORM_SOFT_MARKERS.filter((m) => t.includes(m));
165
- if (softHits.length >= 3) { strong.push(`co-occurring worm terms: ${softHits.slice(0, 6).join(', ')}`); }
166
- else if (softHits.length) weak.push(`worm term(s): ${softHits.join(', ')}`);
167
-
168
- // --- javascript-obfuscator signature ---
158
+ // --- "executable payload" context: dynamic code execution over decoded data, or
159
+ // heavy identifier mangling. Worm IOC strings are only damning when they sit
160
+ // alongside one of these; on their own they also appear in scanners, advisories,
161
+ // and writeups (this file included). ---
169
162
  const hexIds = (t.match(/_0x[a-f0-9]{4,6}\b/g) || []).length;
163
+ const evalOverDecoded = /(eval|new Function|Function\()\s*\(?\s*(atob|Buffer\.from\s*\([^)]*base64|unescape|decodeURIComponent)/.test(t);
164
+ const procFromDecoded = /\bchild_process\b[\s\S]{0,400}\b(atob|Buffer\.from\s*\([^)]*base64)/.test(t);
165
+ const execContext = evalOverDecoded || procFromDecoded || hexIds > 80;
166
+ if (evalOverDecoded) strong.push('constructs & executes decoded payload (eval/Function over atob/Buffer.from)');
167
+ if (procFromDecoded) strong.push('spawns a process from decoded data');
170
168
  if (hexIds > 80) strong.push(`${hexIds} _0x… mangled identifiers (javascript-obfuscator)`);
171
169
  else if (hexIds > 20) weak.push(`${hexIds} _0x… identifiers`);
172
170
 
173
- // --- code building & executing decoded data dynamically ---
174
- if (/(eval|new Function|Function\()\s*\(?\s*(atob|Buffer\.from\s*\([^)]*base64|unescape|decodeURIComponent)/.test(t)) strong.push('constructs & executes decoded payload (eval/Function over atob/Buffer.from)');
175
- if (/\bchild_process\b[\s\S]{0,400}\b(atob|Buffer\.from\s*\([^)]*base64)/.test(t)) strong.push('spawns a process from decoded data');
171
+ // --- worm IOC strings ---
172
+ const hardHits = WORM_HARD_MARKERS.filter((m) => t.includes(m));
173
+ if (hardHits.length) {
174
+ if (execContext) { strong.push(`worm IOC string(s) in an executing payload: ${hardHits.join(', ')}`); critical = true; }
175
+ else weak.push(`contains known IOC string(s): ${hardHits.join(', ')} (also found in security tooling / advisories / writeups)`);
176
+ }
177
+ const softHits = WORM_SOFT_MARKERS.filter((m) => t.includes(m));
178
+ if (softHits.length >= 3 && execContext) strong.push(`co-occurring worm terms with an executable payload: ${softHits.slice(0, 6).join(', ')}`);
179
+ else if (softHits.length) weak.push(`worm term(s): ${softHits.slice(0, 6).join(', ')}`);
176
180
 
177
181
  // --- weak structural signals (modern bundlers do these constantly) ---
178
182
  const longest = t.split('\n').reduce((m, l) => Math.max(m, l.length), 0);
@@ -217,7 +221,13 @@ function checkManifestAnomalies(pkg) {
217
221
  return out;
218
222
  }
219
223
 
224
+ let SELF = null;
225
+ try { SELF = require('../package.json').name; } catch { /* ignore */ }
226
+
220
227
  function runAll(pkg) {
228
+ // Don't scan our own package: this file necessarily contains the IOC strings it
229
+ // searches for, so scanning a cached copy of pkgradar would flag pkgradar.
230
+ if (SELF && pkg && pkg.manifest && pkg.manifest.name === SELF) return [];
221
231
  const findings = [];
222
232
  for (const fn of [checkLifecycleHooks, checkWormFiles, checkManifestAnomalies, checkObfuscation]) {
223
233
  try { findings.push(...fn(pkg)); } catch (e) { /* ignore per-check failure */ }
package/lib/scan.js CHANGED
@@ -24,11 +24,17 @@ async function scan(opts = {}) {
24
24
  const findings = [];
25
25
  const allPkgList = [];
26
26
 
27
+ const tick = typeof opts.onPhase === 'function' ? opts.onPhase : () => {};
28
+ const yieldToLoop = () => new Promise((r) => setImmediate(r));
29
+
27
30
  for (const store of stores) {
28
31
  if (kindFilter && !kindFilter.has(store.kind)) continue;
32
+ tick(`scanning ${store.kind}`);
33
+ await yieldToLoop();
29
34
  let count = 0;
30
35
  for (const pkg of packagesInStore(store, { maxDepth: opts.maxDepth })) {
31
36
  count++;
37
+ if (count % 100 === 0) { tick(`scanning ${store.kind} (${count} packages)`); await yieldToLoop(); }
32
38
  const key = `${pkg.name}@${pkg.version}@${pkg.dir || store.root}`;
33
39
  if (seen.has(key)) continue;
34
40
  seen.set(key, pkg);
@@ -53,6 +59,7 @@ async function scan(opts = {}) {
53
59
  let osv = null;
54
60
  if (opts.online) {
55
61
  try {
62
+ tick('cross-checking OSV.dev advisories');
56
63
  const map = await queryOSV(allPkgList);
57
64
  osv = [];
58
65
  const SEV_RANK = { CRITICAL: SEV.CRITICAL, HIGH: SEV.HIGH, MEDIUM: SEV.MEDIUM, LOW: SEV.LOW };
@@ -76,9 +83,22 @@ async function scan(opts = {}) {
76
83
  }
77
84
  }
78
85
 
79
- findings.sort((a, b) => b.sevNum - a.sevNum || a.package.localeCompare(b.package));
86
+ // Collapse identical findings that show up in several stores (e.g. the same package
87
+ // cached under multiple npx hashes) into one, noting how many places it was seen.
88
+ const byKey = new Map();
89
+ for (const f of findings) {
90
+ const k = `${f.package}@${f.version}|${f.code}|${f.evidence || ''}|${f.message}`;
91
+ const prev = byKey.get(k);
92
+ if (prev) { prev._copies = (prev._copies || 1) + 1; if (!prev._stores.includes(f.store)) prev._stores.push(f.store); }
93
+ else { f._copies = 1; f._stores = [f.store]; byKey.set(k, f); }
94
+ }
95
+ const deduped = [...byKey.values()].map((f) => {
96
+ if (f._copies > 1) { f.store = `${f._stores.join(', ')} (${f._copies}x)`; }
97
+ delete f._copies; delete f._stores; return f;
98
+ });
99
+ deduped.sort((a, b) => b.sevNum - a.sevNum || a.package.localeCompare(b.package));
80
100
  const totalPackages = [...new Set(allPkgList.map((p) => `${p.name}@${p.version}`))].length;
81
- return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings, osv };
101
+ return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings: deduped, osv };
82
102
  }
83
103
 
84
104
  function splitNV(nv) { const i = nv.lastIndexOf('@'); return [nv.slice(0, i), nv.slice(i + 1)]; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkgradar",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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"