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 +68 -6
- package/lib/checks.js +56 -6
- package/lib/osv.js +45 -16
- package/lib/scan.js +35 -4
- package/lib/stores.js +56 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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] + ' ';
|
|
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('
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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,
|
|
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 = [];
|
|
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
|
|
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
|
|
5
|
-
// 2. /v1/query
|
|
6
|
-
//
|
|
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
|
|
33
|
-
const
|
|
34
|
-
const network =
|
|
35
|
-
const noPriv =
|
|
36
|
-
const noUI =
|
|
37
|
-
if (
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
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
|
|
62
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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"
|