fad-checker 2.2.3 → 2.3.0

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/cve-report.js CHANGED
@@ -14,6 +14,7 @@ const { getCodec, allCodecs, ORDER } = require("./codecs");
14
14
  const { computePriority, sortByPriority } = require("./priority");
15
15
  const { buildInventory } = require("./unmanaged");
16
16
  const { buildEmbeddedInventory } = require("./embedded");
17
+ const { renderCharts } = require("./charts");
17
18
 
18
19
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
19
20
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -98,10 +99,13 @@ h1 { margin: 0 0 4px 0; font-size: 28px; letter-spacing: -.5px; }
98
99
  .report-subtitle { color: #6b7280; font-size: 14px; font-weight: 500; margin-bottom: 12px; letter-spacing: .3px; }
99
100
  h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
100
101
  .meta { color: #6b7280; font-size: 13px; margin-bottom: 16px; }
101
- .summary { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
102
- .summary .card { padding: 10px 16px; border-radius: 6px; background: #fff; border: 1px solid #e5e7eb; min-width: 120px; }
103
- .summary .card .v { font-size: 24px; font-weight: 700; }
104
- .summary .card .l { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: .5px; }
102
+ /* Totals row compact cards that FLEX-GROW to spread evenly across the full
103
+ render width (flex:1), so the set fills the line above the overview charts
104
+ instead of clustering on the left. */
105
+ .summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 14px 0; }
106
+ .summary .card { flex: 1 1 0; padding: 6px 11px; border-radius: 5px; background: #fff; border: 1px solid #e5e7eb; min-width: 100px; }
107
+ .summary .card .v { font-size: 20px; font-weight: 700; line-height: 1.1; }
108
+ .summary .card .l { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: .3px; white-space: nowrap; }
105
109
  .summary .card.sev-critical { border-left: 4px solid #dc2626; }
106
110
  .summary .card.sev-critical .v { color: #dc2626; }
107
111
  .summary .card.sev-high { border-left: 4px solid #f97316; }
@@ -112,13 +116,25 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
112
116
  .summary .card.sev-low .v { color: #2563eb; }
113
117
  .summary .card.sev-vendored { border-left: 4px solid #6366f1; }
114
118
  .summary .card.sev-warn { border-left: 4px solid #f59e0b; background: #fffbeb; }
119
+ /* Overview charts — four equal-width (25%) cards on one row, stretched to the
120
+ same height. All four donuts share one viewBox width (see lib/charts.js) so
121
+ they scale identically and stay the SAME size whatever the legend length.
122
+ The copy button overlays the top-right. */
123
+ .charts-row { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 18px; align-items: stretch; }
124
+ .charts-row .chart-card { flex: 1 1 0; min-width: 200px; position: relative; margin: 0; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; }
125
+ .charts-row .chart-copy { position: absolute; top: 6px; right: 8px; padding: 2px 7px; font-size: 11px; z-index: 1; }
126
+ .charts-row .chart-svg { display: block; width: 100%; height: auto; }
127
+ .charts-row .chart-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
128
+ .charts-row .chart-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: #4b5563; }
129
+ .charts-row .chart-empty { color: #9ca3af; font-style: italic; font-size: 11px; padding: 14px 0; }
115
130
  .toc { position: sticky; top: 0; z-index: 50; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; margin: 16px 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.04); }
116
131
  .toc a { color: #4338ca; text-decoration: none; padding: 2px 6px; border-radius: 3px; }
117
132
  .toc a:hover { background: #eef2ff; }
118
133
  .exec-summary .exec-top { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
119
134
  .exec-summary .exec-top-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
120
135
  .exec-summary .exec-top-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
121
- .exec-summary .exec-top-list li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: ui-monospace, monospace; }
136
+ .exec-summary .exec-top-list li { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 3px 0; font-family: ui-monospace, monospace; }
137
+ .exec-summary .exec-cwes-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
122
138
  .exec-summary .exec-cve-link { color: #1d4ed8; text-decoration: none; font-weight: 600; }
123
139
  .exec-summary .exec-cve-link:hover { text-decoration: underline; }
124
140
  .exec-summary .exec-ver { color: #6b7280; }
@@ -153,6 +169,17 @@ td.cwe .cwe-name { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; f
153
169
  .exec-summary .exec-cwe { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; color: #4b5563; background: #eef2ff; border: 1px solid #c7d2fe; padding: 1px 6px; border-radius: 3px; margin-left: 2px; }
154
170
  .exec-summary .exec-cwe .exec-cwe-id { font-family: ui-monospace, monospace; font-weight: 600; color: #4338ca; }
155
171
  .exec-summary .exec-cwe .exec-cwe-name { color: #4b5563; }
172
+ .exec-summary .exec-cwe .exec-cwe-count { font-family: ui-monospace, monospace; font-size: 10px; color: #6366f1; font-weight: 700; }
173
+ /* transitive-fallback tag on a top-3 row */
174
+ .exec-summary .exec-scope-tag { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: #92400e; background: #fef3c7; border: 1px solid #fde68a; padding: 1px 5px; border-radius: 3px; }
175
+ /* top-EOL block */
176
+ .exec-summary .exec-eol { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
177
+ .exec-summary .exec-eol-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
178
+ .exec-summary .exec-eol-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
179
+ .exec-summary .exec-eol-list li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: ui-monospace, monospace; }
180
+ /* short summary of everything else */
181
+ .exec-summary .exec-rest { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; font-size: 12px; color: #374151; line-height: 1.6; }
182
+ .exec-summary .exec-rest .exec-rest-label { font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; font-size: 11px; margin-right: 4px; }
156
183
  td.published { font-family: ui-monospace, monospace; font-size: 11px; color: #6b7280; white-space: nowrap; }
157
184
  tr.cve-row.cpe-fp td { opacity: .55; }
158
185
  .jump-badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
@@ -391,6 +418,53 @@ function makeRelative(absPath, srcRoot) {
391
418
  } catch { return absPath; }
392
419
  }
393
420
 
421
+ // Inventory of the manifest/lockfile descriptors fad actually parsed and fed into the
422
+ // scan: the union of every declared dep's manifestPaths (relative to the src root), with
423
+ // the ecosystem(s) and the count of direct deps each contributed. Transitive deps (resolved
424
+ // from a registry, no local file) and committed binaries (embedded/native — inventoried in
425
+ // chapters 1B/1C, not "descriptors") are excluded.
426
+ function buildScannedManifests(resolvedDeps, srcRoot, parsedManifests = []) {
427
+ const byPath = new Map();
428
+ const touch = (relPath, eco) => {
429
+ let e = byPath.get(relPath);
430
+ if (!e) { e = { path: relPath, ecosystems: new Set(), count: 0 }; byPath.set(relPath, e); }
431
+ if (eco) e.ecosystems.add(eco);
432
+ return e;
433
+ };
434
+ // Files that contributed at least one scanned dependency (count = direct deps).
435
+ for (const dep of (resolvedDeps?.values?.() || [])) {
436
+ if (dep.scope === "transitive") continue;
437
+ if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
438
+ const paths = (dep.manifestPaths && dep.manifestPaths.length) ? dep.manifestPaths
439
+ : (dep.pomPaths && dep.pomPaths.length) ? dep.pomPaths : [];
440
+ for (const p of paths) touch(makeRelative(p, srcRoot), dep.ecosystemType || dep.ecosystem || "?").count++;
441
+ }
442
+ // EVERY descriptor each codec reported parsing — incl. ones that contributed nothing
443
+ // (ranges-only / no lockfile). These land with count 0 so nothing is silently omitted.
444
+ for (const pm of (parsedManifests || [])) {
445
+ if (!pm || !pm.path) continue;
446
+ touch(makeRelative(pm.path, srcRoot), pm.ecosystemType);
447
+ }
448
+ return [...byPath.values()]
449
+ .map(e => ({ path: e.path, ecosystems: [...e.ecosystems].sort(), count: e.count }))
450
+ .sort((a, b) => a.path.localeCompare(b.path));
451
+ }
452
+
453
+ function renderScannedManifests(list) {
454
+ if (!list.length) return `<div class="empty">No dependency descriptors were parsed (deps came only from registries or committed binaries).</div>`;
455
+ const emptyN = list.filter(e => e.count === 0).length;
456
+ const intro = `<div class="fp-intro">Every manifest / lockfile descriptor fad-checker parsed for this scan (paths relative to the source root) — the complete list, including files that contributed <strong>no scannable dependency</strong> (only version ranges, or no lockfile)${emptyN ? `: ${emptyN} such file${emptyN > 1 ? "s" : ""} shown with <code>0</code>` : ""}. Transitive deps resolved from registries, and committed binaries (chapters 1B/1C), are not descriptors and are excluded here.</div>`;
457
+ const rows = list.map(e => `<tr>
458
+ <td class="dep"><code>${esc(e.path)}</code></td>
459
+ <td>${esc(e.ecosystems.map(x => ECO_LABELS[x] || x).join(", "))}</td>
460
+ <td>${e.count > 0 ? e.count : `<span style="color:#9ca3af">0 <span style="font-style:italic">— ranges / no lockfile</span></span>`}</td>
461
+ </tr>`).join("\n");
462
+ return intro + `<table>
463
+ <thead><tr><th>Descriptor</th><th>Ecosystem</th><th>Direct deps</th></tr></thead>
464
+ <tbody>${rows}</tbody>
465
+ </table>`;
466
+ }
467
+
394
468
  // Présentation des coordonnées, déléguée au codec. Règle générique : le codec
395
469
  // fournit la coord canonique (maven "g:a", npm "name", composer "vendor/pkg"…) ;
396
470
  // Maven la porte déjà son namespace, les autres reçoivent un tag d'écosystème
@@ -980,7 +1054,7 @@ function renderObsoleteTable(obs) {
980
1054
  if (!obs?.length) return `<div class="empty">No obsolete / deprecated libraries detected.</div>`;
981
1055
  const rows = obs.map(o => `<tr>
982
1056
  <td>${badge((o.severity || "MEDIUM").toUpperCase())}</td>
983
- <td class="dep">${esc(depDisplayName(o.dep))}<br><span style="color:#6b7280">${esc(o.dep.version || "?")}</span>${renderDefinedIn(o.dep, RENDER_CTX.srcRoot)}</td>
1057
+ <td class="dep">${esc(depDisplayName(o.dep))}<br><span style="color:#6b7280">${esc(o.dep.version || "?")}</span>${renderDepOrigin(o.dep, RENDER_CTX.srcRoot)}</td>
984
1058
  <td class="dep">${esc(o.replacement || "—")}</td>
985
1059
  <td class="desc">${esc(o.reason || "")}</td>
986
1060
  </tr>`).join("\n");
@@ -1194,21 +1268,19 @@ function renderRetireTable(matches) {
1194
1268
  }
1195
1269
 
1196
1270
  /**
1197
- * One "vulnerable to" clause for a match, in the format requested for the
1198
- * Word-pasteable summary: prefer the primary CWE ("CWE-79 : Cross-site
1199
- * Scripting"), fall back to the CVE id when no weakness is recorded.
1271
+ * The "vulnerable to" clause for a match in the Word-pasteable summary: ALL of
1272
+ * the finding's CWEs ("CWE-79 : Cross-site Scripting, CWE-89 : SQL Injection"),
1273
+ * falling back to the CVE id when no weakness is recorded.
1200
1274
  */
1201
1275
  function execWeakness(m) {
1202
1276
  const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1203
- const primary = cwes.find(c => cweName(c)) || cwes[0];
1204
- if (primary) {
1205
- const label = cweName(primary);
1206
- return label ? `${primary} : ${label}` : primary;
1277
+ if (cwes.length) {
1278
+ return cwes.map(c => { const label = cweName(c); return label ? `${c} : ${label}` : c; }).join(", ");
1207
1279
  }
1208
1280
  return m.cve.id || "an unspecified weakness";
1209
1281
  }
1210
1282
 
1211
- function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, interactive = false }) {
1283
+ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, nativeBinaryCount = 0, interactive = false }) {
1212
1284
  const eolN = eolResults?.length || 0;
1213
1285
  const obsN = obsoleteResults?.length || 0;
1214
1286
  const outN = outdatedResults?.length || 0;
@@ -1222,77 +1294,102 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1222
1294
  else if (prodStats.low > 0 || retireStats.low > 0 || outN > 0) level = "LOW";
1223
1295
  else if (prodStats.total > 0) level = "UNKNOWN";
1224
1296
 
1225
- const bullets = [];
1226
- if (prodStats.total) bullets.push(`<strong>${prodStats.total}</strong> CVE in production deps (critical=${prodStats.critical}, high=${prodStats.high}, medium=${prodStats.medium}, low=${prodStats.low})`);
1227
- if (devStats.total) bullets.push(`<strong>${devStats.total}</strong> CVE in dev/test deps`);
1228
- if (retireStats.total) bullets.push(`<strong>${retireStats.total}</strong> vulnerable vendored JS finding(s) (retire.js)`);
1229
- if (eolN) bullets.push(`<strong>${eolN}</strong> end-of-life framework${eolN > 1 ? "s" : ""}`);
1230
- if (obsN) bullets.push(`<strong>${obsN}</strong> obsolete / deprecated lib${obsN > 1 ? "s" : ""}`);
1231
- if (outN) bullets.push(`<strong>${outN}</strong> outdated lib${outN > 1 ? "s" : ""}`);
1232
- if (warnings?.length) bullets.push(`<strong>${warnings.length}</strong> scan-completeness alert${warnings.length > 1 ? "s" : ""} — see chapter 0`);
1233
-
1234
- const topPreview = (topCriticalMatches || []).length ? `<div class="exec-top">
1235
- <div class="exec-top-title">Top ${topCriticalMatches.length} most critical (preview)</div>
1236
- <ul class="exec-top-list">${topCriticalMatches.map(m => {
1237
- // First known-CWE on the match gets surfaced in the summary so the
1238
- // reader has a one-line answer to "what kind of weakness is this?"
1297
+ const top = topCriticalMatches || [];
1298
+ const topEol = pickTopEol(eolResults, 2);
1299
+
1300
+ const cweChipHtml = (id, name) => `<span class="exec-cwe" title="${esc(id)}${name ? ": " + esc(name) : ""}"><span class="exec-cwe-id">${esc(id)}</span>${name ? `<span class="exec-cwe-name">${esc(name)}</span>` : ""}</span>`;
1301
+
1302
+ // ---- Block 1: the 3 most critical (direct prod, transitive fallback) ----
1303
+ // Each row lists ALL of its CWEs right beside it (the weakness fingerprint
1304
+ // of that specific finding) NOT a separate report-wide CWE aggregate.
1305
+ const topBlock = top.length ? `<div class="exec-top">
1306
+ <div class="exec-top-title">Top ${top.length} most critical · direct production dependencies</div>
1307
+ <ul class="exec-top-list">${top.map(m => {
1239
1308
  const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1240
- const primary = cwes.find(c => cweName(c)) || cwes[0];
1241
- const primaryName = primary ? cweName(primary) : "";
1242
- const cweChip = primary ? `<span class="exec-cwe" title="${esc(primary)}${primaryName ? ": " + esc(primaryName) : ""}"><span class="exec-cwe-id">${esc(primary)}</span>${primaryName ? `<span class="exec-cwe-name">${esc(primaryName)}</span>` : ""}</span>` : "";
1309
+ const cweChips = cwes.length ? `<span class="exec-cwes-chips">${cwes.map(c => cweChipHtml(c, cweName(c))).join("")}</span>` : "";
1310
+ const transitiveTag = m.dep?.scope === "transitive"
1311
+ ? `<span class="exec-scope-tag" title="Pulled in transitively — fewer than ${top.length} direct production findings, so the worst transitive ones fill the rest">transitive</span>` : "";
1243
1312
  return `<li>
1244
1313
  ${badge((m.cve.severity || "HIGH").toUpperCase())}
1245
1314
  <a class="exec-cve-link" href="#ch${m.dep?.scope === "vendored" ? "2" : "1"}">${esc(m.cve.id)}</a>
1246
1315
  <code>${esc(depDisplayName(m.dep))}</code>
1247
1316
  <span class="exec-ver">${esc(m.dep.version || "?")}</span>
1248
1317
  ${m.cve.fixVersion ? `<span class="exec-fix">→ ${esc(m.cve.fixVersion)}</span>` : ""}
1249
- ${cweChip}
1318
+ ${cweChips}
1319
+ ${transitiveTag}
1250
1320
  </li>`;
1251
1321
  }).join("")}</ul>
1252
1322
  </div>` : "";
1253
1323
 
1324
+ // ---- Block 2: the 2 most-overdue end-of-life frameworks ----
1325
+ const eolWhen = e => (e.eol && e.eol !== "true" ? `EOL since ${esc(e.eol)}` : "end-of-life");
1326
+ const eolBlock = topEol.length ? `<div class="exec-eol">
1327
+ <div class="exec-eol-title">${topEol.length === 1 ? "Top end-of-life framework" : `Top ${topEol.length} end-of-life frameworks`}</div>
1328
+ <ul class="exec-eol-list">${topEol.map(e => `<li>
1329
+ <a class="exec-cve-link" href="#ch4">${esc(e.product || e.productSlug || "")}${e.cycle ? " " + esc(e.cycle) : ""}</a>
1330
+ <code>${esc(depDisplayName(e.dep))}</code>
1331
+ <span class="exec-ver">${eolWhen(e)}</span>
1332
+ ${e.latest ? `<span class="exec-fix">→ latest ${esc(e.latest)}</span>` : ""}
1333
+ </li>`).join("")}</ul>
1334
+ </div>` : "";
1335
+
1336
+ // ---- Block 3: a very short summary of everything else ----
1337
+ const restParts = [];
1338
+ if (prodStats.total) restParts.push(`<strong>${prodStats.total}</strong> production CVE (critical=${prodStats.critical}, high=${prodStats.high}, medium=${prodStats.medium}, low=${prodStats.low})`);
1339
+ if (devStats.total) restParts.push(`<strong>${devStats.total}</strong> dev/test CVE`);
1340
+ if (retireStats.total) restParts.push(`<strong>${retireStats.total}</strong> vulnerable vendored-JS`);
1341
+ if (eolN) restParts.push(`<strong>${eolN}</strong> EOL framework${eolN > 1 ? "s" : ""}`);
1342
+ if (obsN) restParts.push(`<strong>${obsN}</strong> obsolete`);
1343
+ if (outN) restParts.push(`<strong>${outN}</strong> outdated`);
1344
+ if (nativeBinaryCount) restParts.push(`<strong>${nativeBinaryCount}</strong> unmanaged native binar${nativeBinaryCount > 1 ? "ies" : "y"}`);
1345
+ if (warnings?.length) restParts.push(`<strong>${warnings.length}</strong> scan alert${warnings.length > 1 ? "s" : ""}`);
1346
+ const restBlock = `<div class="exec-rest"><span class="exec-rest-label">Everything else:</span> ${restParts.length ? restParts.join(" · ") : "no other findings"}</div>`;
1347
+
1254
1348
  // ---- Word-pasteable copy payload (built only for the interactive HTML) ----
1255
1349
  let copyUi = "";
1256
1350
  if (interactive) {
1257
1351
  const stripTags = s => s.replace(/<[^>]+>/g, "");
1258
- const plainBullets = bullets.map(stripTags);
1259
- const top = topCriticalMatches || [];
1260
1352
  const topSentences = top.map(m => ({
1261
1353
  lib: depDisplayName(m.dep),
1262
1354
  ver: m.dep.version || "?",
1263
1355
  weakness: execWeakness(m),
1264
1356
  crit: (m.cve.severity || "HIGH").toUpperCase(),
1357
+ transitive: m.dep?.scope === "transitive",
1265
1358
  }));
1359
+ const eolSentence = e => {
1360
+ const when = e.eol && e.eol !== "true" ? `end-of-life since ${e.eol}` : "end-of-life";
1361
+ return `- "${e.product || e.productSlug || ""}${e.cycle ? " " + e.cycle : ""}" (${depDisplayName(e.dep)}) is ${when}${e.latest ? `; latest is ${e.latest}` : ""}`;
1362
+ };
1363
+ const restText = stripTags(restParts.join(" · ")) || "no other findings";
1266
1364
 
1267
- // Plain-text version (text/plain) — exact bullet format requested.
1268
- const plainLines = [];
1269
- plainLines.push(`EXECUTIVE SUMMARY — ${level}`);
1270
- plainLines.push(`${totalDeps} dependencies scanned`);
1271
- plainLines.push("");
1272
- if (plainBullets.length) for (const b of plainBullets) plainLines.push(`- ${b}`);
1273
- else plainLines.push("- No findings detected.");
1365
+ // Plain-text (text/plain).
1366
+ const plainLines = [`EXECUTIVE SUMMARY — ${level}`, `${totalDeps} dependencies scanned`];
1274
1367
  if (topSentences.length) {
1275
- plainLines.push("");
1276
- plainLines.push(`Top ${topSentences.length} most critical:`);
1277
- for (const t of topSentences) {
1278
- plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )`);
1279
- }
1368
+ plainLines.push("", `Top ${topSentences.length} most critical (direct production dependencies):`);
1369
+ for (const t of topSentences) plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )${t.transitive ? " [transitive]" : ""}`);
1370
+ }
1371
+ if (topEol.length) {
1372
+ plainLines.push("", `Top ${topEol.length} end-of-life framework${topEol.length > 1 ? "s" : ""}:`);
1373
+ for (const e of topEol) plainLines.push(eolSentence(e));
1280
1374
  }
1375
+ plainLines.push("", `Everything else: ${restText}`);
1281
1376
  const plainText = plainLines.join("\n");
1282
1377
 
1283
- // Rich version (text/html) — inline styles so Word keeps the formatting.
1284
- const richBullets = plainBullets.length
1285
- ? `<ul style="margin:4px 0 0; padding-left:22px;">${plainBullets.map(b => `<li style="margin:2px 0;">${esc(b)}</li>`).join("")}</ul>`
1286
- : `<p style="margin:4px 0;">No findings detected.</p>`;
1378
+ // Rich (text/html) — inline styles so Word keeps the formatting.
1287
1379
  const richTop = topSentences.length
1288
- ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical:</p>
1380
+ ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical (direct production dependencies):</p>
1289
1381
  <ul style="margin:0; padding-left:22px;">${topSentences.map(t =>
1290
- `<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )</li>`).join("")}</ul>`
1382
+ `<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )${t.transitive ? ` <i>[transitive]</i>` : ""}</li>`).join("")}</ul>`
1291
1383
  : "";
1384
+ const richEol = topEol.length
1385
+ ? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topEol.length} end-of-life framework${topEol.length > 1 ? "s" : ""}:</p>
1386
+ <ul style="margin:0; padding-left:22px;">${topEol.map(e => `<li style="margin:3px 0;">${esc(stripTags(eolSentence(e)).replace(/^- /, ""))}</li>`).join("")}</ul>`
1387
+ : "";
1388
+ const richRest = `<p style="margin:12px 0 0;"><b>Everything else:</b> ${esc(restText)}</p>`;
1292
1389
  const richHtml = `<div style="font-family:Calibri,Arial,sans-serif; font-size:11pt; color:#1f2937;">`
1293
1390
  + `<p style="font-size:14pt; font-weight:bold; margin:0 0 2px;">Executive Summary — ${esc(level)}</p>`
1294
1391
  + `<p style="margin:0 0 8px; color:#4b5563;">${esc(totalDeps)} dependencies scanned</p>`
1295
- + richBullets + richTop + `</div>`;
1392
+ + richTop + richEol + richRest + `</div>`;
1296
1393
 
1297
1394
  copyUi = `<button class="btn-copy exec-copy-btn" type="button" title="Copy the executive summary — paste it into Word with formatting preserved">📋 Copy summary</button>`
1298
1395
  + `<template class="exec-copy-rich">${richHtml}</template>`
@@ -1306,10 +1403,9 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1306
1403
  ${copyUi}
1307
1404
  </div>
1308
1405
  <div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
1309
- <ul class="exec-bullets">
1310
- ${bullets.map(b => `<li>${b}</li>`).join("") || `<li>No findings detected.</li>`}
1311
- </ul>
1312
- ${topPreview}
1406
+ ${topBlock}
1407
+ ${eolBlock}
1408
+ ${restBlock}
1313
1409
  </div>`;
1314
1410
  }
1315
1411
 
@@ -1389,7 +1485,7 @@ function renderLicenseChapter(licenseResults) {
1389
1485
  return intro + blocks;
1390
1486
  }
1391
1487
 
1392
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1488
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
1393
1489
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1394
1490
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1395
1491
  cveMatches = mergeMatches(cveMatches || []);
@@ -1439,20 +1535,27 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1439
1535
  <span class="toolbar-label">Click a section header or a CVE row to toggle.</span>
1440
1536
  </div>`;
1441
1537
 
1538
+ // Committed native binaries no package manager governs (provenance:"binary",
1539
+ // no resolved version) — surfaced as a count in the exec summary's rest line.
1540
+ const nativeBinaryCount = [...(resolvedDeps?.values?.() || [])].filter(d => d.provenance === "binary").length;
1541
+
1442
1542
  const exec = renderExecutiveSummary({
1443
1543
  projectInfo, prodStats, devStats, retireStats,
1444
1544
  eolResults, obsoleteResults, outdatedResults,
1445
1545
  warnings, totalDeps: resolvedDeps?.size || 0,
1446
- topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
1546
+ topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, 3),
1547
+ nativeBinaryCount,
1447
1548
  interactive: wrapTables, // copy button + scripts only in the HTML output
1448
1549
  });
1449
1550
 
1450
1551
  // (C) Table of contents — rendered as a sticky nav. Only enumerates the
1451
1552
  // chapters that actually have content (warnings, dev CVE, retire, FP appendix
1452
1553
  // might be empty).
1554
+ const scanned = buildScannedManifests(resolvedDeps, projectInfo?.src, parsedManifests);
1453
1555
  const toc = renderToc({
1454
1556
  hasWarnings: !!(warnings?.length),
1455
1557
  prodTotal: prodStats.total,
1558
+ scannedTotal: scanned.length,
1456
1559
  retireTotal: retireMatches.length,
1457
1560
  devTotal: devStats.total,
1458
1561
  eolTotal: eolResults?.length || 0,
@@ -1466,17 +1569,21 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1466
1569
  fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1467
1570
  });
1468
1571
 
1572
+ // Overview charts row, rendered right under the compact totals.
1573
+ const chartsRow = renderCharts({ prodMatches: prodMatchesActive }, { interactive: wrapTables });
1574
+
1469
1575
  const body = `
1470
1576
  <header class="report-header">
1471
1577
  <h1>FAD-Checker Report</h1>
1472
1578
  <div class="report-subtitle">Multi-ecosystem dependency security audit</div>
1473
1579
  <div class="meta">
1474
- Project: <strong>${esc(projectInfo.name)}</strong> · <code class="path">${esc(projectInfo.src)}</code><br>
1580
+ Project: <strong>${esc(projectInfo.name)}</strong><br>
1475
1581
  Generated: ${esc(projectInfo.generatedAt)}${projectInfo.toolVersion ? ` · fad-checker ${esc(projectInfo.toolVersion)}` : ""}${projectInfo.cveDataDate ? ` · CVE data: ${esc(projectInfo.cveDataDate)}` : ""}
1476
1582
  </div>
1477
1583
  </header>
1478
1584
  ${exec}
1479
1585
  ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length, licenseFlagged })}
1586
+ ${chartsRow}
1480
1587
  ${toc}
1481
1588
  ${toolbar}
1482
1589
 
@@ -1493,34 +1600,43 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1493
1600
  ${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
1494
1601
  ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1495
1602
  ${(prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length) ? majorSection(`9. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length})`, fpContent, { open: false, id: "ch9" }) : ""}
1603
+ ${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
1496
1604
  `;
1497
1605
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1498
1606
  }
1499
1607
 
1608
+ // Descending composite-priority comparator: KEV first, then the blended
1609
+ // CVSS+EPSS score, tie-broken on having an actionable fix, then CVE id.
1610
+ function byCvePriority(a, b) {
1611
+ const pa = a.cve?.priority || computePriority(a.cve || {});
1612
+ const pb = b.cve?.priority || computePriority(b.cve || {});
1613
+ const ka = pa.sortKey || [0, 0], kb = pb.sortKey || [0, 0];
1614
+ if (kb[0] !== ka[0]) return kb[0] - ka[0];
1615
+ if (kb[1] !== ka[1]) return kb[1] - ka[1];
1616
+ const af = a.cve?.fixVersion ? 1 : 0, bf = b.cve?.fixVersion ? 1 : 0;
1617
+ if (bf !== af) return bf - af;
1618
+ return String(a.cve?.id || "").localeCompare(String(b.cve?.id || ""));
1619
+ }
1620
+
1621
+ function coordKeyOf(dep) {
1622
+ return dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : (dep.groupId || "") + ":"}${dep.artifactId}`;
1623
+ }
1624
+
1500
1625
  /**
1501
- * Pick the most impactful matches to preview in the exec summary.
1502
- * Sort by severity rank, prefer entries with a fix-version (more actionable),
1503
- * dedupe per dep coord so the preview shows diversity instead of 5 rows of
1504
- * the same tomcat CVE.
1626
+ * Pick the N most critical matches for the exec summary, scoped to NON-DEV
1627
+ * dependencies and biased to DIRECT (declared) deps: direct findings fill the
1628
+ * slots first (ranked by composite priority); only if fewer than N direct
1629
+ * findings exist do we top up with the worst TRANSITIVE ones (flagged as such
1630
+ * downstream). Deduped per dep coord so the list shows distinct libraries.
1505
1631
  */
1506
- function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1507
- const rank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
1508
- const all = [...prodMatches, ...retireMatches]
1509
- .filter(m => (rank[(m.cve?.severity || "").toUpperCase()] || 0) >= 3); // HIGH+
1510
- all.sort((a, b) => {
1511
- const sa = rank[(a.cve.severity || "").toUpperCase()] || 0;
1512
- const sb = rank[(b.cve.severity || "").toUpperCase()] || 0;
1513
- if (sb !== sa) return sb - sa;
1514
- // Prefer those with a known fixVersion (actionable)
1515
- const af = a.cve.fixVersion ? 1 : 0, bf = b.cve.fixVersion ? 1 : 0;
1516
- if (bf !== af) return bf - af;
1517
- return (a.cve.id || "").localeCompare(b.cve.id || "");
1518
- });
1632
+ function pickTopCriticalMatches(prodMatches, n) {
1633
+ const isTransitive = m => m.dep?.scope === "transitive";
1634
+ const direct = (prodMatches || []).filter(m => !isTransitive(m)).slice().sort(byCvePriority);
1635
+ const transitive = (prodMatches || []).filter(isTransitive).slice().sort(byCvePriority);
1519
1636
  const seen = new Set();
1520
1637
  const out = [];
1521
- for (const m of all) {
1522
- const dep = m.dep;
1523
- const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1638
+ for (const m of [...direct, ...transitive]) { // direct-first, transitive fallback
1639
+ const k = coordKeyOf(m.dep);
1524
1640
  if (seen.has(k)) continue;
1525
1641
  seen.add(k);
1526
1642
  out.push(m);
@@ -1529,7 +1645,26 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1529
1645
  return out;
1530
1646
  }
1531
1647
 
1532
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1648
+ /**
1649
+ * The 2 (or N) most significant end-of-life findings for the exec summary:
1650
+ * direct frameworks first, then the most overdue (oldest EOL date). Findings
1651
+ * with no concrete EOL date (eol === "true") sort after dated ones.
1652
+ */
1653
+ function pickTopEol(eolResults, n) {
1654
+ const dateMs = e => {
1655
+ if (!e || !e.eol || e.eol === "true") return Number.POSITIVE_INFINITY;
1656
+ const t = Date.parse(e.eol);
1657
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
1658
+ };
1659
+ const directRank = e => (e?.dep?.scope === "transitive" ? 1 : 0);
1660
+ return (eolResults || []).slice().sort((a, b) => {
1661
+ const da = directRank(a), db = directRank(b);
1662
+ if (da !== db) return da - db;
1663
+ return dateMs(a) - dateMs(b);
1664
+ }).slice(0, n);
1665
+ }
1666
+
1667
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal }) {
1533
1668
  const entries = [];
1534
1669
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1535
1670
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
@@ -1544,6 +1679,7 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
1544
1679
  entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
1545
1680
  entries.push({ id: "ch8", label: `8. Fix Recos` });
1546
1681
  if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1682
+ entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
1547
1683
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1548
1684
  }
1549
1685
 
@@ -1774,9 +1910,11 @@ const COPY_SCRIPT = `
1774
1910
  return writeClipboard(clone.outerHTML, tableToTsv(table), btn);
1775
1911
  }
1776
1912
  document.querySelectorAll('.btn-copy').forEach(function(btn){
1777
- // The executive-summary button is wired separately (it copies a prose
1778
- // summary, not a table).
1913
+ // The executive-summary button and the chart buttons are wired separately
1914
+ // (prose summary / SVG→PNG). Skipping them here also avoids the table
1915
+ // handler's stopPropagation() swallowing the chart click.
1779
1916
  if (btn.classList.contains('exec-copy-btn')) return;
1917
+ if (btn.classList.contains('chart-copy')) return;
1780
1918
  btn.addEventListener('click', function(e){
1781
1919
  e.stopPropagation();
1782
1920
  var wrap = btn.closest('.table-wrap');
@@ -1849,10 +1987,86 @@ const TOGGLE_SCRIPT = `
1849
1987
  })();
1850
1988
  </script>`;
1851
1989
 
1990
+ // Chart copy: rasterise the clicked card's <svg> to PNG (SVG → Image → canvas →
1991
+ // toBlob) and put image/png on the clipboard so it pastes into Word as a picture.
1992
+ // A white background is painted first (Word has no page background) and the canvas
1993
+ // is scaled ×3 for a crisp paste. Falls back to copying the SVG markup as rich
1994
+ // HTML when the browser lacks image-clipboard support.
1995
+ const CHART_COPY_SCRIPT = `
1996
+ <script>
1997
+ (function(){
1998
+ function flash(btn, ok){
1999
+ if(!btn) return;
2000
+ var t = btn.textContent;
2001
+ btn.classList.add(ok ? 'flashing-ok' : 'flashing-err');
2002
+ btn.textContent = ok ? '✓' : '✕';
2003
+ setTimeout(function(){ btn.classList.remove('flashing-ok','flashing-err'); btn.textContent = t; }, 1100);
2004
+ }
2005
+ function fallbackHtml(svg, btn){
2006
+ try {
2007
+ var xml = new XMLSerializer().serializeToString(svg);
2008
+ var item = new ClipboardItem({'text/html': new Blob([xml], {type:'text/html'})});
2009
+ navigator.clipboard.write([item]).then(function(){ flash(btn,true); }).catch(function(){ flash(btn,false); });
2010
+ } catch(e){ flash(btn,false); }
2011
+ }
2012
+ function roundRectPath(ctx, x, y, w, h, r){
2013
+ if (ctx.roundRect){ ctx.beginPath(); ctx.roundRect(x, y, w, h, r); return; }
2014
+ ctx.beginPath();
2015
+ ctx.moveTo(x+r, y);
2016
+ ctx.arcTo(x+w, y, x+w, y+h, r);
2017
+ ctx.arcTo(x+w, y+h, x, y+h, r);
2018
+ ctx.arcTo(x, y+h, x, y, r);
2019
+ ctx.arcTo(x, y, x+w, y, r);
2020
+ ctx.closePath();
2021
+ }
2022
+ function svgToPng(svg, ok, err){
2023
+ try {
2024
+ var xml = new XMLSerializer().serializeToString(svg);
2025
+ var vb = svg.viewBox && svg.viewBox.baseVal;
2026
+ var w = (vb && vb.width) || svg.clientWidth || 250;
2027
+ var h = (vb && vb.height) || svg.clientHeight || 150;
2028
+ var img = new Image();
2029
+ img.onload = function(){
2030
+ // Draw the chart inside a rounded white card with an outer margin,
2031
+ // inner padding and a light border — so the pasted PNG matches the
2032
+ // on-screen card instead of being a flush, square-cornered image.
2033
+ var scale = 3, M = 8, P = 12, RAD = 8;
2034
+ var cw = w + 2*P + 2*M, ch = h + 2*P + 2*M;
2035
+ var c = document.createElement('canvas');
2036
+ c.width = Math.round(cw*scale); c.height = Math.round(ch*scale);
2037
+ var ctx = c.getContext('2d');
2038
+ ctx.setTransform(scale,0,0,scale,0,0); // transparent margin (corners show through)
2039
+ roundRectPath(ctx, M, M, w + 2*P, h + 2*P, RAD);
2040
+ ctx.fillStyle = '#ffffff'; ctx.fill();
2041
+ ctx.lineWidth = 1; ctx.strokeStyle = '#e5e7eb'; ctx.stroke();
2042
+ ctx.drawImage(img, M+P, M+P, w, h);
2043
+ c.toBlob(function(blob){ blob ? ok(blob) : err(); }, 'image/png');
2044
+ };
2045
+ img.onerror = err;
2046
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
2047
+ } catch(e){ err(); }
2048
+ }
2049
+ document.addEventListener('click', function(ev){
2050
+ var btn = ev.target.closest && ev.target.closest('.chart-copy');
2051
+ if(!btn) return;
2052
+ var card = btn.closest('.chart-card');
2053
+ var svg = card && card.querySelector('svg.chart-svg');
2054
+ if(!svg) return;
2055
+ svgToPng(svg, function(blob){
2056
+ if(navigator.clipboard && window.ClipboardItem){
2057
+ navigator.clipboard.write([new ClipboardItem({'image/png': blob})])
2058
+ .then(function(){ flash(btn,true); })
2059
+ .catch(function(){ fallbackHtml(svg, btn); });
2060
+ } else { fallbackHtml(svg, btn); }
2061
+ }, function(){ fallbackHtml(svg, btn); });
2062
+ });
2063
+ })();
2064
+ </script>`;
2065
+
1852
2066
  function generateHtmlReport(payload) {
1853
2067
  return `<!doctype html>
1854
2068
  <html><head><meta charset="utf-8"><title>FAD-Checker Report</title><style>${CSS}</style></head>
1855
- <body>${buildBody(payload)}${TOGGLE_SCRIPT}${COPY_SCRIPT}</body></html>`;
2069
+ <body>${buildBody(payload)}${TOGGLE_SCRIPT}${COPY_SCRIPT}${CHART_COPY_SCRIPT}</body></html>`;
1856
2070
  }
1857
2071
 
1858
2072
  // Word-specific CSS tweaks layered on top of the shared CSS:
@@ -2006,12 +2220,12 @@ ${WORD_SECTION_PR}
2006
2220
  // Render the HTML and/or Word report. Callers pass explicit target paths;
2007
2221
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
2008
2222
  // Back-compat: `outputDir` still writes both under the default file names.
2009
- async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
2223
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
2010
2224
  if (outputDir) {
2011
2225
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
2012
2226
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
2013
2227
  }
2014
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
2228
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests };
2015
2229
  const written = { htmlPath: null, docPath: null };
2016
2230
  if (htmlPath) {
2017
2231
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * lib/deps-descriptor.js — serialize / deserialize an *anonymized* descriptor of
3
- * the resolved dependency set, for PASSI-style offline→online→offline audits.
3
+ * the resolved dependency set, for air-gapped offline→online→offline audits.
4
4
  *
5
5
  * Phase 1 (offline): collect deps → serializeDeps → write JSON. No paths, URLs,
6
6
  * hostnames or usernames leave the air-gapped machine — only public package
@@ -10,7 +10,7 @@
10
10
  * Phase 3 (offline): `--import-cache` + a normal `--offline` scan re-collects the
11
11
  * source locally (real paths) and gets cache hits → full detailed report.
12
12
  *
13
- * See docs/superpowers/specs/2026-05-30-anonymized-deps-descriptor-passi-design.md
13
+ * See the anonymized-deps-descriptor design spec.
14
14
  *
15
15
  * Pure functions: no I/O, no console. The caller does file read/write.
16
16
  *
package/lib/osv-db.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * and matches EVERY dep against it offline, deterministically, regardless of the per-dep
10
10
  * cache. That is exactly the model OSV-Scanner uses for air-gapped scans
11
11
  * (`--download-offline-databases`), and it makes fad's offline Maven recall complete and
12
- * cache-independent for a PASSI engagement.
12
+ * cache-independent for an air-gapped engagement.
13
13
  *
14
14
  * Maven only for now: range matching needs the ecosystem's version ordering, and fad has
15
15
  * a robust Maven comparator (lib/maven-version). npm/PyPI/etc. are a documented follow-up