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 +152 -77
- package/lib/checks.js +29 -19
- package/lib/scan.js +22 -2
- 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/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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
// ---
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
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
|
-
// ---
|
|
174
|
-
|
|
175
|
-
if (
|
|
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
|
|
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.
|
|
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"
|