fad-checker 2.0.0 → 2.1.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +147 -25
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/eol-mapping.json +5 -5
  6. package/data/license-policy.json +97 -0
  7. package/fad-checker-report/cve-report.doc +630 -0
  8. package/fad-checker-report/cve-report.html +748 -0
  9. package/fad-checker.js +556 -245
  10. package/lib/cache-archive.js +3 -0
  11. package/lib/codecs/codec.interface.js +3 -0
  12. package/lib/codecs/composer/parse.js +3 -0
  13. package/lib/codecs/composer/registry.js +13 -5
  14. package/lib/codecs/composer.codec.js +3 -0
  15. package/lib/codecs/go/parse.js +65 -0
  16. package/lib/codecs/go/registry.js +74 -0
  17. package/lib/codecs/go.codec.js +76 -0
  18. package/lib/codecs/index.js +7 -2
  19. package/lib/codecs/maven/jar-scan.js +199 -0
  20. package/lib/codecs/maven.codec.js +17 -1
  21. package/lib/codecs/npm/collect.js +37 -17
  22. package/lib/codecs/npm/parse.js +117 -10
  23. package/lib/codecs/npm/registry.js +14 -5
  24. package/lib/codecs/npm.codec.js +4 -1
  25. package/lib/codecs/nuget/parse.js +3 -0
  26. package/lib/codecs/nuget/registry.js +11 -4
  27. package/lib/codecs/nuget.codec.js +7 -2
  28. package/lib/codecs/pypi/parse.js +111 -14
  29. package/lib/codecs/pypi/registry.js +24 -5
  30. package/lib/codecs/pypi.codec.js +30 -6
  31. package/lib/codecs/recipes.js +28 -1
  32. package/lib/codecs/ruby/parse.js +42 -0
  33. package/lib/codecs/ruby/registry.js +76 -0
  34. package/lib/codecs/ruby.codec.js +66 -0
  35. package/lib/codecs/select.js +3 -0
  36. package/lib/codecs/yarn.codec.js +3 -0
  37. package/lib/config.js +3 -0
  38. package/lib/core.js +38 -1
  39. package/lib/cpe.js +30 -5
  40. package/lib/csaf-export.js +159 -0
  41. package/lib/cve-download.js +3 -0
  42. package/lib/cve-match.js +27 -6
  43. package/lib/cve-report.js +157 -28
  44. package/lib/dep-record.js +15 -2
  45. package/lib/deps-descriptor.js +113 -0
  46. package/lib/epss.js +115 -0
  47. package/lib/gate.js +45 -0
  48. package/lib/json-export.js +110 -0
  49. package/lib/kev.js +88 -0
  50. package/lib/license-policy.js +110 -0
  51. package/lib/maven-license.js +52 -0
  52. package/lib/maven-repo.js +3 -0
  53. package/lib/maven-version.js +8 -3
  54. package/lib/nvd.js +17 -6
  55. package/lib/osv.js +75 -23
  56. package/lib/outdated.js +10 -2
  57. package/lib/priority.js +90 -0
  58. package/lib/purl.js +77 -0
  59. package/lib/retire.js +80 -13
  60. package/lib/sarif-export.js +134 -0
  61. package/lib/sbom-export.js +153 -0
  62. package/lib/scan-completeness.js +3 -0
  63. package/lib/snyk.js +3 -0
  64. package/lib/suppress.js +113 -0
  65. package/lib/transitive.js +6 -3
  66. package/lib/ui.js +90 -0
  67. package/package.json +49 -2
  68. package/CLAUDE.md +0 -147
  69. package/docs/ARCHITECTURE.md +0 -182
  70. package/docs/USAGE.md +0 -187
  71. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  72. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  73. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  74. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  75. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  76. package/test/cli-ecosystem.test.js +0 -30
  77. package/test/codec-edge-cases.test.js +0 -131
  78. package/test/codec-integration.test.js +0 -119
  79. package/test/codecs.test.js +0 -92
  80. package/test/composer.test.js +0 -71
  81. package/test/core.test.js +0 -153
  82. package/test/cpe.test.js +0 -166
  83. package/test/cve-download.test.js +0 -39
  84. package/test/cve-match.test.js +0 -217
  85. package/test/cve-report.test.js +0 -180
  86. package/test/dep-record.test.js +0 -31
  87. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  88. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  89. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  90. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  91. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  92. package/test/fixtures/csharp-config/packages.config +0 -4
  93. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  94. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  95. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  96. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  97. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  98. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  99. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  100. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  101. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  102. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  103. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  104. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  105. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  106. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  107. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  108. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  109. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  110. package/test/fixtures/php-app/composer.json +0 -5
  111. package/test/fixtures/php-app/composer.lock +0 -10
  112. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  113. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  114. package/test/fixtures/polyglot/js/package.json +0 -1
  115. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  116. package/test/fixtures/polyglot/php/composer.lock +0 -1
  117. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  118. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  119. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  120. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  121. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  122. package/test/fixtures/python-poetry/poetry.lock +0 -7
  123. package/test/fixtures/python-reqs/requirements.txt +0 -5
  124. package/test/fixtures/python-uv/uv.lock +0 -3
  125. package/test/fixtures/simple/app/pom.xml +0 -28
  126. package/test/fixtures/simple/lib/pom.xml +0 -18
  127. package/test/fixtures/simple/pom.xml +0 -24
  128. package/test/maven-repo.test.js +0 -111
  129. package/test/maven-version.test.js +0 -57
  130. package/test/monorepo.test.js +0 -132
  131. package/test/npm-registry.test.js +0 -64
  132. package/test/npm.test.js +0 -150
  133. package/test/nuget.test.js +0 -66
  134. package/test/osv.test.js +0 -62
  135. package/test/outdated.test.js +0 -101
  136. package/test/pypi.test.js +0 -72
  137. package/test/snyk.test.js +0 -64
  138. package/test/transitive.test.js +0 -305
  139. package/test/webjar.test.js +0 -33
@@ -0,0 +1,159 @@
1
+ /**
2
+ * lib/csaf-export.js — emit a CSAF 2.0 VEX (csaf_vex) document from the resolved
3
+ * deps + matches. buildCsaf is pure; writeCsaf writes the JSON to disk.
4
+ *
5
+ * Spec: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
9
+ */
10
+ const fs = require("fs");
11
+ const { purlFor } = require("./purl");
12
+
13
+ function humanName(dep) {
14
+ const ns = dep.namespace || dep.groupId || "";
15
+ const name = dep.name || dep.artifactId;
16
+ const base = dep.ecosystem === "composer" && ns ? `${ns}/${name}` : (ns && dep.ecosystem === "maven" ? `${ns}:${name}` : name);
17
+ return dep.version ? `${base}@${dep.version}` : base;
18
+ }
19
+
20
+ // NVD "CVSS:3.1" → CVSS v3 JSON `version` field, or null when not a v3 score.
21
+ // Tolerates the legacy "CVSS:V31"/"CVSS:V30" labels still sitting in pre-fix NVD
22
+ // caches (the producer now emits "CVSS:3.1"; this keeps offline caches working).
23
+ function cvssV3Version(cvssVersion) {
24
+ const v = String(cvssVersion || "");
25
+ if (v.includes("3.1") || /v31\b/i.test(v)) return "3.1";
26
+ if (v.includes("3.0") || v === "CVSS:3" || /v30\b/i.test(v)) return "3.0";
27
+ return null;
28
+ }
29
+
30
+ // CVSS v3 `baseSeverity` is a closed enum (NONE|LOW|MEDIUM|HIGH|CRITICAL). OSV/GHSA
31
+ // often yield "UNKNOWN", which fails CSAF schema validation — derive it from the
32
+ // base score's standard v3 bands when the label isn't a valid enum value.
33
+ function cvssV3Severity(severity, score) {
34
+ const s = String(severity || "").toUpperCase();
35
+ if (["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"].includes(s)) return s;
36
+ if (typeof score !== "number") return "NONE";
37
+ if (score === 0) return "NONE";
38
+ if (score < 4) return "LOW";
39
+ if (score < 7) return "MEDIUM";
40
+ if (score < 9) return "HIGH";
41
+ return "CRITICAL";
42
+ }
43
+
44
+ /**
45
+ * Build a CSAF 2.0 csaf_vex object.
46
+ * opts: { projectInfo, toolVersion, timestamp }
47
+ */
48
+ function buildCsaf(resolvedDeps, cveMatches, opts = {}) {
49
+ const { projectInfo = {}, toolVersion = "0", timestamp } = opts;
50
+ const now = timestamp || projectInfo.generatedAt || "1970-01-01T00:00:00Z";
51
+
52
+ // Stable product ids + coordKey → product_id lookup.
53
+ const full_product_names = [];
54
+ const productIdByCoord = new Map();
55
+ let n = 0;
56
+ for (const dep of resolvedDeps.values()) {
57
+ const pid = `PROD-${n++}`;
58
+ const purl = purlFor(dep);
59
+ productIdByCoord.set(dep.coordKey, pid);
60
+ full_product_names.push({
61
+ product_id: pid,
62
+ name: humanName(dep),
63
+ ...(purl ? { product_identification_helper: { purl } } : {}),
64
+ });
65
+ }
66
+
67
+ // One vulnerability per CVE, listing every affected product id.
68
+ const byCve = new Map();
69
+ for (const m of cveMatches || []) {
70
+ const id = m.cve?.id;
71
+ if (!id) continue;
72
+ let pid = productIdByCoord.get(m.dep?.coordKey);
73
+ // A match whose dep isn't in resolvedDeps (e.g. a Snyk-only finding) has no
74
+ // product yet — register it on the fly so the CVE never ends up with an empty
75
+ // known_affected[] (which is schema-invalid: product_status lists need ≥1 item).
76
+ if (!pid && m.dep && m.dep.coordKey) {
77
+ pid = `PROD-${n++}`;
78
+ const purl = purlFor(m.dep);
79
+ productIdByCoord.set(m.dep.coordKey, pid);
80
+ full_product_names.push({
81
+ product_id: pid,
82
+ name: humanName(m.dep),
83
+ ...(purl ? { product_identification_helper: { purl } } : {}),
84
+ });
85
+ }
86
+ let v = byCve.get(id);
87
+ if (!v) {
88
+ const cve = m.cve;
89
+ const notes = [];
90
+ if (cve.description) notes.push({ category: "description", text: cve.description, title: "Description" });
91
+ const extras = [];
92
+ if (cve.epssPercentile != null) extras.push(`EPSS percentile ${Math.round(cve.epssPercentile * 100)}%`);
93
+ if (cve.kev) extras.push(`CISA KEV (known exploited)${cve.kevDueDate ? `, remediate by ${cve.kevDueDate}` : ""}`);
94
+ if (cve.priority?.band) extras.push(`fad-checker priority: ${cve.priority.band} (${cve.priority.score}/100)`);
95
+ if (extras.length) notes.push({ category: "other", text: extras.join("; "), title: "Prioritization" });
96
+
97
+ const v3ver = cvssV3Version(cve.cvssVersion);
98
+ const scores = (v3ver && cve.score != null) ? [{
99
+ cvss_v3: {
100
+ version: v3ver,
101
+ baseScore: cve.score,
102
+ baseSeverity: cvssV3Severity(cve.severity, cve.score),
103
+ // Only carry the vector when it actually IS a v3 vector — a v4 (or
104
+ // mismatched) vector string fails the v3 schema's vectorString pattern.
105
+ ...((cve.cvssVector && /^CVSS:3\.[01]\//.test(cve.cvssVector)) ? { vectorString: cve.cvssVector } : {}),
106
+ },
107
+ products: [],
108
+ }] : [];
109
+
110
+ v = {
111
+ cve: id,
112
+ ...(notes.length ? { notes } : {}),
113
+ product_status: { known_affected: [] },
114
+ ...(scores.length ? { scores } : {}),
115
+ ...(cve.kev ? { flags: [{ label: "exploited", product_ids: [] }] } : {}),
116
+ };
117
+ byCve.set(id, v);
118
+ }
119
+ if (pid && !v.product_status.known_affected.includes(pid)) {
120
+ v.product_status.known_affected.push(pid);
121
+ if (v.scores?.[0]) v.scores[0].products.push(pid);
122
+ if (v.flags?.[0]) v.flags[0].product_ids.push(pid);
123
+ }
124
+ }
125
+
126
+ return {
127
+ document: {
128
+ category: "csaf_vex",
129
+ csaf_version: "2.0",
130
+ title: `Vulnerability disclosure (VEX) — ${projectInfo.name || "project"}`,
131
+ publisher: {
132
+ category: "vendor",
133
+ name: "fad-checker",
134
+ namespace: "https://github.com/nathb2b/fad-checker",
135
+ },
136
+ tracking: {
137
+ id: `fad-checker-${(projectInfo.name || "project").replace(/[^A-Za-z0-9._-]/g, "-")}-${String(now).slice(0, 10)}`,
138
+ status: "final",
139
+ version: "1",
140
+ generator: { engine: { name: "fad-checker", version: String(toolVersion) } },
141
+ initial_release_date: now,
142
+ current_release_date: now,
143
+ revision_history: [{ number: "1", date: now, summary: "Initial automated VEX from fad-checker scan" }],
144
+ },
145
+ },
146
+ product_tree: { full_product_names },
147
+ // Drop any vulnerability that ended up with zero affected products (e.g. a
148
+ // match with no usable dep) — an empty product_status is schema-invalid.
149
+ vulnerabilities: [...byCve.values()].filter(v => v.product_status.known_affected.length > 0),
150
+ };
151
+ }
152
+
153
+ function writeCsaf(resolvedDeps, cveMatches, outputPath, opts = {}) {
154
+ const doc = buildCsaf(resolvedDeps, cveMatches, opts);
155
+ fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n");
156
+ return doc;
157
+ }
158
+
159
+ module.exports = { buildCsaf, writeCsaf };
@@ -5,6 +5,9 @@
5
5
  * The bundle is ~500 MB+. We shell out to curl + unzip rather than
6
6
  * bundling a zip library. The extracted JSON is deleted after the index
7
7
  * is built.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
  const fs = require("fs");
10
13
  const path = require("path");
package/lib/cve-match.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * lib/cve-match.js — collect resolved deps from the parsed POM tree and
3
3
  * match them against a CVE index built by lib/cve-download.js.
4
+ *
5
+ * @author: N.BRAUN
6
+ * @email: pp9ping@gmail.com
4
7
  */
5
8
  const { coord } = require("./core");
6
9
  const { compareMavenVersions, isVersionAffected } = require("./maven-version");
@@ -12,11 +15,21 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOW
12
15
  function resolveDepVersion(rawVersion, allProps) {
13
16
  if (!rawVersion) return null;
14
17
  // Resolve ${prop} references using the merged property map for this POM.
15
- return String(rawVersion).replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
16
- const v = allProps?.[k];
17
- if (Array.isArray(v)) return v[0];
18
- return v != null ? v : m;
19
- });
18
+ // Maven interpolates recursively (e.g. ${project.version} ${revision}
19
+ // "1.0"), so we re-substitute until the string stops changing. Bounded +
20
+ // stop-on-no-progress guards against property cycles. Unknown refs are left
21
+ // verbatim, so they still surface as "unresolved-versions" in chapter 0.
22
+ let v = String(rawVersion);
23
+ for (let i = 0; i < 10 && v.includes("${"); i++) {
24
+ const next = v.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
25
+ const val = allProps?.[k];
26
+ if (Array.isArray(val)) return val[0];
27
+ return val != null ? val : m;
28
+ });
29
+ if (next === v) break; // no progress → remaining refs are unresolvable
30
+ v = next;
31
+ }
32
+ return v;
20
33
  }
21
34
 
22
35
  /**
@@ -109,12 +122,17 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
109
122
  // project pins a transitive's version via <dependencyManagement>.
110
123
  const rootDepMgmt = new Map();
111
124
  for (const dep of resolvedDeps.values()) {
125
+ // Embedded-binary coords don't participate in Maven Central resolution (a
126
+ // fat-jar already ships its deps, discovered by recursion) and must not act as
127
+ // a depMgmt override for the declared tree.
128
+ if (dep.provenance === "embedded") continue;
112
129
  if (dep.version && !/\$\{/.test(dep.version)) {
113
130
  rootDepMgmt.set(`${dep.groupId}:${dep.artifactId}`, { version: dep.version });
114
131
  }
115
132
  }
116
133
 
117
134
  const directs = [...resolvedDeps.values()]
135
+ .filter(d => d.provenance !== "embedded")
118
136
  .filter(d => d.version && !/\$\{/.test(d.version))
119
137
  .filter(d => d.scope !== "test" || opts.includeTestDeps)
120
138
  .filter(d => d.scope !== "parent");
@@ -228,7 +246,10 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
228
246
  const seen = new Set();
229
247
  const deduped = [];
230
248
  for (const m of all) {
231
- const k = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
249
+ // Key by coordKey (not g:a): identical to g:a for declared Maven deps, but
250
+ // keeps embedded-binary findings (coordKey "embedded:<path>") distinct from a
251
+ // declared dep with the same g:a:v so the embedded chapter doesn't lose them.
252
+ const k = `${m.dep.coordKey}:${m.dep.version}|${m.cve.id}`;
232
253
  if (seen.has(k)) continue;
233
254
  seen.add(k);
234
255
  deduped.push(m);
package/lib/cve-report.js CHANGED
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Word opens HTML saved as .doc natively — we use the same HTML body
6
6
  * wrapped in Word XML namespace meta tags.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
10
13
  const { getCodec, allCodecs, ORDER } = require("./codecs");
14
+ const { computePriority, sortByPriority } = require("./priority");
11
15
 
12
16
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
13
17
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -34,13 +38,14 @@ function esc(s) {
34
38
  }
35
39
 
36
40
  function computeStats(cveMatches) {
37
- const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0 };
41
+ const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0, kev: 0 };
38
42
  for (const m of cveMatches || []) {
39
43
  const sev = (m.cve.severity || "UNKNOWN").toLowerCase();
40
44
  if (buckets[sev] != null) buckets[sev]++;
41
45
  else buckets.unknown++;
42
46
  if (m.dep?.scope === "transitive") buckets.transitive++;
43
47
  else buckets.direct++;
48
+ if (m.cve?.kev) buckets.kev++;
44
49
  }
45
50
  buckets.total = (cveMatches || []).length;
46
51
  return buckets;
@@ -61,6 +66,26 @@ function badge(sev) {
61
66
  return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${c.bg};color:${c.fg};font-weight:600;font-size:11px;letter-spacing:.5px">${esc(s)}</span>`;
62
67
  }
63
68
 
69
+ const BAND_BADGE = {
70
+ exploited: { bg: "#7c0008", fg: "#fff", label: "EXPLOITED" },
71
+ critical: { bg: "#b91c1c", fg: "#fff", label: "CRITICAL" },
72
+ high: { bg: "#ea580c", fg: "#fff", label: "HIGH" },
73
+ medium: { bg: "#ca8a04", fg: "#fff", label: "MEDIUM" },
74
+ low: { bg: "#2563eb", fg: "#fff", label: "LOW" },
75
+ };
76
+
77
+ // Priority cell: composite band badge (KEV-aware) + EPSS percentile + KEV chip.
78
+ function priorityCell(m) {
79
+ const p = m.cve?.priority || computePriority(m.cve || {});
80
+ const b = BAND_BADGE[p.band] || BAND_BADGE.low;
81
+ const bandBadge = `<span class="prio-band" style="display:inline-block;padding:2px 7px;border-radius:3px;background:${b.bg};color:${b.fg};font-weight:700;font-size:10px;letter-spacing:.4px">${esc(b.label)}</span>`;
82
+ const kev = p.kev ? `<div class="kev-chip" title="CISA Known Exploited Vulnerability${m.cve?.kevDueDate ? ` — remediate by ${esc(m.cve.kevDueDate)}` : ""}">🛑 KEV${m.cve?.kevRansomware ? " · ransomware" : ""}</div>` : "";
83
+ const epss = m.cve?.epssPercentile != null
84
+ ? `<div class="epss-chip" title="EPSS score ${(m.cve.epssScore != null ? (m.cve.epssScore * 100).toFixed(1) : "?")}%">EPSS ${Math.round(m.cve.epssPercentile * 100)}%</div>`
85
+ : "";
86
+ return `${bandBadge}${kev}${epss}`;
87
+ }
88
+
64
89
  const CSS = `
65
90
  * { box-sizing: border-box; }
66
91
  body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2937; margin: 24px; background: #f9fafb; }
@@ -95,6 +120,19 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
95
120
  .exec-summary .exec-ver { color: #6b7280; }
96
121
  .exec-summary .exec-fix { color: #065f46; font-weight: 600; }
97
122
  .fp-intro { background: #f3f4f6; border-left: 3px solid #6b7280; padding: 10px 14px; margin: 8px 0 16px; font-size: 12px; color: #4b5563; line-height: 1.5; }
123
+ td.prio { white-space: normal; }
124
+ td.prio .prio-band { white-space: nowrap; }
125
+ td.prio .kev-chip { display: block; margin-top: 3px; font-size: 10px; font-weight: 700; color: #7c0008; white-space: nowrap; }
126
+ td.prio .epss-chip { display: block; margin-top: 2px; font-size: 10px; color: #6b7280; font-family: ui-monospace, monospace; white-space: nowrap; }
127
+ .summary .card.sev-kev { border-left: 4px solid #7c0008; background: #fff1f2; }
128
+ .summary .card.sev-kev .v { color: #7c0008; }
129
+ .lic-group { margin: 14px 0; }
130
+ .lic-group .lic-h { font-size: 14px; margin: 8px 0 4px; padding: 4px 8px; border-radius: 4px; }
131
+ .lic-group .lic-n { font-size: 11px; color: #6b7280; font-weight: 500; }
132
+ .lic-group.lic-strong .lic-h { background: #fee2e2; color: #7c0008; }
133
+ .lic-group.lic-weak .lic-h { background: #fef3c7; color: #92400e; }
134
+ .lic-group.lic-unknown .lic-h { background: #e5e7eb; color: #374151; }
135
+ .lic-group.lic-ok .lic-h { background: #dcfce7; color: #065f46; }
98
136
  td.cwe { font-family: ui-monospace, monospace; font-size: 11px; color: #4b5563; }
99
137
  td.cwe .cwe-link { color: #6366f1; text-decoration: none; }
100
138
  td.cwe .cwe-link:hover { text-decoration: underline; }
@@ -479,6 +517,9 @@ function renderDetailPanel(m) {
479
517
  const kv = [];
480
518
  if (cve.cvssVector) kv.push(`<div><label>${esc(cve.cvssVersion || "CVSS")}</label><span class="cvss-vector">${esc(cve.cvssVector)}</span></div>`);
481
519
  if (cve.score != null) kv.push(`<div><label>Base score</label><span>${esc(cve.score.toFixed ? cve.score.toFixed(1) : cve.score)} (${esc(cve.severity || "?")})</span></div>`);
520
+ if (cve.priority) kv.push(`<div><label>Priority</label><span>${esc((cve.priority.band || "").toUpperCase())} · ${esc(cve.priority.score)}/100</span></div>`);
521
+ if (cve.epssPercentile != null) kv.push(`<div><label>EPSS</label><span>${(cve.epssScore != null ? (cve.epssScore * 100).toFixed(1) + "% prob · " : "")}${Math.round(cve.epssPercentile * 100)}th pct</span></div>`);
522
+ if (cve.kev) kv.push(`<div><label>CISA KEV</label><span>Known exploited${cve.kevDateAdded ? ` · added ${esc(cve.kevDateAdded)}` : ""}${cve.kevDueDate ? ` · due ${esc(cve.kevDueDate)}` : ""}${cve.kevRansomware ? " · ransomware" : ""}</span></div>`);
482
523
  if (cve.fixVersion) kv.push(`<div><label>Fix version</label><span>${esc(cve.fixVersion)}</span></div>`);
483
524
  if (cve.published) kv.push(`<div><label>Published</label><span>${esc(cve.published.slice(0, 10))}</span></div>`);
484
525
  if (cve.modified) kv.push(`<div><label>Modified</label><span>${esc(cve.modified.slice(0, 10))}</span></div>`);
@@ -585,7 +626,7 @@ function renderCveRow(m, opts = {}) {
585
626
  const source = sourceParts.map(s => `<span class="source ${esc(s)}">${esc(s)}</span>`).join("");
586
627
  const conf = m.confidence ? `<div class="confidence">${esc(m.confidence)}</div>` : "";
587
628
  const actionCell = withAction ? `<td class="action">${renderActionCell(m)}</td>` : "";
588
- const baseCols = 8; // Sev, CVE, Dep, CWE, Published, Description, Fix, Source
629
+ const baseCols = 9; // Priority, Sev, CVE, Dep, CWE, Published, Description, Fix, Source
589
630
  const colspan = withAction ? baseCols + 1 : baseCols;
590
631
  const pub = formatPubDate(m.cve.published);
591
632
  const cwes = formatCwes(m.cve.cwes);
@@ -593,6 +634,7 @@ function renderCveRow(m, opts = {}) {
593
634
  // tr.detail-row. JS toggle is at the bottom of the document; links inside
594
635
  // cells stay clickable (the handler ignores anchor/button clicks).
595
636
  return `<tr class="cve-row${m.cpeFiltered ? " cpe-fp" : ""}">
637
+ <td class="prio">${priorityCell(m)}</td>
596
638
  <td>${badge(m.cve.severity)}${m.cve.score != null ? `<div class="confidence">${esc(m.cve.score.toFixed(1))}</div>` : ""}</td>
597
639
  <td class="cve"><a href="${link}" target="_blank">${esc(m.cve.id)}</a>${conf}${m.cpeFiltered ? `<div class="cpe-tag">CPE-filtered</div>` : ""}</td>
598
640
  ${depCell(m.dep)}
@@ -629,10 +671,11 @@ function renderActionCell(m) {
629
671
  function renderCveTable(cveMatches, opts = {}) {
630
672
  if (!cveMatches?.length) return `<div class="empty">No CVEs matched.</div>`;
631
673
  const { withAction } = opts;
632
- const rows = cveMatches.map(m => renderCveRow(m, opts)).join("\n");
674
+ // Highest composite priority (KEV EPSS-weighted CVSS) first.
675
+ const rows = sortByPriority(cveMatches).map(m => renderCveRow(m, opts)).join("\n");
633
676
  const actionHeader = withAction ? `<th>Recommended action</th>` : "";
634
677
  return `<table>
635
- <thead><tr><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
678
+ <thead><tr><th>Priority</th><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
636
679
  <tbody>${rows}</tbody>
637
680
  </table>`;
638
681
  }
@@ -966,6 +1009,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
966
1009
  { v: stats.high, l: "High", cls: "sev-high" },
967
1010
  { v: stats.medium, l: "Medium", cls: "sev-medium" },
968
1011
  { v: stats.low, l: "Low", cls: "sev-low" },
1012
+ ...(stats.kev ? [{ v: stats.kev, l: "KEV exploited", cls: "sev-kev" }] : []),
969
1013
  { v: stats.total, l: "Total CVEs" },
970
1014
  { v: stats.direct, l: "in Direct" },
971
1015
  { v: stats.transitive, l: "in Transitive" },
@@ -973,6 +1017,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
973
1017
  { v: eol?.length || 0, l: "EOL" },
974
1018
  { v: obs?.length || 0, l: "Obsolete" },
975
1019
  { v: out?.length || 0, l: "Outdated" },
1020
+ ...(extra.licenseFlagged ? [{ v: extra.licenseFlagged, l: "Licenses to review", cls: "sev-medium" }] : []),
976
1021
  { v: extra.warnings?.length || 0, l: "Scan alerts", cls: "sev-warn" },
977
1022
  ];
978
1023
  return `<div class="summary">${cards.map(c => `<div class="card${c.cls ? " " + c.cls : ""}"><div class="v">${c.v}</div><div class="l">${esc(c.l)}</div></div>`).join("")}</div>`;
@@ -1278,11 +1323,49 @@ function renderWarningItems(items, itemLimit) {
1278
1323
  return `<ul class="warn-items">${lis}${overflow}</ul>`;
1279
1324
  }
1280
1325
 
1281
- function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1326
+ const LICENSE_CAT_META = {
1327
+ "network-copyleft": { label: "Network copyleft (AGPL/SSPL)", cls: "lic-strong" },
1328
+ "strong-copyleft": { label: "Strong copyleft (GPL)", cls: "lic-strong" },
1329
+ "proprietary": { label: "Proprietary / commercial", cls: "lic-strong" },
1330
+ "weak-copyleft": { label: "Weak copyleft (LGPL/MPL/EPL)", cls: "lic-weak" },
1331
+ "unknown": { label: "Unknown / unrecognized", cls: "lic-unknown" },
1332
+ "permissive": { label: "Permissive (MIT/Apache/BSD)", cls: "lic-ok" },
1333
+ };
1334
+ const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary", "weak-copyleft", "unknown", "permissive"];
1335
+
1336
+ function renderLicenseChapter(licenseResults) {
1337
+ if (!licenseResults || !licenseResults.assessed?.length) {
1338
+ return `<div class="empty">No license data resolved (offline, or registries returned none).</div>`;
1339
+ }
1340
+ const { byCategory } = licenseResults;
1341
+ const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
1342
+ const meta = LICENSE_CAT_META[cat] || { label: cat, cls: "lic-unknown" };
1343
+ const rows = byCategory[cat].slice().sort((a, b) => depDisplayName(a.dep).localeCompare(depDisplayName(b.dep))).map(e => {
1344
+ const lics = e.ids.concat(e.raw);
1345
+ return `<tr>
1346
+ <td>${depCell(e.dep)}</td>
1347
+ <td>${esc(lics.join(", ") || "—")}</td>
1348
+ <td>${esc(e.source || "—")}</td>
1349
+ </tr>`;
1350
+ }).join("");
1351
+ return `<div class="lic-group ${meta.cls}">
1352
+ <h3 class="lic-h">${esc(meta.label)} <span class="lic-n">${byCategory[cat].length}</span></h3>
1353
+ <table><thead><tr><th>Dependency</th><th>License(s)</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table>
1354
+ </div>`;
1355
+ }).join("");
1356
+ const flaggedN = licenseResults.flagged?.length || 0;
1357
+ const intro = flaggedN
1358
+ ? `<div class="fp-intro">${flaggedN} dependency(ies) carry a copyleft, proprietary, or unrecognized license that may impose redistribution obligations — review against your distribution model. Permissive licenses are listed for completeness.</div>`
1359
+ : `<div class="fp-intro">All resolved licenses are permissive. Listed for completeness.</div>`;
1360
+ return intro + blocks;
1361
+ }
1362
+
1363
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1282
1364
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1283
1365
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1284
- cveMatches = mergeMatches(cveMatches || []);
1285
- devCveMatches = mergeMatches(devCveMatches || []);
1366
+ cveMatches = mergeMatches(cveMatches || []);
1367
+ devCveMatches = mergeMatches(devCveMatches || []);
1368
+ embeddedMatches = mergeMatches(embeddedMatches || []);
1286
1369
  retireMatches = retireMatches || [];
1287
1370
  attachRootUpdates(cveMatches, outdatedResults, resolvedDeps);
1288
1371
  attachRootUpdates(devCveMatches, outdatedResults, resolvedDeps);
@@ -1294,9 +1377,12 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1294
1377
  const prodMatchesFP = cveMatches.filter(m => m.cpeFiltered);
1295
1378
  const devMatchesActive = devCveMatches.filter(m => !m.cpeFiltered);
1296
1379
  const devMatchesFP = devCveMatches.filter(m => m.cpeFiltered);
1380
+ const embMatchesActive = embeddedMatches.filter(m => !m.cpeFiltered);
1381
+ const embMatchesFP = embeddedMatches.filter(m => m.cpeFiltered);
1297
1382
 
1298
1383
  const prodStats = computeStats(prodMatchesActive);
1299
1384
  const devStats = computeStats(devMatchesActive);
1385
+ const embStats = computeStats(embMatchesActive);
1300
1386
  const retireStats = computeStats(retireMatches);
1301
1387
 
1302
1388
  // Recos are built from prod matches only (dev/test issues aren't a release blocker).
@@ -1305,7 +1391,11 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1305
1391
  const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
1306
1392
  const vendoredContent = renderRetireTable(retireMatches);
1307
1393
  const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
1308
- const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP]);
1394
+ const licenseContent = renderLicenseChapter(licenseResults);
1395
+ const licenseTotal = licenseResults?.assessed?.length || 0;
1396
+ const licenseFlagged = licenseResults?.flagged?.length || 0;
1397
+ const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
1398
+ const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
1309
1399
 
1310
1400
  const toolbar = `<div class="toolbar">
1311
1401
  <button class="btn" type="button" data-action="expand-all">⊕ Expand all</button>
@@ -1334,7 +1424,10 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1334
1424
  eolTotal: eolResults?.length || 0,
1335
1425
  obsoleteTotal: obsoleteResults?.length || 0,
1336
1426
  outdatedTotal: outdatedResults?.length || 0,
1337
- fpTotal: prodMatchesFP.length + devMatchesFP.length,
1427
+ licenseTotal,
1428
+ licenseFlagged,
1429
+ embeddedTotal: embStats.total,
1430
+ fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1338
1431
  });
1339
1432
 
1340
1433
  const body = `
@@ -1347,19 +1440,21 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1347
1440
  </div>
1348
1441
  </header>
1349
1442
  ${exec}
1350
- ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length })}
1443
+ ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length, licenseFlagged })}
1351
1444
  ${toc}
1352
1445
  ${toolbar}
1353
1446
 
1354
1447
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1355
1448
  ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1449
+ ${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
1356
1450
  ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1357
1451
  ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1358
1452
  ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
1359
1453
  ${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
1360
1454
  ${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
1361
- ${majorSection(`7. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch7" })}
1362
- ${(prodMatchesFP.length + devMatchesFP.length) ? majorSection(`8. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length})`, fpContent, { open: false, id: "ch8" }) : ""}
1455
+ ${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
1456
+ ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1457
+ ${(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" }) : ""}
1363
1458
  `;
1364
1459
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1365
1460
  }
@@ -1396,20 +1491,41 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1396
1491
  return out;
1397
1492
  }
1398
1493
 
1399
- function renderToc({ hasWarnings, prodTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, fpTotal }) {
1494
+ function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
1400
1495
  const entries = [];
1401
1496
  if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1402
1497
  entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1498
+ if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1403
1499
  entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
1404
1500
  entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1405
1501
  entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1406
1502
  entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
1407
1503
  entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
1408
- entries.push({ id: "ch7", label: `7. Fix Recos` });
1409
- if (fpTotal) entries.push({ id: "ch8", label: `8. Likely FP (${fpTotal})` });
1504
+ entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
1505
+ entries.push({ id: "ch8", label: `8. Fix Recos` });
1506
+ if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1410
1507
  return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1411
1508
  }
1412
1509
 
1510
+ // Embedded-binary CVE chapter: group findings by their top-level archive (the
1511
+ // part of the manifestPath before the first "!/"), so a fat-jar lists all the
1512
+ // vulnerable libraries shaded inside it.
1513
+ function renderEmbeddedChapter(matches, srcRoot) {
1514
+ if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
1515
+ const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars) — not declared in any <code>pom.xml</code>. Patch by rebuilding/replacing the containing archive.</div>`;
1516
+ const byArchive = new Map();
1517
+ for (const m of matches) {
1518
+ const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
1519
+ const top = String(mp).split("!/")[0];
1520
+ if (!byArchive.has(top)) byArchive.set(top, []);
1521
+ byArchive.get(top).push(m);
1522
+ }
1523
+ const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
1524
+ minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
1525
+ ).join("\n");
1526
+ return intro + blocks;
1527
+ }
1528
+
1413
1529
  function renderFalsePositives(matches) {
1414
1530
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1415
1531
  const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
@@ -1721,10 +1837,10 @@ const WORD_SECTION_PR = `<!--[if gte mso 9]>
1721
1837
  // so Word can lay out the columns deterministically instead of inflating wide
1722
1838
  // columns based on natural content width.
1723
1839
  const TABLE_COLGROUPS = {
1724
- // 8-col CVE table (header starts with <th>Severity</th><th>CVE ID</th>…)
1725
- cve: { match: /<thead><tr><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
1726
- widths8: [7, 10, 15, 10, 7, 26, 11, 14],
1727
- widths9: [6, 9, 13, 9, 6, 22, 10, 13, 12] },
1840
+ // 9-col CVE table (header starts with <th>Priority</th><th>Severity</th>…)
1841
+ cve: { match: /<thead><tr><th>Priority<\/th><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
1842
+ widths9: [9, 7, 9, 14, 9, 6, 22, 10, 14],
1843
+ widths10: [8, 6, 8, 12, 8, 6, 18, 9, 13, 12] },
1728
1844
  // 5-col EOL table
1729
1845
  eol: { match: /<thead><tr><th>Product<\/th><th>Dependency<\/th><th>EOL date<\/th><th>Latest<\/th><th>Notes<\/th><\/tr><\/thead>/,
1730
1846
  widths: [15, 25, 12, 12, 36] },
@@ -1748,7 +1864,7 @@ function colgroupHtml(widths) {
1748
1864
  function injectColgroups(html) {
1749
1865
  // CVE 8-col / 9-col (action variant)
1750
1866
  html = html.replace(new RegExp(`(<table[^>]*>)\\s*(${TABLE_COLGROUPS.cve.match.source})`, "g"), (m, tableTag, thead) => {
1751
- const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths9 : TABLE_COLGROUPS.cve.widths8;
1867
+ const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths10 : TABLE_COLGROUPS.cve.widths9;
1752
1868
  return `${tableTag}${colgroupHtml(widths)}${thead}`;
1753
1869
  });
1754
1870
  for (const key of ["eol", "obsolete", "outdated", "retire"]) {
@@ -1790,14 +1906,27 @@ ${WORD_SECTION_PR}
1790
1906
  </html>`;
1791
1907
  }
1792
1908
 
1793
- async function writeReports({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, outputDir, warnings }) {
1794
- await fs.promises.mkdir(outputDir, { recursive: true });
1795
- const payload = { cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings };
1796
- const htmlPath = path.join(outputDir, "cve-report.html");
1797
- const docPath = path.join(outputDir, "cve-report.doc");
1798
- await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
1799
- await fs.promises.writeFile(docPath, generateWordReport(payload));
1800
- return { htmlPath, docPath };
1909
+ // Render the HTML and/or Word report. Callers pass explicit target paths;
1910
+ // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
1911
+ // Back-compat: `outputDir` still writes both under the default file names.
1912
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
1913
+ if (outputDir) {
1914
+ if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
1915
+ if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
1916
+ }
1917
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
1918
+ const written = { htmlPath: null, docPath: null };
1919
+ if (htmlPath) {
1920
+ await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
1921
+ await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
1922
+ written.htmlPath = htmlPath;
1923
+ }
1924
+ if (docPath) {
1925
+ await fs.promises.mkdir(path.dirname(path.resolve(docPath)), { recursive: true });
1926
+ await fs.promises.writeFile(docPath, generateWordReport(payload));
1927
+ written.docPath = docPath;
1928
+ }
1929
+ return written;
1801
1930
  }
1802
1931
 
1803
1932
  module.exports = {
package/lib/dep-record.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
6
6
  * sont conservés comme alias rétro-compat le temps de migrer tous les
7
7
  * consommateurs vers `namespace`/`name`/`manifestPaths`.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
 
10
13
  // NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
@@ -32,9 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
32
35
  }
33
36
 
34
37
  function makeDepRecord(input) {
35
- const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
38
+ const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
36
39
  const concrete = version && !/\$\{/.test(version) ? version : null;
37
40
  const manifestPaths = manifestPath ? [manifestPath] : [];
41
+ // Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear, not declared in a
42
+ // manifest) must not share the Map key of a declared dep with the same g:a — else
43
+ // they'd merge and the "embedded binaries" report chapter would lose them. Key
44
+ // them by their unique physical location instead. provenance stays on the record
45
+ // so the report/exports can label & group them; "both" is set later if a merge
46
+ // across provenances ever happens.
47
+ const coordKey = provenance === "embedded" && manifestPath
48
+ ? `embedded:${manifestPath}`
49
+ : coordKeyFor(ecosystem, namespace, name);
38
50
  return {
39
51
  ecosystem,
40
52
  ecosystemType: ecosystemType || ecosystem,
@@ -42,7 +54,8 @@ function makeDepRecord(input) {
42
54
  name,
43
55
  version: version || null,
44
56
  versions: concrete ? [concrete] : [],
45
- coordKey: coordKeyFor(ecosystem, namespace, name),
57
+ coordKey,
58
+ provenance,
46
59
  scope,
47
60
  isDev: !!isDev,
48
61
  manifestPaths,