pkgradar 0.1.3 → 0.1.4
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 +66 -44
- package/package.json +1 -1
package/bin/pkgradar.js
CHANGED
|
@@ -37,7 +37,7 @@ USAGE
|
|
|
37
37
|
OPTIONS
|
|
38
38
|
--online also cross-reference OSV.dev for known advisories (network)
|
|
39
39
|
--json machine-readable output
|
|
40
|
-
--
|
|
40
|
+
--compact one terse line per finding instead of full blocks
|
|
41
41
|
--min-sev LEVEL report findings at or above LEVEL
|
|
42
42
|
critical | high | medium | low | info [default: medium]
|
|
43
43
|
--stores LIST comma list to limit which stores are scanned
|
|
@@ -76,19 +76,6 @@ const C = {
|
|
|
76
76
|
blu: paint('34'), mag: paint('35'), cyan: paint('36'), gray: paint('90'),
|
|
77
77
|
};
|
|
78
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
|
-
*/
|
|
86
|
-
function sevCell(sev, width) {
|
|
87
|
-
const bg = { CRITICAL: '41', HIGH: '45', MEDIUM: '43', LOW: '100', INFO: '100' }[sev] || '100';
|
|
88
|
-
const fg = sev === 'MEDIUM' ? '30' : '97';
|
|
89
|
-
const label = sev.padStart((width + sev.length) >> 1).padEnd(width);
|
|
90
|
-
return COLOR ? `\x1b[${bg};${fg};1m${label}\x1b[0m` : label;
|
|
91
|
-
}
|
|
92
79
|
|
|
93
80
|
/** Maps severity name to a colouring function for plain-text severity mentions. */
|
|
94
81
|
const SEV_TEXT = { CRITICAL: C.red, HIGH: C.mag, MEDIUM: C.ylw, LOW: C.gray, INFO: C.gray };
|
|
@@ -169,6 +156,41 @@ function table(cols, rows) {
|
|
|
169
156
|
return out.join('\n');
|
|
170
157
|
}
|
|
171
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Word-wrap a plain string to a column width, returning an array of lines.
|
|
161
|
+
* Tokens longer than the width are hard-split so nothing overflows.
|
|
162
|
+
* @param {string} text - Text to wrap (no ANSI codes).
|
|
163
|
+
* @param {number} width - Max characters per line.
|
|
164
|
+
* @returns {string[]}
|
|
165
|
+
*/
|
|
166
|
+
function wrap(text, width) {
|
|
167
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
168
|
+
const lines = [];
|
|
169
|
+
let cur = '';
|
|
170
|
+
for (let w of words) {
|
|
171
|
+
while (w.length > width) { if (cur) { lines.push(cur); cur = ''; } lines.push(w.slice(0, width)); w = w.slice(width); }
|
|
172
|
+
if (!cur) cur = w;
|
|
173
|
+
else if (cur.length + 1 + w.length <= width) cur += ' ' + w;
|
|
174
|
+
else { lines.push(cur); cur = w; }
|
|
175
|
+
}
|
|
176
|
+
if (cur) lines.push(cur);
|
|
177
|
+
return lines.length ? lines : [''];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One-line plain-language explanation of why each finding type matters. */
|
|
181
|
+
const WHY = {
|
|
182
|
+
'lifecycle-hook': 'install hooks run automatically on npm/yarn/pnpm install; this one does something a normal build step would not',
|
|
183
|
+
'worm-ioc-file': 'this filename is a known artifact of the Shai-Hulud worm family',
|
|
184
|
+
'suspicious-js': 'code that decodes and then executes a payload, or hard-coded worm exfiltration constants',
|
|
185
|
+
'embedded-gh-workflow': 'harmless on its own; just noting the package ships a CI workflow file',
|
|
186
|
+
'malicious-gh-workflow': 'a bundled GitHub Actions workflow that pipes a remote script straight into a shell',
|
|
187
|
+
'odd-bin': 'a bin entry pointing outside the package or at a shell script is unusual for a dependency',
|
|
188
|
+
'osv-advisory': 'a publicly known vulnerability affects this exact version; check whether a patched release exists',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** ●-marker colour by severity, used at the start of each finding block. */
|
|
192
|
+
const SEV_DOT = { CRITICAL: C.red('●'), HIGH: C.mag('●'), MEDIUM: C.ylw('●'), LOW: C.gray('●'), INFO: C.gray('○') };
|
|
193
|
+
|
|
172
194
|
// ----------------------------------------------------------------------------
|
|
173
195
|
// main
|
|
174
196
|
// ----------------------------------------------------------------------------
|
|
@@ -242,41 +264,41 @@ function table(cols, rows) {
|
|
|
242
264
|
p('');
|
|
243
265
|
|
|
244
266
|
// ---- findings ----
|
|
267
|
+
// Grouped by severity, one readable block per finding. A fixed-width table would
|
|
268
|
+
// truncate the detail text, so we wrap it to the terminal instead.
|
|
245
269
|
const sorted = res.findings.slice().sort((a, b) => b.sevNum - a.sevNum || a.code.localeCompare(b.code) || a.package.localeCompare(b.package));
|
|
270
|
+
const term = Math.max(60, Math.min(process.stdout.columns || 100, 120));
|
|
271
|
+
const ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
|
|
246
272
|
|
|
247
|
-
if (has('--
|
|
273
|
+
if (has('--compact')) {
|
|
274
|
+
// one terse line per finding
|
|
275
|
+
const pkgW = Math.min(34, Math.max(...sorted.map((f) => (f.package + '@' + f.version).length)));
|
|
248
276
|
for (const f of sorted) {
|
|
249
|
-
p(` ${
|
|
250
|
-
p(` ${f.message}`);
|
|
251
|
-
if (f.evidence) p(` ${C.gray('-> ' + f.evidence)}`);
|
|
252
|
-
if (f.note) p(` ${C.gray('note: ' + f.note)}`);
|
|
253
|
-
if (f.dir) p(` ${C.gray(homeShort(f.dir))}`);
|
|
254
|
-
p('');
|
|
277
|
+
p(` ${SEV_DOT[f.severity]} ${SEV_TEXT[f.severity](f.severity.padEnd(8))} ${C.bold(padTo(clip(f.package + '@' + f.version, pkgW), pkgW))} ${C.cyan(padTo(f.code, 21))} ${C.gray(clip(f.evidence || f.message, term - pkgW - 36))}`);
|
|
255
278
|
}
|
|
256
279
|
} else {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
p(` ${C.gray('use')} ${C.dim('--full')} ${C.gray('for untruncated details and on-disk paths')}`);
|
|
280
|
+
const FW = term - 13; // width available for wrapped field text
|
|
281
|
+
/** Print a labelled, word-wrapped field under a finding (label only on the first line). */
|
|
282
|
+
const field = (key, text, paint) => {
|
|
283
|
+
if (text == null || text === '') return;
|
|
284
|
+
wrap(text, FW).forEach((ln, i) => p(` ${C.gray((i === 0 ? key : '').padEnd(9))} ${paint ? paint(ln) : ln}`));
|
|
285
|
+
};
|
|
286
|
+
for (const sev of ORDER) {
|
|
287
|
+
const group = sorted.filter((f) => f.severity === sev);
|
|
288
|
+
if (!group.length) continue;
|
|
289
|
+
p(` ${SEV_TEXT[sev](C.bold(sev))} ${C.gray('· ' + group.length)}`);
|
|
290
|
+
p(` ${C.gray('─'.repeat(term - 2))}`);
|
|
291
|
+
for (const f of group) {
|
|
292
|
+
p(` ${SEV_DOT[sev]} ${C.bold(f.package + '@' + f.version)} ${C.cyan(f.code)} ${C.gray('in ' + f.store)}`);
|
|
293
|
+
field('what', f.message);
|
|
294
|
+
field('evidence', f.evidence, C.gray);
|
|
295
|
+
field('why', WHY[f.code], C.gray);
|
|
296
|
+
field('note', f.note, C.gray);
|
|
297
|
+
if (f.dir) p(` ${C.gray('path'.padEnd(9))} ${C.gray(homeShort(f.dir))}`);
|
|
298
|
+
p('');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
p(` ${C.gray('use')} ${C.dim('--compact')} ${C.gray('for a one-line-per-finding view, or')} ${C.dim('--json')} ${C.gray('for machine output')}`);
|
|
280
302
|
}
|
|
281
303
|
p('');
|
|
282
304
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pkgradar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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"
|