fad-checker 2.2.3 → 2.2.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/lib/charts.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * lib/charts.js — compact, dependency-free SVG charts for the HTML report's
3
+ * "Overview" row (rendered right under the summary totals).
4
+ *
5
+ * Why hand-rolled inline SVG (not <canvas>, not a chart lib, not pure-CSS):
6
+ * - self-contained: the report ships zero external assets, and an SVG is just
7
+ * markup — no runtime, no CDN. It even renders in the static .doc (no JS).
8
+ * - copy-to-Word: each chart rasterises SVG→<canvas>→PNG entirely in the
9
+ * browser (see the report's chart-copy script) and a PNG pastes into Word
10
+ * perfectly. A <div> has no native "→ PNG" API; an SVG does.
11
+ *
12
+ * Every visual attribute is INLINE on the SVG elements (fill/font-size/…), never
13
+ * a CSS class — a class would not survive the canvas rasterisation (the document
14
+ * stylesheet is not applied to the serialised SVG).
15
+ *
16
+ * Charts: (1) CWE of DIRECT vulns — donut, sliced by CWE, coloured by that CWE's
17
+ * worst severity, legend carries the human CWE title; (2) vulnerable transitive
18
+ * sub-deps per (root) dependency — readable horizontal bars stacked by severity,
19
+ * with rootless transitives reported as a note rather than a bogus "unknown
20
+ * root" bar; (3) reported CVE/elements — donut; (4) fix-priority bands — donut.
21
+ *
22
+ * Pure: aggregators take match arrays + counts → plain data; renderers turn that
23
+ * into SVG strings. No I/O, no network.
24
+ *
25
+ * @author: N.BRAUN
26
+ * @email: pp9ping@gmail.com
27
+ */
28
+ const { computePriority } = require("./priority");
29
+
30
+ const CWE_NAMES = (() => {
31
+ try { const raw = { ...require("../data/cwe-names.json") }; delete raw._comment; return raw; }
32
+ catch { return {}; }
33
+ })();
34
+ const cweName = id => CWE_NAMES[String(id || "").toUpperCase()] || "";
35
+
36
+ function esc(s) {
37
+ if (s == null) return "";
38
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
39
+ }
40
+
41
+ // Severity series (stacked bars) — colours mirror the report's severity badges.
42
+ const SEV_SERIES = [
43
+ { key: "critical", label: "Critical", color: "#7c0008" },
44
+ { key: "high", label: "High", color: "#c92a2a" },
45
+ { key: "medium", label: "Medium", color: "#f08c00" },
46
+ { key: "low", label: "Low", color: "#3b82f6" },
47
+ { key: "unknown", label: "Unknown", color: "#9ca3af" },
48
+ ];
49
+ const SEV_COLOR = Object.fromEntries(SEV_SERIES.map(s => [s.key, s.color]));
50
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 };
51
+ const sevKey = m => {
52
+ const s = (m && m.cve && m.cve.severity || "").toLowerCase();
53
+ return s in SEV_RANK ? s : "unknown";
54
+ };
55
+ const emptySeg = () => ({ critical: 0, high: 0, medium: 0, low: 0, unknown: 0 });
56
+ const worstSev = seg => SEV_SERIES.map(s => s.key).find(k => seg[k] > 0) || "unknown";
57
+ const coordKeyOf = dep => dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : (dep.groupId || "") + ":"}${dep.artifactId}`;
58
+
59
+ // Keep the top-N rows; fold the remainder into a single "+K more" aggregate row
60
+ // (a chart must never silently drop categories).
61
+ function capRows(rows, topN, { stacked = false } = {}) {
62
+ if (rows.length <= topN) return rows;
63
+ const kept = rows.slice(0, topN);
64
+ const rest = rows.slice(topN);
65
+ if (stacked) {
66
+ const seg = emptySeg();
67
+ let total = 0;
68
+ for (const r of rest) { for (const k of Object.keys(seg)) seg[k] += r.segments[k] || 0; total += r.total; }
69
+ kept.push({ key: "__more__", label: `+${rest.length} more`, segments: seg, total, more: true });
70
+ } else {
71
+ const value = rest.reduce((a, r) => a + r.value, 0);
72
+ kept.push({ key: "__more__", label: `+${rest.length} more`, value, color: "#9ca3af", more: true });
73
+ }
74
+ return kept;
75
+ }
76
+
77
+ // ---------------------------------------------------------------- aggregators
78
+
79
+ /** Chart 1 — CWE distribution of DIRECT production vulns, with per-severity counts. */
80
+ function cweByCriticality(prodMatches, { topN = 7 } = {}) {
81
+ const map = new Map();
82
+ for (const m of prodMatches || []) {
83
+ if (m.dep && m.dep.scope === "transitive") continue; // direct (declared) only
84
+ const cwes = Array.isArray(m.cve && m.cve.cwes) ? m.cve.cwes : [];
85
+ if (!cwes.length) continue; // a "by CWE" chart only counts categorised findings
86
+ const sev = sevKey(m);
87
+ for (const c of cwes) {
88
+ const id = String(c || "").toUpperCase();
89
+ if (!id) continue;
90
+ if (!map.has(id)) map.set(id, emptySeg());
91
+ map.get(id)[sev]++;
92
+ }
93
+ }
94
+ const rows = [...map.entries()].map(([id, segments]) => ({
95
+ key: id,
96
+ label: id,
97
+ name: cweName(id),
98
+ segments,
99
+ total: SEV_SERIES.reduce((a, s) => a + segments[s.key], 0),
100
+ })).sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
101
+ return capRows(rows, topN, { stacked: true });
102
+ }
103
+
104
+ // Readable label for a root coordKey: the artifact / package name (the part a
105
+ // human recognises), e.g. "org.spring.boot:spring-boot-starter-web" → "spring-boot-starter-web".
106
+ function readableDepLabel(coordKey) {
107
+ const s = String(coordKey || "");
108
+ const seg = s.split("/").pop(); // npm "@scope/name" path tail
109
+ const tail = seg.includes(":") ? seg.slice(seg.indexOf(":") + 1) : seg;
110
+ return tail || s;
111
+ }
112
+
113
+ /** Chart 2 — sub-dep CVEs grouped by their ROOT (direct) dep, by severity.
114
+ * Counts CVEs (a sub-dep with 3 CVEs contributes 3), each in its own bucket. */
115
+ function vulnSubdepsByDep(prodMatches, { topN = 7 } = {}) {
116
+ const roots = new Map(); // rootKey -> segments
117
+ for (const m of prodMatches || []) {
118
+ if (!m.dep || m.dep.scope !== "transitive") continue;
119
+ const root = m.dep.via && m.dep.via[0];
120
+ if (!root) continue; // rootless → unattributedSubdeps()
121
+ if (!roots.has(root)) roots.set(root, emptySeg());
122
+ roots.get(root)[sevKey(m)]++; // one count per CVE, in its own severity
123
+ }
124
+ const rows = [...roots.entries()].map(([root, segments]) => ({
125
+ key: root,
126
+ label: readableDepLabel(root),
127
+ segments,
128
+ total: SEV_SERIES.reduce((a, s) => a + segments[s.key], 0),
129
+ })).filter(r => r.total > 0).sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
130
+ return capRows(rows, topN, { stacked: true });
131
+ }
132
+
133
+ /** Sub-dep CVEs on transitive deps with no resolved root (e.g. npm). */
134
+ function unattributedSubdeps(prodMatches) {
135
+ let n = 0;
136
+ for (const m of prodMatches || []) {
137
+ if (!m.dep || m.dep.scope !== "transitive") continue;
138
+ if (m.dep.via && m.dep.via[0]) continue;
139
+ n++;
140
+ }
141
+ return n;
142
+ }
143
+
144
+ /** Chart 3 — where the risk lives: direct vs transitive production vulns, with
145
+ * each side's per-severity breakdown carried in the legend (e.g. "2C 1H 1M"). */
146
+ function directVsTransitive(prodMatches) {
147
+ const g = { direct: emptySeg(), transitive: emptySeg() };
148
+ for (const m of prodMatches || []) {
149
+ g[(m.dep && m.dep.scope === "transitive") ? "transitive" : "direct"][sevKey(m)]++;
150
+ }
151
+ const tot = seg => SEV_SERIES.reduce((a, s) => a + seg[s.key], 0);
152
+ const summary = seg => SEV_SERIES.filter(s => seg[s.key] > 0).map(s => `${seg[s.key]}${s.label[0]}`).join(" ");
153
+ const out = [];
154
+ if (tot(g.direct)) out.push({ key: "direct", label: "Direct", name: summary(g.direct), value: tot(g.direct), color: "#4338ca" });
155
+ if (tot(g.transitive)) out.push({ key: "transitive", label: "Transitive", name: summary(g.transitive), value: tot(g.transitive), color: "#d97706" });
156
+ return out;
157
+ }
158
+
159
+ const FIX_BANDS = [
160
+ { key: "exploited", label: "Exploited", color: "#7c0008" },
161
+ { key: "critical", label: "Critical", color: "#b91c1c" },
162
+ { key: "high", label: "High", color: "#ea580c" },
163
+ { key: "medium", label: "Medium", color: "#ca8a04" },
164
+ { key: "low", label: "Low", color: "#2563eb" },
165
+ ];
166
+ /** Chart 4 — fix-priority distribution (composite KEV/EPSS-weighted bands). */
167
+ function fixPriority(prodMatches) {
168
+ const counts = { exploited: 0, critical: 0, high: 0, medium: 0, low: 0 };
169
+ for (const m of prodMatches || []) {
170
+ const p = (m.cve && m.cve.priority) || computePriority(m.cve || {});
171
+ if (counts[p.band] != null) counts[p.band]++;
172
+ }
173
+ return FIX_BANDS.map(b => ({ key: b.key, label: b.label, value: counts[b.key], color: b.color })).filter(r => r.value > 0);
174
+ }
175
+
176
+ // ------------------------------------------------------------------ renderers
177
+
178
+ const FONT = "font-family='-apple-system,Segoe UI,Roboto,sans-serif'";
179
+
180
+ const legendStr = s => s.name ? `${s.label} · ${s.name} · ${s.value}` : `${s.label} · ${s.value}`;
181
+ const LEGEND_CHARW = 4.15; // ~px per char at the 7.5px legend font
182
+
183
+ // The viewBox width needed to fit the FULL legend text (no ellipsis). The caller
184
+ // takes the MAX across all four charts and feeds it to every donut, so they share
185
+ // one viewBox width → uniform donut size when each card is the same 25% slot.
186
+ function legendBoxWidth(slices, note) {
187
+ const items = (slices || []).filter(s => s.value > 0);
188
+ return Math.ceil(Math.max(0, ...items.map(s => 13 + legendStr(s).length * LEGEND_CHARW), note ? note.length * 4 : 0)) + 8;
189
+ }
190
+
191
+ // A donut + a legend underneath. slices: [{label, name?, value, color}].
192
+ // The legend carries the human title (so the principal CWEs read in plain text).
193
+ function donutChart({ slices, width = 250, note }) {
194
+ const items = (slices || []).filter(s => s.value > 0);
195
+ const total = items.reduce((a, s) => a + s.value, 0);
196
+ const legendTxt = legendStr;
197
+ const R = 47, cy = R + 4, cx = width / 2;
198
+ const parts = [];
199
+ if (items.length === 1) {
200
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="${items[0].color}"/>`);
201
+ } else {
202
+ let a0 = -Math.PI / 2;
203
+ for (const s of items) {
204
+ const a1 = a0 + (s.value / total) * 2 * Math.PI;
205
+ const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
206
+ const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
207
+ const large = (a1 - a0) > Math.PI ? 1 : 0;
208
+ const tip = s.name ? `${s.label} — ${s.name}: ${s.value}` : `${s.label}: ${s.value}`;
209
+ parts.push(`<path d="M${cx.toFixed(1)} ${cy.toFixed(1)} L${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${large} 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z" fill="${s.color}" stroke="#ffffff" stroke-width="1"><title>${esc(tip)}</title></path>`);
210
+ a0 = a1;
211
+ }
212
+ }
213
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${(R * 0.58).toFixed(1)}" fill="#ffffff"/>`);
214
+ parts.push(`<text x="${cx}" y="${cy + 5}" text-anchor="middle" ${FONT} font-size="17" font-weight="700" fill="#374151">${total}</text>`);
215
+ let ly = R * 2 + 23; // +10px breathing room donut → legend
216
+ for (const s of items) { // full legend text — no ellipsis
217
+ parts.push(`<rect x="0" y="${ly - 7}" width="8" height="8" rx="1" fill="${s.color}"/>`);
218
+ parts.push(`<text x="11" y="${ly}" ${FONT} font-size="7.5" fill="#4b5563">${esc(legendTxt(s))}</text>`);
219
+ ly += 11;
220
+ }
221
+ if (note) { parts.push(`<text x="0" y="${ly}" ${FONT} font-size="7" font-style="italic" fill="#9ca3af">${esc(note)}</text>`); ly += 11; }
222
+ return { body: parts.join(""), width, height: ly + 2 };
223
+ }
224
+
225
+ function chartCard({ id, title, body, width, height, interactive }) {
226
+ // The title lives INSIDE the svg (so it travels with the copied PNG); the body
227
+ // is shifted down to make room. The copy button is an HTML overlay (top-right).
228
+ const titleH = 18;
229
+ const h = Math.max(1, Math.round(height + titleH));
230
+ const titleSvg = `<text x="0" y="12" ${FONT} font-size="10.5" font-weight="700" fill="#4b5563">${esc(title)}</text>`;
231
+ const svg = `<svg class="chart-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" role="img" aria-label="${esc(title)}"><rect x="0" y="0" width="${width}" height="${h}" fill="#ffffff"/>${titleSvg}<g transform="translate(0,${titleH})">${body}</g></svg>`;
232
+ const copyBtn = interactive ? `<button class="btn-copy chart-copy" type="button" title="Copy this chart as a PNG — paste it into Word">📋</button>` : "";
233
+ return `<figure class="chart-card" id="${esc(id)}">${copyBtn}${svg}</figure>`;
234
+ }
235
+
236
+ function emptyCard(id, title, note) {
237
+ return `<figure class="chart-card" id="${esc(id)}"><figcaption class="chart-head"><span class="chart-title">${esc(title)}</span></figcaption><div class="chart-empty">${esc(note)}</div></figure>`;
238
+ }
239
+
240
+ /**
241
+ * Build the four-chart "Overview" row. Returns "" when there is nothing to plot.
242
+ */
243
+ function renderCharts(payload = {}, opts = {}) {
244
+ const prod = payload.prodMatches || [];
245
+ const cwe = cweByCriticality(prod);
246
+ const subs = vulnSubdepsByDep(prod);
247
+ const unattr = unattributedSubdeps(prod);
248
+ const scope = directVsTransitive(prod);
249
+ const prio = fixPriority(prod);
250
+
251
+ if (!(cwe.length || subs.length || scope.length || prio.length || unattr)) return "";
252
+
253
+ const interactive = opts.interactive !== false; // copy button only in the interactive HTML
254
+
255
+ const cweSlices = cwe.map(r => ({ label: r.key, name: r.name, value: r.total, color: SEV_COLOR[worstSev(r.segments)] }));
256
+ // Donut per direct dep: slice size = its sub-dep CVE count, colour = worst
257
+ // severity among them, legend = readable dep name + count.
258
+ const subSlices = subs.map(r => ({ label: r.label, value: r.total, color: SEV_COLOR[worstSev(r.segments)] }));
259
+ const note2 = unattr ? `+${unattr} transitive CVE(s) with no resolved root` : "";
260
+
261
+ // One shared viewBox width across all four donuts (the widest legend) → with
262
+ // equal-width (25%) flex cards they scale identically, so the donuts stay the
263
+ // SAME size regardless of how long any single chart's legend is.
264
+ const W = Math.max(250, legendBoxWidth(cweSlices), legendBoxWidth(subSlices, note2), legendBoxWidth(scope), legendBoxWidth(prio));
265
+
266
+ const card1 = cwe.length
267
+ ? chartCard({ id: "chart-cwe", title: "CWE — direct vulns (by criticality)", interactive, ...donutChart({ slices: cweSlices, width: W }) })
268
+ : emptyCard("chart-cwe", "CWE — direct vulns (by criticality)", "No categorised direct CVE.");
269
+
270
+ const card2 = (subs.length || unattr)
271
+ ? chartCard({ id: "chart-subdeps", title: "Sub-dep CVEs per dependency", interactive, ...donutChart({ slices: subSlices, width: W, note: note2 }) })
272
+ : emptyCard("chart-subdeps", "Sub-dep CVEs per dependency", "No vulnerable transitive deps.");
273
+
274
+ const card3 = scope.length
275
+ ? chartCard({ id: "chart-scope", title: "Direct vs transitive (by severity)", interactive, ...donutChart({ slices: scope, width: W }) })
276
+ : emptyCard("chart-scope", "Direct vs transitive (by severity)", "No production CVE.");
277
+
278
+ const card4 = prio.length
279
+ ? chartCard({ id: "chart-priority", title: "Fix priority", interactive, ...donutChart({ slices: prio, width: W }) })
280
+ : emptyCard("chart-priority", "Fix priority", "No production CVE.");
281
+
282
+ return `<section class="charts-row" aria-label="Overview charts">${card1}${card2}${card3}${card4}</section>`;
283
+ }
284
+
285
+ module.exports = {
286
+ cweByCriticality,
287
+ vulnSubdepsByDep,
288
+ unattributedSubdeps,
289
+ directVsTransitive,
290
+ fixPriority,
291
+ renderCharts,
292
+ SEV_SERIES,
293
+ };