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.
Files changed (2) hide show
  1. package/bin/pkgradar.js +66 -44
  2. 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
- --full expanded per-finding output instead of the summary table
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('--full')) {
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(` ${sevCell(f.severity, 10)} ${C.bold(f.package + '@' + f.version)} ${C.gray(f.store)} ${C.cyan(f.code)}`);
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 term = process.stdout.columns || 120;
258
- const W_PKG = 28, W_TYPE = 21, W_WHERE = 16;
259
- const W_DETAIL = Math.max(24, term - (2 + 3 + 10 + W_PKG + W_TYPE + W_WHERE + 3 * 5 + 1));
260
- const SHOW = 60;
261
- const rows = sorted.slice(0, SHOW).map((f) => ({
262
- sev: sevCell(f.severity, 10),
263
- pkg: f.package + '@' + f.version,
264
- type: f.code,
265
- where: f.store,
266
- detail: (f.evidence ? f.evidence + ' ' : '') + f.message,
267
- }));
268
- p(table(
269
- [
270
- { key: 'sev', label: 'SEVERITY', width: 10, raw: true },
271
- { key: 'pkg', label: 'PACKAGE', width: W_PKG, color: C.bold },
272
- { key: 'type', label: 'TYPE', width: W_TYPE, color: C.cyan },
273
- { key: 'where', label: 'WHERE', width: W_WHERE, color: C.dim },
274
- { key: 'detail', label: 'DETAIL', width: W_DETAIL, color: C.gray },
275
- ],
276
- rows,
277
- ));
278
- if (sorted.length > SHOW) p(` ${C.gray('... and ' + (sorted.length - SHOW) + ' more (use --json for the full list)')}`);
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",
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"