pkgradar 0.1.1 → 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/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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkgradar",
3
- "version": "0.1.1",
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"