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 +152 -77
- package/lib/scan.js +7 -0
- package/package.json +1 -1
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
48
|
-
blu:
|
|
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
|
-
//
|
|
51
|
-
|
|
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 =
|
|
55
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
133
|
+
spin.stop();
|
|
134
|
+
process.stderr.write(C.red('pkgradar error: ') + (err && err.stack || err) + '\n');
|
|
76
135
|
process.exit(2);
|
|
77
136
|
}
|
|
78
|
-
|
|
137
|
+
spin.stop();
|
|
138
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
79
139
|
|
|
80
140
|
if (has('--json')) {
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
const dot = worst === 'CRITICAL' || worst === 'HIGH' ? C.red('●') : worst === 'MEDIUM' ? C.ylw('●') : C.gray('●');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
// ----
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
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.
|
|
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"
|