fad-checker 1.0.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 (67) hide show
  1. package/CLAUDE.md +126 -0
  2. package/README.md +279 -0
  3. package/completions/fad-check.bash +23 -0
  4. package/completions/fad-check.zsh +27 -0
  5. package/data/cpe-coord-map.json +112 -0
  6. package/data/eol-mapping.json +34 -0
  7. package/data/known-obsolete.json +142 -0
  8. package/data/known-public-namespaces.json +79 -0
  9. package/docs/ARCHITECTURE.md +152 -0
  10. package/docs/USAGE.md +179 -0
  11. package/fad-check.js +665 -0
  12. package/lib/cache-archive.js +107 -0
  13. package/lib/config.js +87 -0
  14. package/lib/core.js +317 -0
  15. package/lib/cpe.js +287 -0
  16. package/lib/cve-download.js +369 -0
  17. package/lib/cve-match.js +228 -0
  18. package/lib/cve-report.js +1455 -0
  19. package/lib/maven-repo.js +134 -0
  20. package/lib/maven-version.js +153 -0
  21. package/lib/npm/collect.js +224 -0
  22. package/lib/npm/parse.js +291 -0
  23. package/lib/nvd.js +239 -0
  24. package/lib/osv.js +298 -0
  25. package/lib/outdated.js +265 -0
  26. package/lib/retire.js +211 -0
  27. package/lib/scan-completeness.js +67 -0
  28. package/lib/snyk.js +127 -0
  29. package/lib/transitive.js +410 -0
  30. package/package.json +35 -0
  31. package/test/core.test.js +153 -0
  32. package/test/cpe.test.js +148 -0
  33. package/test/cve-download.test.js +39 -0
  34. package/test/cve-match.test.js +108 -0
  35. package/test/cve-report.test.js +79 -0
  36. package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
  37. package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
  38. package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
  39. package/test/fixtures/complex-enterprise/pom.xml +66 -0
  40. package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
  41. package/test/fixtures/cve-samples/cve-non-java.json +19 -0
  42. package/test/fixtures/cve-samples/cve-product-only.json +31 -0
  43. package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
  44. package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
  45. package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
  46. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
  47. package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
  48. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
  50. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
  51. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
  52. package/test/fixtures/monorepo-mixed/pom.xml +29 -0
  53. package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
  54. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
  55. package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
  56. package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
  57. package/test/fixtures/private-lib-detection/pom.xml +35 -0
  58. package/test/fixtures/simple/app/pom.xml +28 -0
  59. package/test/fixtures/simple/lib/pom.xml +18 -0
  60. package/test/fixtures/simple/pom.xml +24 -0
  61. package/test/maven-repo.test.js +111 -0
  62. package/test/maven-version.test.js +48 -0
  63. package/test/monorepo.test.js +132 -0
  64. package/test/npm.test.js +146 -0
  65. package/test/outdated.test.js +56 -0
  66. package/test/snyk.test.js +64 -0
  67. package/test/transitive.test.js +305 -0
@@ -0,0 +1,1455 @@
1
+ /**
2
+ * lib/cve-report.js — generate self-contained HTML and Word-compatible (.doc)
3
+ * reports from matched CVE/EOL/obsolete/outdated findings.
4
+ *
5
+ * Word opens HTML saved as .doc natively — we use the same HTML body
6
+ * wrapped in Word XML namespace meta tags.
7
+ */
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+
11
+ function esc(s) {
12
+ if (s == null) return "";
13
+ return String(s)
14
+ .replace(/&/g, "&")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;");
18
+ }
19
+
20
+ function computeStats(cveMatches) {
21
+ const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0 };
22
+ for (const m of cveMatches || []) {
23
+ const sev = (m.cve.severity || "UNKNOWN").toLowerCase();
24
+ if (buckets[sev] != null) buckets[sev]++;
25
+ else buckets.unknown++;
26
+ if (m.dep?.scope === "transitive") buckets.transitive++;
27
+ else buckets.direct++;
28
+ }
29
+ buckets.total = (cveMatches || []).length;
30
+ return buckets;
31
+ }
32
+
33
+ const SEVERITY_BADGE = {
34
+ CRITICAL: { bg: "#7c0008", fg: "#fff" },
35
+ HIGH: { bg: "#c92a2a", fg: "#fff" },
36
+ MEDIUM: { bg: "#f08c00", fg: "#fff" },
37
+ LOW: { bg: "#3b82f6", fg: "#fff" },
38
+ NONE: { bg: "#aaa", fg: "#fff" },
39
+ UNKNOWN: { bg: "#666", fg: "#fff" },
40
+ };
41
+
42
+ function badge(sev) {
43
+ const s = (sev || "UNKNOWN").toUpperCase();
44
+ const c = SEVERITY_BADGE[s] || SEVERITY_BADGE.UNKNOWN;
45
+ 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>`;
46
+ }
47
+
48
+ const CSS = `
49
+ * { box-sizing: border-box; }
50
+ body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2937; margin: 24px; background: #f9fafb; }
51
+ .report-header { border-bottom: 3px solid #6366f1; padding-bottom: 16px; margin-bottom: 20px; }
52
+ h1 { margin: 0 0 4px 0; font-size: 28px; letter-spacing: -.5px; }
53
+ .report-subtitle { color: #6b7280; font-size: 14px; font-weight: 500; margin-bottom: 12px; letter-spacing: .3px; }
54
+ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
55
+ .meta { color: #6b7280; font-size: 13px; margin-bottom: 16px; }
56
+ .summary { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
57
+ .summary .card { padding: 10px 16px; border-radius: 6px; background: #fff; border: 1px solid #e5e7eb; min-width: 120px; }
58
+ .summary .card .v { font-size: 24px; font-weight: 700; }
59
+ .summary .card .l { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: .5px; }
60
+ .summary .card.sev-critical { border-left: 4px solid #dc2626; }
61
+ .summary .card.sev-critical .v { color: #dc2626; }
62
+ .summary .card.sev-high { border-left: 4px solid #f97316; }
63
+ .summary .card.sev-high .v { color: #f97316; }
64
+ .summary .card.sev-medium { border-left: 4px solid #f59e0b; }
65
+ .summary .card.sev-medium .v { color: #b45309; }
66
+ .summary .card.sev-low { border-left: 4px solid #2563eb; }
67
+ .summary .card.sev-low .v { color: #2563eb; }
68
+ .summary .card.sev-vendored { border-left: 4px solid #6366f1; }
69
+ .summary .card.sev-warn { border-left: 4px solid #f59e0b; background: #fffbeb; }
70
+ .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); }
71
+ .toc a { color: #4338ca; text-decoration: none; padding: 2px 6px; border-radius: 3px; }
72
+ .toc a:hover { background: #eef2ff; }
73
+ .exec-summary .exec-top { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
74
+ .exec-summary .exec-top-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
75
+ .exec-summary .exec-top-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
76
+ .exec-summary .exec-top-list li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: ui-monospace, monospace; }
77
+ .exec-summary .exec-cve-link { color: #1d4ed8; text-decoration: none; font-weight: 600; }
78
+ .exec-summary .exec-cve-link:hover { text-decoration: underline; }
79
+ .exec-summary .exec-ver { color: #6b7280; }
80
+ .exec-summary .exec-fix { color: #065f46; font-weight: 600; }
81
+ .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; }
82
+ td.cwe { font-family: ui-monospace, monospace; font-size: 11px; color: #4b5563; }
83
+ td.cwe .cwe-link { color: #6366f1; text-decoration: none; }
84
+ td.cwe .cwe-link:hover { text-decoration: underline; }
85
+ td.cwe .cwe-empty { color: #d1d5db; }
86
+ td.cwe .cwe-more { color: #9ca3af; font-style: italic; font-size: 10px; }
87
+ td.published { font-family: ui-monospace, monospace; font-size: 11px; color: #6b7280; white-space: nowrap; }
88
+ tr.cve-row.cpe-fp td { opacity: .55; }
89
+ .jump-badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
90
+ .jump-badge.jump-major { background: #fee2e2; color: #991b1b; }
91
+ .jump-badge.jump-minor { background: #fef3c7; color: #92400e; }
92
+ .jump-badge.jump-patch { background: #e0f2fe; color: #075985; }
93
+ .jump-badge.jump-pre, .jump-badge.jump-unknown { background: #f3f4f6; color: #6b7280; }
94
+ tr.jump-patch td, tr.jump-pre td, tr.jump-unknown td { opacity: .75; }
95
+ tr.cve-row .cpe-tag { display: inline-block; margin-top: 2px; font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; background: #f3f4f6; color: #6b7280; padding: 1px 5px; border-radius: 3px; }
96
+ table { width: 100%; border-collapse: collapse; background: #fff; margin: 8px 0 24px; font-size: 13px; }
97
+ th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
98
+ th { background: #f3f4f6; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #4b5563; }
99
+ tr:nth-child(even) { background: #fafafa; }
100
+ td.dep { font-family: ui-monospace, monospace; font-size: 12px; }
101
+ .defined-in { margin-top: 4px; font-family: ui-monospace, monospace; font-size: 10px; color: #6b7280; line-height: 1.4; }
102
+ .defined-in .defined-label { color: #9ca3af; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; text-transform: uppercase; letter-spacing: .4px; font-size: 9px; margin-right: 4px; }
103
+ .defined-in code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; color: #374151; word-break: break-all; }
104
+ code.path { font-family: ui-monospace, monospace; font-size: 0.9em; background: #f3f4f6; padding: 1px 6px; border-radius: 3px; color: #1f2937; font-weight: 400; }
105
+ .exec-summary { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px 20px; margin: 16px 0; background: #fff; display: flex; flex-direction: column; gap: 8px; }
106
+ .exec-summary .exec-head { display: flex; align-items: center; gap: 12px; }
107
+ .exec-summary .exec-level { font-size: 12px; font-weight: 700; letter-spacing: 1px; padding: 4px 12px; border-radius: 999px; }
108
+ .exec-summary .exec-title { font-size: 18px; font-weight: 600; color: #111827; }
109
+ .exec-summary .exec-meta { font-size: 12px; color: #6b7280; }
110
+ .exec-summary .exec-bullets { margin: 4px 0 0; padding-left: 22px; font-size: 13px; color: #374151; line-height: 1.6; }
111
+ .exec-summary.exec-critical { border-color: #dc2626; }
112
+ .exec-summary.exec-critical .exec-level { background: #dc2626; color: #fff; }
113
+ .exec-summary.exec-high { border-color: #f97316; }
114
+ .exec-summary.exec-high .exec-level { background: #f97316; color: #fff; }
115
+ .exec-summary.exec-medium { border-color: #f59e0b; }
116
+ .exec-summary.exec-medium .exec-level { background: #f59e0b; color: #fff; }
117
+ .exec-summary.exec-low { border-color: #2563eb; }
118
+ .exec-summary.exec-low .exec-level { background: #2563eb; color: #fff; }
119
+ .exec-summary.exec-info { border-color: #10b981; }
120
+ .exec-summary.exec-info .exec-level { background: #10b981; color: #fff; }
121
+ .exec-summary.exec-unknown { border-color: #6b7280; }
122
+ .exec-summary.exec-unknown .exec-level { background: #6b7280; color: #fff; }
123
+ .defined-in .defined-more { color: #9ca3af; font-style: italic; }
124
+ td.cve a { color: #1d4ed8; text-decoration: none; font-family: ui-monospace, monospace; }
125
+ td.cve a:hover { text-decoration: underline; }
126
+ td.desc { color: #4b5563; max-width: 480px; }
127
+ .empty { color: #9ca3af; font-style: italic; padding: 12px 0; }
128
+ .warnings { background: #fffbeb; border: 1px solid #fcd34d; padding: 12px 16px; border-radius: 6px; margin: 16px 0; }
129
+ .warnings h3 { margin: 0 0 10px; font-size: 14px; color: #92400e; }
130
+ .warnings .warn-block { background: #fff; border-left: 3px solid #f59e0b; padding: 8px 12px; margin: 8px 0; border-radius: 0 4px 4px 0; }
131
+ .warnings .warn-block.warn-unresolved-versions { border-left-color: #ef4444; background: #fef2f2; }
132
+ .warnings .warn-block.warn-private-libs { border-left-color: #6366f1; background: #eef2ff; }
133
+ .warnings .warn-head { font-size: 13px; color: #92400e; margin-bottom: 4px; }
134
+ .warnings .warn-unresolved-versions .warn-head { color: #991b1b; }
135
+ .warnings .warn-private-libs .warn-head { color: #312e81; }
136
+ .warnings .warn-private-libs code { background: #e0e7ff; }
137
+ .warnings .warn-defined { font-size: 10px; color: #6b7280; margin-top: 2px; padding-left: 14px; }
138
+ .warnings .warn-defined code.path { background: #f3f4f6; color: #374151; font-size: 10px; padding: 1px 4px; }
139
+ .warnings .warn-defined-more { color: #9ca3af; font-style: italic; }
140
+ .warnings .count { color: #6b7280; font-weight: 400; }
141
+ .warnings .warn-path { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
142
+ .warnings .warn-msg { font-size: 12px; color: #374151; line-height: 1.45; }
143
+ .warnings .warn-items { margin: 6px 0 0; padding-left: 18px; font-size: 11px; max-height: 180px; overflow: auto; }
144
+ .warnings .warn-items li { margin-bottom: 2px; }
145
+ .warnings .warn-items .more { color: #6b7280; font-style: italic; }
146
+ .warnings code { background: #fef3c7; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
147
+ .warnings .warn-unresolved-versions code { background: #fecaca; }
148
+ .confidence { font-size: 10px; color: #9ca3af; text-transform: uppercase; }
149
+ .source { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; background: #e5e7eb; color: #374151; margin: 1px 2px 1px 0; }
150
+ .source.both { background: #c7d2fe; color: #312e81; }
151
+ .source.snyk { background: #fce7f3; color: #831843; }
152
+ .source.osv { background: #dcfce7; color: #166534; }
153
+ .source.fad { background: #fef3c7; color: #92400e; }
154
+ .source.nvd { background: #cffafe; color: #155e75; }
155
+ .chip { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-left: 4px; }
156
+ .chip.transitive { background: #fef3c7; color: #92400e; }
157
+ .chip.direct { background: #d1fae5; color: #065f46; }
158
+ .chip.parent { background: #ddd6fe; color: #5b21b6; }
159
+ /* Maven / npm scope qualifiers added next to .chip.direct when relevant */
160
+ .chip.scope-test { background: #fee2e2; color: #991b1b; }
161
+ .chip.scope-provided { background: #fed7aa; color: #9a3412; }
162
+ .chip.scope-runtime { background: #cffafe; color: #155e75; }
163
+ .chip.scope-system { background: #f3f4f6; color: #4b5563; }
164
+ .chip.scope-dev { background: #fee2e2; color: #991b1b; }
165
+ .chip.scope-peer { background: #ede9fe; color: #5b21b6; }
166
+ .chip.scope-optional { background: #f3f4f6; color: #6b7280; }
167
+ .chip.scope-vendored { background: #e0e7ff; color: #3730a3; }
168
+ .via { font-size: 10px; color: #6b7280; margin-top: 2px; font-family: ui-monospace, monospace; max-width: 220px; word-break: break-all; }
169
+ h3 { margin: 24px 0 10px; font-size: 16px; color: #374151; }
170
+ details.report-section { margin: 28px 0 0; }
171
+ details.report-section[open] { margin-bottom: 8px; }
172
+ details.report-section > summary { cursor: pointer; list-style: none; user-select: none; outline: none; padding: 0; }
173
+ details.report-section > summary::-webkit-details-marker { display: none; }
174
+ details.report-section > summary h2 { margin: 0; display: flex; align-items: center; gap: 8px; }
175
+ details.report-section > summary h2::before { content: "▾"; color: #6366f1; font-size: 14px; transition: transform .15s ease; display: inline-block; }
176
+ details.report-section:not([open]) > summary h2::before { transform: rotate(-90deg); }
177
+ details.report-section > summary:hover h2 { color: #4338ca; }
178
+ details.report-subsection { margin: 16px 0 0; }
179
+ details.report-subsection > summary { cursor: pointer; list-style: none; user-select: none; outline: none; padding: 0; }
180
+ details.report-subsection > summary::-webkit-details-marker { display: none; }
181
+ details.report-subsection > summary h3 { margin: 0; display: flex; align-items: center; gap: 8px; }
182
+ details.report-subsection > summary h3::before { content: "▾"; color: #9ca3af; font-size: 12px; transition: transform .15s ease; display: inline-block; }
183
+ details.report-subsection:not([open]) > summary h3::before { transform: rotate(-90deg); }
184
+ details.report-subsection > summary:hover h3 { color: #4b5563; }
185
+ /* Hierarchical indentation: every subsection's content gets a padded,
186
+ bordered container. Nested subsections inherit + add to the indent so
187
+ children visually nest under their parent. */
188
+ details.report-subsection > div { padding-left: 22px; margin-left: 6px; border-left: 2px solid #f3f4f6; padding-top: 4px; }
189
+ details.report-subsection details.report-subsection > summary h3 { font-size: 14px; color: #4b5563; }
190
+ details.report-subsection details.report-subsection details.report-subsection > summary h3 { font-size: 13px; color: #6b7280; font-weight: 500; }
191
+ details.report-subsection details.report-subsection > div { border-left-color: #e5e7eb; }
192
+ details.report-subsection details.report-subsection details.report-subsection > div { border-left-color: #d1d5db; }
193
+ .toolbar { display: flex; gap: 8px; margin: 12px 0 8px; flex-wrap: wrap; align-items: center; }
194
+ .toolbar .btn { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px; font-size: 12px; background: #fff; border: 1px solid #d1d5db; border-radius: 4px; cursor: pointer; color: #374151; font-family: inherit; }
195
+ .toolbar .btn:hover { background: #f3f4f6; border-color: #9ca3af; }
196
+ .toolbar .btn.primary { background: #4f46e5; color: #fff; border-color: #4f46e5; }
197
+ .toolbar .btn.primary:hover { background: #4338ca; }
198
+ .toolbar .toolbar-label { font-size: 11px; color: #6b7280; }
199
+ .fix-recipe { background: #fff7ed; border: 1px solid #fed7aa; border-left: 4px solid #f97316; padding: 10px 14px; border-radius: 4px; margin: 6px 0; font-size: 12px; }
200
+ .fix-recipe code { display: block; background: #1f2937; color: #f3f4f6; padding: 8px 10px; border-radius: 3px; font-size: 11px; margin-top: 6px; white-space: pre; overflow-x: auto; }
201
+ .fix-recipe .recipe-title { font-weight: 600; margin-bottom: 2px; }
202
+ .fix-recipe .recipe-meta { color: #92400e; }
203
+ td.action { font-size: 11px; max-width: 280px; }
204
+ td.action .pin { font-family: ui-monospace, monospace; background: #f3f4f6; padding: 2px 4px; border-radius: 2px; }
205
+ td.action .update-hint { color: #6b7280; font-size: 10px; margin-top: 2px; }
206
+ tr.cve-row { cursor: pointer; }
207
+ tr.cve-row:hover { background: #eef2ff !important; }
208
+ tr.cve-row.open { background: #e0e7ff !important; }
209
+ tr.cve-row td:first-child::before { content: "▸"; display: inline-block; margin-right: 6px; color: #6b7280; transition: transform .15s ease; }
210
+ tr.cve-row.open td:first-child::before { transform: rotate(90deg); color: #6366f1; }
211
+ tr.detail-row { display: none; }
212
+ tr.detail-row.open { display: table-row; }
213
+ tr.detail-row > td { padding: 0; border-bottom: 2px solid #c7d2fe; background: #f9fafb; }
214
+ .detail-panel { background: #f9fafb; border-left: 4px solid #6366f1; padding: 16px 24px; font-size: 12px; line-height: 1.6; width: 100%; }
215
+ .detail-panel .panel-block { margin: 0; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
216
+ .detail-panel .panel-block:last-child { border-bottom: none; }
217
+ .detail-panel .panel-block .panel-title { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: .5px; font-weight: 600; margin-bottom: 6px; }
218
+ .detail-panel .panel-block .panel-title .count { color: #9ca3af; text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 4px; }
219
+ .detail-panel .full-desc { white-space: pre-wrap; color: #1f2937; max-width: 1100px; line-height: 1.55; }
220
+ .detail-panel .cvss-vector { font-family: ui-monospace, monospace; font-size: 11px; background: #1f2937; color: #f3f4f6; padding: 4px 8px; border-radius: 3px; display: inline-block; }
221
+ .detail-panel .ref-group { margin: 6px 0; }
222
+ .detail-panel .ref-group .ref-label { display: inline-block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; padding: 1px 6px; border-radius: 3px; margin-right: 4px; }
223
+ .detail-panel .ref-group .ref-label.patch { background: #d1fae5; color: #065f46; }
224
+ .detail-panel .ref-group .ref-label.exploit { background: #fee2e2; color: #991b1b; }
225
+ .detail-panel .ref-group .ref-label.advisory { background: #dbeafe; color: #1e40af; }
226
+ .detail-panel .ref-group .ref-label.vendor { background: #ede9fe; color: #5b21b6; }
227
+ .detail-panel .ref-group .ref-label.third-party { background: #fef3c7; color: #92400e; }
228
+ .detail-panel .ref-group .ref-label.mailing { background: #f3f4f6; color: #4b5563; }
229
+ .detail-panel .ref-group .ref-label.fix { background: #d1fae5; color: #065f46; }
230
+ .detail-panel .ref-group .ref-label.web { background: #f3f4f6; color: #6b7280; }
231
+ .detail-panel .ref-group .ref-label.other { background: #f3f4f6; color: #6b7280; }
232
+ .detail-panel .ref-list { margin: 4px 0 8px; padding-left: 18px; max-height: 220px; overflow-y: auto; }
233
+ .detail-panel .ref-list li { font-size: 11px; word-break: break-all; line-height: 1.6; }
234
+ .detail-panel .ref-list a { color: #1d4ed8; text-decoration: none; }
235
+ .detail-panel .ref-list a:hover { text-decoration: underline; }
236
+ .detail-panel .kv { display: flex; gap: 16px; flex-wrap: wrap; }
237
+ .detail-panel .kv > div { min-width: 200px; }
238
+ .detail-panel .kv label { display: block; font-size: 10px; color: #6b7280; text-transform: uppercase; letter-spacing: .5px; }
239
+ .detail-panel .kv span { font-family: ui-monospace, monospace; font-size: 12px; }
240
+ .detail-panel .via-list { font-family: ui-monospace, monospace; font-size: 11px; color: #4b5563; max-height: 200px; overflow-y: auto; }
241
+ .detail-panel .via-list div { padding: 2px 0; }
242
+ `;
243
+
244
+ // Module-local render context — set at the start of buildBody so deep helpers
245
+ // (renderEolTable, renderObsoleteTable, etc.) can read srcRoot without passing
246
+ // it through every layer. Safe because buildBody is invoked synchronously.
247
+ let RENDER_CTX = { srcRoot: null };
248
+ function setRenderCtx(ctx) { RENDER_CTX = { ...RENDER_CTX, ...ctx }; }
249
+
250
+ function depCell(dep, srcRoot = RENDER_CTX.srcRoot) {
251
+ let chip = "";
252
+ let via = "";
253
+ if (dep.scope === "transitive") {
254
+ chip = `<span class="chip transitive">transitive d${dep.depth || "?"}</span>`;
255
+ const paths = dep.viaPaths?.length ? dep.viaPaths : (dep.via?.length ? [dep.via] : []);
256
+ if (paths.length) {
257
+ const shown = paths.slice(0, 3).map(p => `<div class="via">via ${esc(p.join(" → "))}</div>`).join("");
258
+ const more = paths.length > 3 ? `<div class="via" style="color:#9ca3af">+${paths.length - 3} other path${paths.length - 3 > 1 ? "s" : ""}</div>` : "";
259
+ via = shown + more;
260
+ }
261
+ } else if (dep.scope === "parent") {
262
+ chip = `<span class="chip parent">parent POM</span>`;
263
+ } else {
264
+ // "direct" means "declared in this manifest" (not transitive, not parent).
265
+ // We additionally surface the *Maven scope* (test / provided / dev / peer
266
+ // / optional) when it's not the default compile — so the reader sees
267
+ // instantly why a dep ended up in the Dev chapter, for instance.
268
+ chip = `<span class="chip direct">direct</span>`;
269
+ const s = (dep.scope || "").toLowerCase();
270
+ const SCOPE_CHIPS = {
271
+ test: `<span class="chip scope-test">test</span>`,
272
+ provided: `<span class="chip scope-provided">provided</span>`,
273
+ runtime: `<span class="chip scope-runtime">runtime</span>`,
274
+ system: `<span class="chip scope-system">system</span>`,
275
+ dev: `<span class="chip scope-dev">dev</span>`,
276
+ peer: `<span class="chip scope-peer">peer</span>`,
277
+ optional: `<span class="chip scope-optional">optional</span>`,
278
+ vendored: `<span class="chip scope-vendored">vendored</span>`,
279
+ };
280
+ if (SCOPE_CHIPS[s]) chip += " " + SCOPE_CHIPS[s];
281
+ }
282
+ const { groupLine, nameLine } = depDisplayParts(dep);
283
+ const defined = renderDefinedIn(dep, srcRoot);
284
+ return `<td class="dep">${esc(groupLine)}<br>${esc(nameLine)}<br><span style="color:#6b7280">${esc(dep.version || "?")}</span> ${chip}${via}${defined}</td>`;
285
+ }
286
+
287
+ /**
288
+ * Render the "defined in" footer for a dep cell — the relative path(s) of the
289
+ * pom.xml / package-lock.json / yarn.lock where this dep is declared.
290
+ *
291
+ * Transitives from the Maven transitive resolver don't have a manifest path
292
+ * (they live on Maven Central, not in the source tree) — we return "" for them.
293
+ */
294
+ function renderDefinedIn(dep, srcRoot) {
295
+ const paths = (dep.manifestPaths && dep.manifestPaths.length) ? dep.manifestPaths
296
+ : (dep.pomPaths && dep.pomPaths.length) ? dep.pomPaths
297
+ : [];
298
+ if (!paths.length) return "";
299
+ const rel = paths.map(p => makeRelative(p, srcRoot));
300
+ const shown = rel.slice(0, 3).map(p => `<code>${esc(p)}</code>`).join(", ");
301
+ const more = rel.length > 3 ? ` <span class="defined-more">+${rel.length - 3} more</span>` : "";
302
+ return `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
303
+ }
304
+
305
+ function makeRelative(absPath, srcRoot) {
306
+ if (!srcRoot || !absPath) return absPath || "";
307
+ const path = require("path");
308
+ try {
309
+ const r = path.relative(srcRoot, absPath);
310
+ // If the path escapes the src root, show the absolute path instead
311
+ if (r.startsWith("..") || path.isAbsolute(r)) return absPath;
312
+ return r || ".";
313
+ } catch { return absPath; }
314
+ }
315
+
316
+ function depDisplayParts(dep) {
317
+ if (dep?.ecosystem === "npm") return { groupLine: "npm:", nameLine: dep.artifactId || "" };
318
+ return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
319
+ }
320
+
321
+ function depDisplayName(dep) {
322
+ if (dep?.ecosystem === "npm") return `npm:${dep.artifactId || ""}`;
323
+ return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
324
+ }
325
+
326
+ const REF_LABELS = {
327
+ patch: "Patch / Commit",
328
+ exploit: "Exploit / PoC",
329
+ advisory: "Security advisory",
330
+ vendor: "Vendor advisory",
331
+ "third-party": "Third-party advisory",
332
+ fix: "Upstream fix",
333
+ mailing: "Mailing list",
334
+ web: "Other",
335
+ other: "Other",
336
+ };
337
+
338
+ // Combine NVD-tagged refs + OSV-typed refs, classify each by URL + tags,
339
+ // dedupe by URL, return { category: [urls] }.
340
+ function groupExternalRefs(nvdRefs, osvRefs) {
341
+ const seen = new Map(); // url -> category
342
+ const out = { patch: [], exploit: [], advisory: [], vendor: [], "third-party": [], fix: [], mailing: [], web: [], other: [] };
343
+ const push = (url, cat) => {
344
+ if (!url) return;
345
+ if (seen.has(url)) return;
346
+ seen.set(url, cat);
347
+ (out[cat] || out.other).push(url);
348
+ };
349
+ for (const r of nvdRefs) {
350
+ const cat = classifyNvdRef(r);
351
+ push(r.url, cat);
352
+ }
353
+ for (const r of osvRefs) {
354
+ const cat = classifyOsvRef(r);
355
+ push(r.url, cat);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function classifyNvdRef(r) {
361
+ const tags = (r.tags || []).map(t => String(t).toLowerCase());
362
+ if (tags.includes("patch")) return "patch";
363
+ if (tags.includes("exploit")) return "exploit";
364
+ if (tags.includes("vendor advisory")) return "vendor";
365
+ if (tags.includes("third party advisory")) return "third-party";
366
+ if (tags.includes("mailing list")) return "mailing";
367
+ if (tags.some(t => t.includes("advisory"))) return "advisory";
368
+ return classifyByUrl(r.url);
369
+ }
370
+
371
+ function classifyOsvRef(r) {
372
+ const t = (r.type || "").toUpperCase();
373
+ if (t === "FIX") return "fix";
374
+ if (t === "ADVISORY") return "advisory";
375
+ if (t === "REPORT") return "exploit";
376
+ if (t === "PACKAGE") return "web";
377
+ return classifyByUrl(r.url);
378
+ }
379
+
380
+ function classifyByUrl(url) {
381
+ if (!url) return "other";
382
+ const u = url.toLowerCase();
383
+ if (/commit|\/pull\/|github\.com\/.+\/(commit|compare|pull|releases)/.test(u)) return "patch";
384
+ if (/exploit|poc|metasploit|packetstorm/.test(u)) return "exploit";
385
+ if (/(security|advisory|advisories|GHSA|cve-\d{4})/i.test(u) && /github\.com\/.+\/(security|advisories)/.test(u)) return "advisory";
386
+ if (/snyk\.io|tenable\.com|github\.com\/advisories/.test(u)) return "advisory";
387
+ if (/lists\.|mailman|mailing/.test(u)) return "mailing";
388
+ if (/oracle\.com|apache\.org\/security|redhat\.com\/security|cisco\.com|debian\.org|ubuntu\.com|suse\.com/.test(u)) return "vendor";
389
+ return "web";
390
+ }
391
+
392
+ // Short blurb for the row (first sentence / 240 chars) — full description is in the expandable panel
393
+ function shortDesc(s) {
394
+ if (!s) return "";
395
+ const t = String(s).trim();
396
+ if (t.length <= 240) return t;
397
+ // Try to cut at the first sentence boundary near the limit
398
+ const cut = t.slice(0, 240);
399
+ const lastDot = cut.lastIndexOf(". ");
400
+ if (lastDot > 80) return cut.slice(0, lastDot + 1);
401
+ return cut + "…";
402
+ }
403
+
404
+ // Render a flat sub-block inside the CVE detail panel. `count` adds a "(n)"
405
+ // hint next to the title. (Was previously a <details>; the user wanted these
406
+ // always visible once the row's panel is open.)
407
+ function section(title, contentHtml, opts = {}) {
408
+ const { count = null } = opts;
409
+ if (!contentHtml) return "";
410
+ const countHtml = count != null ? ` <span class="count">(${esc(count)})</span>` : "";
411
+ return `<div class="panel-block">
412
+ <div class="panel-title">${esc(title)}${countHtml}</div>
413
+ <div>${contentHtml}</div>
414
+ </div>`;
415
+ }
416
+
417
+ function renderDetailPanel(m) {
418
+ const cve = m.cve;
419
+ const dep = m.dep;
420
+ const sections = [];
421
+
422
+ // 1. Description
423
+ if (cve.description) {
424
+ sections.push(section("Description", `<div class="full-desc">${esc(cve.description)}</div>`));
425
+ }
426
+
427
+ // 2. Metadata
428
+ const kv = [];
429
+ if (cve.cvssVector) kv.push(`<div><label>${esc(cve.cvssVersion || "CVSS")}</label><span class="cvss-vector">${esc(cve.cvssVector)}</span></div>`);
430
+ 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>`);
431
+ if (cve.fixVersion) kv.push(`<div><label>Fix version</label><span>${esc(cve.fixVersion)}</span></div>`);
432
+ if (cve.published) kv.push(`<div><label>Published</label><span>${esc(cve.published.slice(0, 10))}</span></div>`);
433
+ if (cve.modified) kv.push(`<div><label>Modified</label><span>${esc(cve.modified.slice(0, 10))}</span></div>`);
434
+ if (cve.ghsa) kv.push(`<div><label>GHSA</label><span><a href="https://github.com/advisories/${esc(cve.ghsa)}" target="_blank">${esc(cve.ghsa)}</a></span></div>`);
435
+ if (m.confidence) kv.push(`<div><label>Match confidence</label><span>${esc(m.confidence)}</span></div>`);
436
+ if (m.source) kv.push(`<div><label>Sources</label><span>${esc(m.source)}</span></div>`);
437
+ if (kv.length) sections.push(section("Metadata", `<div class="kv">${kv.join("")}</div>`));
438
+
439
+ // 3. External links — grouped
440
+ const grouped = groupExternalRefs(cve.nvdRefs || [], cve.osvRefs || []);
441
+ const order = ["patch", "exploit", "advisory", "vendor", "third-party", "fix", "mailing", "web", "other"];
442
+ const orderedKeys = order.filter(k => grouped[k]?.length);
443
+ if (orderedKeys.length) {
444
+ const totalRefs = Object.values(grouped).reduce((s, arr) => s + arr.length, 0);
445
+ const groupBlocks = orderedKeys.map(k => {
446
+ const label = REF_LABELS[k] || k;
447
+ const items = grouped[k].slice(0, 15).map(u => `<li><a href="${esc(u)}" target="_blank">${esc(u)}</a></li>`).join("");
448
+ const more = grouped[k].length > 15 ? `<li style="color:#9ca3af">+${grouped[k].length - 15} more</li>` : "";
449
+ return `<div class="ref-group">
450
+ <span class="ref-label ${esc(k)}">${esc(label)}</span><span style="color:#9ca3af;font-size:11px">${grouped[k].length} link${grouped[k].length > 1 ? "s" : ""}</span>
451
+ <ul class="ref-list">${items}${more}</ul>
452
+ </div>`;
453
+ }).join("");
454
+ sections.push(section("External links", groupBlocks, { count: totalRefs }));
455
+ }
456
+
457
+ // 4. CPE configurations
458
+ if (cve.cpes?.length) {
459
+ const cpes = cve.cpes.slice(0, 20).map(c => `<li>${esc(c)}</li>`).join("");
460
+ sections.push(section("Affected CPE configurations",
461
+ `<ul class="ref-list">${cpes}${cve.cpes.length > 20 ? `<li style="color:#9ca3af">+${cve.cpes.length - 20} more</li>` : ""}</ul>`,
462
+ { count: cve.cpes.length, open: false }));
463
+ }
464
+
465
+ // 5. Aliases
466
+ if (cve.aliases?.length) {
467
+ const aliasItems = cve.aliases.map(a => {
468
+ if (a.startsWith("GHSA-")) return `<li><a href="https://github.com/advisories/${esc(a)}" target="_blank">${esc(a)}</a> (GitHub)</li>`;
469
+ if (a.startsWith("CVE-")) return `<li><a href="https://nvd.nist.gov/vuln/detail/${esc(a)}" target="_blank">${esc(a)}</a> (NVD)</li>`;
470
+ return `<li>${esc(a)}</li>`;
471
+ }).join("");
472
+ sections.push(section("Aliases", `<ul class="ref-list">${aliasItems}</ul>`, { open: false }));
473
+ }
474
+
475
+ // 6. Full dep chains (transitive only)
476
+ if (dep.scope === "transitive") {
477
+ const paths = dep.viaPaths?.length ? dep.viaPaths : (dep.via?.length ? [dep.via] : []);
478
+ if (paths.length) {
479
+ const items = paths.map((p, i) => `<div>${i + 1}. ${esc([...p, depDisplayName(dep)].join(" → "))}</div>`).join("");
480
+ sections.push(section("All dependency chains", `<div class="via-list">${items}</div>`, { count: paths.length }));
481
+ }
482
+ }
483
+
484
+ // 7. Declared-in POMs
485
+ if (dep.pomPaths?.length) {
486
+ const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(p)}</div>`).join("");
487
+ sections.push(section("Declared in",
488
+ `<div class="via-list">${items}${dep.pomPaths.length > 10 ? `<div style="color:#9ca3af">+${dep.pomPaths.length - 10} more</div>` : ""}</div>`,
489
+ { count: `${dep.pomPaths.length} POM${dep.pomPaths.length > 1 ? "s" : ""}`, open: false }));
490
+ }
491
+
492
+ return `<div class="detail-panel">${sections.join("")}</div>`;
493
+ }
494
+
495
+ function formatPubDate(iso) {
496
+ if (!iso) return "—";
497
+ const s = String(iso).slice(0, 10);
498
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return "—";
499
+ return s;
500
+ }
501
+ function formatCwes(cwes) {
502
+ if (!Array.isArray(cwes) || !cwes.length) return `<span class="cwe-empty">—</span>`;
503
+ const top = cwes.slice(0, 2);
504
+ const more = cwes.length > 2 ? ` <span class="cwe-more">+${cwes.length - 2}</span>` : "";
505
+ return top.map(c => `<a href="https://cwe.mitre.org/data/definitions/${esc(String(c).replace(/^CWE-/i, ""))}.html" target="_blank" rel="noopener" class="cwe-link">${esc(c)}</a>`).join(" ") + more;
506
+ }
507
+
508
+ function renderCveRow(m, opts = {}) {
509
+ const { withAction } = opts;
510
+ const link = `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(m.cve.id)}`;
511
+ const sourceParts = m.source ? String(m.source).split("+") : [];
512
+ const source = sourceParts.map(s => `<span class="source ${esc(s)}">${esc(s)}</span>`).join("");
513
+ const conf = m.confidence ? `<div class="confidence">${esc(m.confidence)}</div>` : "";
514
+ const actionCell = withAction ? `<td class="action">${renderActionCell(m)}</td>` : "";
515
+ const baseCols = 8; // Sev, CVE, Dep, CWE, Published, Description, Fix, Source
516
+ const colspan = withAction ? baseCols + 1 : baseCols;
517
+ const pub = formatPubDate(m.cve.published);
518
+ const cwes = formatCwes(m.cve.cwes);
519
+ // Click-to-expand: clicking anywhere on tr.cve-row toggles the next sibling
520
+ // tr.detail-row. JS toggle is at the bottom of the document; links inside
521
+ // cells stay clickable (the handler ignores anchor/button clicks).
522
+ return `<tr class="cve-row${m.cpeFiltered ? " cpe-fp" : ""}">
523
+ <td>${badge(m.cve.severity)}${m.cve.score != null ? `<div class="confidence">${esc(m.cve.score.toFixed(1))}</div>` : ""}</td>
524
+ <td class="cve"><a href="${link}" target="_blank">${esc(m.cve.id)}</a>${conf}${m.cpeFiltered ? `<div class="cpe-tag">CPE-filtered</div>` : ""}</td>
525
+ ${depCell(m.dep)}
526
+ <td class="cwe">${cwes}</td>
527
+ <td class="published">${esc(pub)}</td>
528
+ <td class="desc">${esc(shortDesc(m.cve.description))}</td>
529
+ <td class="dep">${esc(m.cve.fixVersion || "—")}</td>
530
+ <td>${source}</td>
531
+ ${actionCell}
532
+ </tr>
533
+ <tr class="detail-row"><td colspan="${colspan}">${renderDetailPanel(m)}</td></tr>`;
534
+ }
535
+
536
+ function renderActionCell(m) {
537
+ const root = m.dep.via?.[0];
538
+ const fix = m.cve.fixVersion;
539
+ let action = "";
540
+ if (fix) {
541
+ action += `Pin <span class="pin">${esc(depDisplayName(m.dep))}</span> ≥&nbsp;${esc(fix)}`;
542
+ } else {
543
+ action += `Add <span class="pin">&lt;exclusion&gt;</span> in root POM`;
544
+ }
545
+ if (root) {
546
+ const rootInfo = m._rootUpdate;
547
+ if (rootInfo?.latest && rootInfo.latest !== rootInfo.current) {
548
+ action += `<div class="update-hint">or update <code>${esc(root)}</code> ${esc(rootInfo.current)} → ${esc(rootInfo.latest)}</div>`;
549
+ } else {
550
+ action += `<div class="update-hint">pulled in by <code>${esc(root)}</code></div>`;
551
+ }
552
+ }
553
+ return action;
554
+ }
555
+
556
+ function renderCveTable(cveMatches, opts = {}) {
557
+ if (!cveMatches?.length) return `<div class="empty">No CVEs matched.</div>`;
558
+ const { withAction } = opts;
559
+ const rows = cveMatches.map(m => renderCveRow(m, opts)).join("\n");
560
+ const actionHeader = withAction ? `<th>Recommended action</th>` : "";
561
+ return `<table>
562
+ <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>
563
+ <tbody>${rows}</tbody>
564
+ </table>`;
565
+ }
566
+
567
+ /**
568
+ * Build a snippet of pom.xml the user can paste into their root POM's
569
+ * <dependencyManagement> to pin a transitive to a safe version.
570
+ */
571
+ function dependencyManagementSnippet(items) {
572
+ const inner = items.map(it => ` <dependency>
573
+ <groupId>${esc(it.groupId)}</groupId>
574
+ <artifactId>${esc(it.artifactId)}</artifactId>
575
+ <version>${esc(it.fixVersion)}</version>
576
+ </dependency>`).join("\n");
577
+ return `<dependencyManagement>
578
+ <dependencies>
579
+ ${inner}
580
+ </dependencies>
581
+ </dependencyManagement>`;
582
+ }
583
+
584
+ function npmOverridesSnippet(items) {
585
+ const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
586
+ return `{
587
+ "overrides": {
588
+ ${lines}
589
+ }
590
+ }`;
591
+ }
592
+
593
+ function yarnResolutionsSnippet(items) {
594
+ const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
595
+ return `{
596
+ "resolutions": {
597
+ ${lines}
598
+ }
599
+ }`;
600
+ }
601
+
602
+ // Per-ecosystem fix-recipe metadata. Keys correspond to ecosystemType values.
603
+ const ECO_RECIPE = {
604
+ maven: {
605
+ label: "Maven",
606
+ pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
607
+ pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
608
+ snippet: dependencyManagementSnippet,
609
+ directSection: "B. Or update the direct dependencies pulling them in",
610
+ },
611
+ npm: {
612
+ label: "npm",
613
+ pinSection: "A. Pin vulnerable transitives via npm overrides",
614
+ pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
615
+ snippet: npmOverridesSnippet,
616
+ directSection: "B. Or update the direct dependencies (and run npm install)",
617
+ },
618
+ yarn: {
619
+ label: "Yarn",
620
+ pinSection: "A. Pin vulnerable transitives via yarn resolutions",
621
+ pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
622
+ snippet: yarnResolutionsSnippet,
623
+ directSection: "B. Or update the direct dependencies (and run yarn install)",
624
+ },
625
+ };
626
+
627
+ /**
628
+ * From CVE matches (split direct/transitive) + outdatedResults + resolvedDeps,
629
+ * build a list of actionable fixes:
630
+ * - byTransitive: pin coords (highest severity per coord, with fixVersion)
631
+ * - byDirect: which direct dep brings in the most CVE, with outdated lookup
632
+ */
633
+ function buildFixRecommendations(cveMatches, outdatedResults, resolvedDeps) {
634
+ const severityRank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
635
+ const outByKey = new Map();
636
+ for (const o of outdatedResults || []) outByKey.set(`${o.dep.groupId}:${o.dep.artifactId}`, o);
637
+
638
+ // Group transitive matches by the leaf transitive coordinate
639
+ const byTransitive = new Map();
640
+ // Group transitive matches by the root direct that pulls them in
641
+ const byDirect = new Map();
642
+
643
+ for (const m of cveMatches) {
644
+ if (m.dep.scope !== "transitive") continue;
645
+ const tkey = `${m.dep.groupId}:${m.dep.artifactId}`;
646
+ const tEntry = byTransitive.get(tkey) || {
647
+ groupId: m.dep.groupId, artifactId: m.dep.artifactId,
648
+ ecosystem: m.dep.ecosystem || "maven",
649
+ ecosystemType: m.dep.ecosystemType || (m.dep.ecosystem === "npm" ? "npm" : "maven"),
650
+ currentVersion: m.dep.version, fixVersion: null,
651
+ maxSeverity: "UNKNOWN", cveIds: [], rootDirects: new Set(),
652
+ };
653
+ tEntry.cveIds.push(m.cve.id);
654
+ if (m.cve.fixVersion && (!tEntry.fixVersion ||
655
+ compareVersionsLoose(m.cve.fixVersion, tEntry.fixVersion) > 0)) {
656
+ tEntry.fixVersion = m.cve.fixVersion;
657
+ }
658
+ if ((severityRank[m.cve.severity] || 0) > (severityRank[tEntry.maxSeverity] || 0)) {
659
+ tEntry.maxSeverity = m.cve.severity;
660
+ }
661
+ const root = m.dep.via?.[0];
662
+ if (root) tEntry.rootDirects.add(root);
663
+ byTransitive.set(tkey, tEntry);
664
+
665
+ if (root) {
666
+ const dEntry = byDirect.get(root) || {
667
+ key: root, transitives: new Map(),
668
+ cveCount: 0, maxSeverity: "UNKNOWN",
669
+ ecosystemType: m.dep.ecosystemType || (m.dep.ecosystem === "npm" ? "npm" : "maven"),
670
+ };
671
+ dEntry.cveCount++;
672
+ if ((severityRank[m.cve.severity] || 0) > (severityRank[dEntry.maxSeverity] || 0)) {
673
+ dEntry.maxSeverity = m.cve.severity;
674
+ }
675
+ const tCount = dEntry.transitives.get(tkey) || 0;
676
+ dEntry.transitives.set(tkey, tCount + 1);
677
+ byDirect.set(root, dEntry);
678
+ }
679
+ }
680
+
681
+ // Sort transitive recos: max severity desc, then cve count desc
682
+ const transitivesList = [...byTransitive.values()].sort((a, b) =>
683
+ (severityRank[b.maxSeverity] || 0) - (severityRank[a.maxSeverity] || 0) ||
684
+ b.cveIds.length - a.cveIds.length);
685
+
686
+ // Enrich direct list with outdated info + current version from resolvedDeps
687
+ const directsList = [...byDirect.values()].map(d => {
688
+ const dep = resolvedDeps?.get(d.key);
689
+ const out = outByKey.get(d.key);
690
+ return {
691
+ ...d,
692
+ currentVersion: dep?.version || null,
693
+ latest: out?.latest || null,
694
+ releaseDate: out?.releaseDate || null,
695
+ transitivesArr: [...d.transitives.entries()].map(([k, v]) => ({ key: k, cveCount: v })).sort((a, b) => b.cveCount - a.cveCount),
696
+ };
697
+ }).sort((a, b) =>
698
+ (severityRank[b.maxSeverity] || 0) - (severityRank[a.maxSeverity] || 0) ||
699
+ b.cveCount - a.cveCount);
700
+
701
+ return { transitivesList, directsList };
702
+ }
703
+
704
+ // Lightweight version comparator (sufficient for "pick highest fix version" when several CVEs share the same coord)
705
+ function compareVersionsLoose(a, b) {
706
+ const seg = s => String(s).split(/[.\-]/).map(p => /^\d+$/.test(p) ? parseInt(p, 10) : p);
707
+ const sa = seg(a), sb = seg(b);
708
+ for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
709
+ const x = sa[i], y = sb[i];
710
+ if (x === y) continue;
711
+ if (typeof x === "number" && typeof y === "number") return x - y;
712
+ return String(x ?? "").localeCompare(String(y ?? ""));
713
+ }
714
+ return 0;
715
+ }
716
+
717
+ function renderFixRecommendations(recos, allMatches = [], outdatedResults = []) {
718
+ // Section 7.0 — Direct deps to update. Aggregates every direct dep (not
719
+ // transitive, not parent, not vendored) that has at least one CVE and
720
+ // picks the highest fix-version found across its CVEs as the target.
721
+ const directs = buildDirectDepsToUpdate(allMatches, outdatedResults);
722
+ const directsSection = directs.length
723
+ ? minorSection(`7.0 Direct deps to update (${directs.length})`, renderDirectDepsToUpdateTable(directs))
724
+ : "";
725
+
726
+ if (!recos.transitivesList.length && !recos.directsList.length) {
727
+ return directsSection || `<div class="empty">No actionable recommendations.</div>`;
728
+ }
729
+
730
+ // Group by ecosystemType so each tool gets its own pin/update advice.
731
+ const byEco = { maven: { trans: [], directs: [] }, npm: { trans: [], directs: [] }, yarn: { trans: [], directs: [] } };
732
+ for (const t of recos.transitivesList) {
733
+ const et = ecoTypeOf(t);
734
+ (byEco[et] || byEco.maven).trans.push(t);
735
+ }
736
+ for (const d of recos.directsList) {
737
+ const et = ecoTypeOf(d);
738
+ (byEco[et] || byEco.maven).directs.push(d);
739
+ }
740
+
741
+ const ecoOrder = ["maven", "npm", "yarn"];
742
+ const out = [directsSection];
743
+ let letterIdx = 0;
744
+ for (const eco of ecoOrder) {
745
+ const slice = byEco[eco];
746
+ if (!slice.trans.length && !slice.directs.length) continue;
747
+ const meta = ECO_RECIPE[eco];
748
+ const inner = renderRecommendationsForEco(slice, meta);
749
+ const letter = String.fromCharCode("a".charCodeAt(0) + letterIdx++);
750
+ out.push(minorSection(`7.${letter} ${meta.label} (${slice.trans.length + slice.directs.length} transitive recommendation${(slice.trans.length + slice.directs.length) > 1 ? "s" : ""})`, inner));
751
+ }
752
+ return out.join("") || `<div class="empty">No actionable recommendations.</div>`;
753
+ }
754
+
755
+ /**
756
+ * Aggregate direct (non-transitive, non-parent, non-vendored) deps that have
757
+ * at least one matched CVE. For each, pick the highest declared fixVersion
758
+ * across its CVEs, or fall back to Maven Central's latest if known.
759
+ */
760
+ function buildDirectDepsToUpdate(matches, outdatedResults) {
761
+ const severityRank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
762
+ const outByKey = new Map();
763
+ for (const o of outdatedResults || []) outByKey.set(`${o.dep.groupId}:${o.dep.artifactId}`, o);
764
+
765
+ const byKey = new Map();
766
+ for (const m of matches) {
767
+ const d = m.dep || {};
768
+ if (d.scope === "transitive" || d.scope === "parent" || d.scope === "vendored") continue;
769
+ const key = depDisplayName(d);
770
+ const e = byKey.get(key) || {
771
+ key,
772
+ coord: key,
773
+ ecosystemType: d.ecosystemType || (d.ecosystem === "npm" ? "npm" : "maven"),
774
+ currentVersion: d.version || null,
775
+ cveIds: [],
776
+ maxSeverity: "UNKNOWN",
777
+ fixVersion: null,
778
+ };
779
+ e.cveIds.push(m.cve.id);
780
+ if ((severityRank[m.cve.severity] || 0) > (severityRank[e.maxSeverity] || 0)) e.maxSeverity = m.cve.severity;
781
+ if (m.cve.fixVersion && (!e.fixVersion || compareVersionsLoose(m.cve.fixVersion, e.fixVersion) > 0)) {
782
+ e.fixVersion = m.cve.fixVersion;
783
+ }
784
+ byKey.set(key, e);
785
+ }
786
+ // Enrich with outdated lookup
787
+ for (const e of byKey.values()) {
788
+ const lookupKey = e.ecosystemType === "maven" ? e.coord : null;
789
+ const o = lookupKey ? outByKey.get(lookupKey) : null;
790
+ e.latest = o?.latest || null;
791
+ }
792
+ return [...byKey.values()].sort((a, b) =>
793
+ (severityRank[b.maxSeverity] || 0) - (severityRank[a.maxSeverity] || 0) ||
794
+ b.cveIds.length - a.cveIds.length);
795
+ }
796
+
797
+ function renderDirectDepsToUpdateTable(entries) {
798
+ const intro = `<div class="fp-intro">Every direct dep with at least one CVE matched. The "Pin to ≥" column shows the highest fix-version declared across the CVEs for that dep. When that's missing, the dep needs a manual triage (no clean fix is published).</div>`;
799
+ const rows = entries.map(e => `<tr>
800
+ <td>${badge(e.maxSeverity)}</td>
801
+ <td class="dep">${esc(e.coord)}</td>
802
+ <td class="dep">${esc(e.currentVersion || "?")}</td>
803
+ <td class="dep">${e.fixVersion ? `<span style="color:#065f46;font-weight:600">${esc(e.fixVersion)}</span>` : `<span style="color:#9ca3af">— (manual triage)</span>`}</td>
804
+ <td class="dep">${e.latest ? esc(e.latest) : `<span style="color:#9ca3af">—</span>`}</td>
805
+ <td class="confidence">${e.cveIds.length} CVE: ${esc(e.cveIds.slice(0, 4).join(", "))}${e.cveIds.length > 4 ? "…" : ""}</td>
806
+ </tr>`).join("\n");
807
+ return intro + `<table>
808
+ <thead><tr><th>Worst sev</th><th>Direct dependency</th><th>Current</th><th>Pin to ≥</th><th>Maven Central latest</th><th>CVEs covered</th></tr></thead>
809
+ <tbody>${rows}</tbody>
810
+ </table>`;
811
+ }
812
+
813
+ function ecoTypeOf(entry) {
814
+ if (entry.ecosystemType) return entry.ecosystemType;
815
+ if (entry.ecosystem === "npm") return "npm";
816
+ return "maven";
817
+ }
818
+
819
+ function renderRecommendationsForEco(slice, meta) {
820
+ const blocks = [];
821
+
822
+ // Section A: pin transitives (eco-specific snippet)
823
+ if (slice.trans.length) {
824
+ let html = "";
825
+ const pinnable = slice.trans.filter(t => t.fixVersion);
826
+ if (pinnable.length) {
827
+ const top = pinnable.slice(0, 30);
828
+ html += `<div class="fix-recipe">
829
+ <div class="recipe-title">${meta.pinIntro(pinnable.length)}</div>
830
+ <code>${esc(meta.snippet(top))}</code>
831
+ ${pinnable.length > 30 ? `<div class="recipe-meta">${pinnable.length - 30} additional pins omitted from the snippet — see the table below.</div>` : ""}
832
+ </div>`;
833
+ }
834
+ const rows = slice.trans.map(t => `<tr>
835
+ <td>${badge(t.maxSeverity)}</td>
836
+ <td class="dep">${esc(t.ecosystem === "npm" ? "npm:" : `${t.groupId}:`)}<br>${esc(t.artifactId)}<br><span style="color:#6b7280">${esc(t.currentVersion)}</span></td>
837
+ <td class="dep">${t.fixVersion ? esc(t.fixVersion) : `<span style="color:#9ca3af">— (no clean fix declared)</span>`}</td>
838
+ <td class="confidence">${t.cveIds.length} CVE: ${esc(t.cveIds.slice(0, 4).join(", "))}${t.cveIds.length > 4 ? "…" : ""}</td>
839
+ <td class="dep">${[...t.rootDirects].slice(0, 3).map(r => esc(r)).join("<br>")}${t.rootDirects.size > 3 ? `<div class="confidence">+${t.rootDirects.size - 3} more</div>` : ""}</td>
840
+ </tr>`).join("\n");
841
+ html += `<table>
842
+ <thead><tr><th>Max Sev</th><th>Transitive (current)</th><th>Pin to ≥</th><th>CVE</th><th>Brought in by</th></tr></thead>
843
+ <tbody>${rows}</tbody>
844
+ </table>`;
845
+ blocks.push(minorSection(esc(meta.pinSection).replace("&lt;", "&lt;").replace("&gt;", "&gt;"), html));
846
+ }
847
+
848
+ // Section B: alternative — update the direct dep
849
+ if (slice.directs.length) {
850
+ let html = "";
851
+ const rows = slice.directs.map(d => {
852
+ const updateNote = d.latest && d.latest !== d.currentVersion
853
+ ? `<span style="color:#065f46">${esc(d.currentVersion || "?")} → ${esc(d.latest)}</span>`
854
+ : `<span style="color:#9ca3af">${esc(d.currentVersion || "?")} (no update available)</span>`;
855
+ return `<tr>
856
+ <td>${badge(d.maxSeverity)}</td>
857
+ <td class="dep">${esc(d.key)}</td>
858
+ <td>${updateNote}${d.releaseDate ? `<div class="confidence">latest released ${esc(d.releaseDate)}</div>` : ""}</td>
859
+ <td class="confidence">${d.cveCount} CVE in transitives</td>
860
+ <td class="dep">${d.transitivesArr.slice(0, 4).map(t => esc(t.key) + ` (${t.cveCount})`).join("<br>")}${d.transitivesArr.length > 4 ? `<div class="confidence">+${d.transitivesArr.length - 4} more</div>` : ""}</td>
861
+ </tr>`;
862
+ }).join("\n");
863
+ html += `<table>
864
+ <thead><tr><th>Worst transitive sev</th><th>Direct dependency</th><th>Update path</th><th>Transitive CVE</th><th>Vulnerable transitives</th></tr></thead>
865
+ <tbody>${rows}</tbody>
866
+ </table>`;
867
+ blocks.push(minorSection(esc(meta.directSection), html));
868
+ }
869
+
870
+ return blocks.join("");
871
+ }
872
+
873
+ function renderEolTable(eolResults) {
874
+ if (!eolResults?.length) return `<div class="empty">No end-of-life frameworks detected.</div>`;
875
+ const rows = eolResults.map(e => `<tr>
876
+ <td class="dep">${esc(e.product)}</td>
877
+ <td class="dep">${esc(depDisplayName(e.dep))}<br><span style="color:#6b7280">${esc(e.dep.version || "?")}</span>${renderDefinedIn(e.dep, RENDER_CTX.srcRoot)}</td>
878
+ <td>${esc(e.eol || "")}</td>
879
+ <td>${esc(e.latest || "")}</td>
880
+ <td class="desc">${esc(e.notes || "")}</td>
881
+ </tr>`).join("\n");
882
+ return `<table>
883
+ <thead><tr><th>Product</th><th>Dependency</th><th>EOL date</th><th>Latest</th><th>Notes</th></tr></thead>
884
+ <tbody>${rows}</tbody>
885
+ </table>`;
886
+ }
887
+
888
+ function renderObsoleteTable(obs) {
889
+ if (!obs?.length) return `<div class="empty">No obsolete / deprecated libraries detected.</div>`;
890
+ const rows = obs.map(o => `<tr>
891
+ <td>${badge((o.severity || "MEDIUM").toUpperCase())}</td>
892
+ <td class="dep">${esc(depDisplayName(o.dep))}<br><span style="color:#6b7280">${esc(o.dep.version || "?")}</span>${renderDefinedIn(o.dep, RENDER_CTX.srcRoot)}</td>
893
+ <td class="dep">${esc(o.replacement || "—")}</td>
894
+ <td class="desc">${esc(o.reason || "")}</td>
895
+ </tr>`).join("\n");
896
+ return `<table>
897
+ <thead><tr><th>Severity</th><th>Obsolete</th><th>Replacement</th><th>Why</th></tr></thead>
898
+ <tbody>${rows}</tbody>
899
+ </table>`;
900
+ }
901
+
902
+ function renderOutdatedTable(out) {
903
+ if (!out?.length) return `<div class="empty">No outdated libraries (or --allLibs not set).</div>`;
904
+ // Annotate each row with a jump category to cut through noise: a 0.1.x bump
905
+ // rarely matters; a major-version delta usually does. Sort so big jumps
906
+ // surface first, and de-emphasise rows tagged "patch".
907
+ const ranked = out.map(o => ({ o, jump: versionJump(o.dep.version, o.latest) }))
908
+ .sort((a, b) => {
909
+ const ord = { major: 3, minor: 2, patch: 1, pre: 0, unknown: 0 };
910
+ return (ord[b.jump.kind] || 0) - (ord[a.jump.kind] || 0)
911
+ || `${a.o.dep.groupId}:${a.o.dep.artifactId}`.localeCompare(`${b.o.dep.groupId}:${b.o.dep.artifactId}`);
912
+ });
913
+ const rows = ranked.map(({ o, jump }) => `<tr class="jump-${esc(jump.kind)}">
914
+ <td><span class="jump-badge jump-${esc(jump.kind)}">${esc(jump.label)}</span></td>
915
+ <td class="dep">${esc(depDisplayName(o.dep))}${renderDefinedIn(o.dep, RENDER_CTX.srcRoot)}</td>
916
+ <td class="dep">${esc(o.dep.version || "?")}</td>
917
+ <td class="dep">${esc(o.latest || "?")}</td>
918
+ <td>${esc(o.releaseDate || "")}</td>
919
+ </tr>`).join("\n");
920
+ return `<table>
921
+ <thead><tr><th>Jump</th><th>Dependency</th><th>Current</th><th>Latest</th><th>Released</th></tr></thead>
922
+ <tbody>${rows}</tbody>
923
+ </table>`;
924
+ }
925
+
926
+ /**
927
+ * Classify the gap between `current` and `latest` as major / minor / patch / pre / unknown.
928
+ * Used to triage Outdated rows so a patch-only diff doesn't drown majors.
929
+ */
930
+ function versionJump(current, latest) {
931
+ if (!current || !latest) return { kind: "unknown", label: "?" };
932
+ const segs = s => String(s).split(/[.\-]/).map(x => /^\d+$/.test(x) ? parseInt(x, 10) : null);
933
+ const a = segs(current), b = segs(latest);
934
+ if (a[0] !== b[0]) {
935
+ if (a[0] != null && b[0] != null) {
936
+ return { kind: "major", label: `+${b[0] - a[0]} major` };
937
+ }
938
+ }
939
+ if (a[1] !== b[1]) return { kind: "minor", label: `+${(b[1] || 0) - (a[1] || 0)} minor` };
940
+ if (a[2] !== b[2]) return { kind: "patch", label: `+${(b[2] || 0) - (a[2] || 0)} patch` };
941
+ return { kind: "pre", label: "pre/qual" };
942
+ }
943
+
944
+ function summaryCards(stats, eol, obs, out, extra = {}) {
945
+ const cards = [
946
+ { v: stats.critical, l: "Critical", cls: "sev-critical" },
947
+ { v: stats.high, l: "High", cls: "sev-high" },
948
+ { v: stats.medium, l: "Medium", cls: "sev-medium" },
949
+ { v: stats.low, l: "Low", cls: "sev-low" },
950
+ { v: stats.total, l: "Total CVEs" },
951
+ { v: stats.direct, l: "in Direct" },
952
+ { v: stats.transitive, l: "in Transitive" },
953
+ { v: extra.vendored ?? 0, l: "Vendored JS", cls: "sev-vendored" },
954
+ { v: eol?.length || 0, l: "EOL" },
955
+ { v: obs?.length || 0, l: "Obsolete" },
956
+ { v: out?.length || 0, l: "Outdated" },
957
+ { v: extra.warnings?.length || 0, l: "Scan alerts", cls: "sev-warn" },
958
+ ];
959
+ 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>`;
960
+ }
961
+
962
+ /**
963
+ * Merge duplicate (dep, cve) rows that have the same g:a:v + cve.id but
964
+ * different via paths (can happen when sources overlap or when the same
965
+ * transitive is brought in by multiple roots). Resulting row carries the
966
+ * union of viaPaths so the table shows one entry per real (dep, cve) pair.
967
+ */
968
+ function mergeMatches(matches) {
969
+ const byKey = new Map();
970
+ for (const m of matches) {
971
+ const key = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
972
+ if (!byKey.has(key)) {
973
+ // Deep-ish copy so we can mutate dep.viaPaths
974
+ byKey.set(key, { ...m, dep: { ...m.dep, viaPaths: m.dep.viaPaths ? [...m.dep.viaPaths] : (m.dep.via ? [m.dep.via] : []) } });
975
+ continue;
976
+ }
977
+ const existing = byKey.get(key);
978
+ // Union of via paths
979
+ const incoming = m.dep.viaPaths || (m.dep.via ? [m.dep.via] : []);
980
+ for (const p of incoming) {
981
+ const sig = p.join("→");
982
+ if (!existing.dep.viaPaths.some(q => q.join("→") === sig)) existing.dep.viaPaths.push(p);
983
+ }
984
+ // Union of sources (e.g. "fad" + "osv" → "fad+osv")
985
+ if (m.source && existing.source && m.source !== existing.source) {
986
+ const set = new Set([...existing.source.split("+"), ...m.source.split("+")]);
987
+ existing.source = [...set].sort().join("+");
988
+ }
989
+ }
990
+ return [...byKey.values()];
991
+ }
992
+
993
+ function attachRootUpdates(cveMatches, outdatedResults, resolvedDeps) {
994
+ const outByKey = new Map();
995
+ for (const o of outdatedResults || []) outByKey.set(`${o.dep.groupId}:${o.dep.artifactId}`, o);
996
+ for (const m of cveMatches) {
997
+ if (m.dep.scope !== "transitive") continue;
998
+ const root = m.dep.via?.[0];
999
+ if (!root) continue;
1000
+ const dep = resolvedDeps?.get(root);
1001
+ const out = outByKey.get(root);
1002
+ m._rootUpdate = { current: dep?.version || null, latest: out?.latest || null };
1003
+ }
1004
+ }
1005
+
1006
+ function majorSection(title, contentHtml, opts = {}) {
1007
+ const { open = true, id } = opts;
1008
+ const idAttr = id ? ` id="${esc(id)}"` : "";
1009
+ return `<details class="report-section"${open ? " open" : ""}${idAttr}>
1010
+ <summary><h2>${title}</h2></summary>
1011
+ <div>${contentHtml}</div>
1012
+ </details>`;
1013
+ }
1014
+
1015
+ function minorSection(title, contentHtml, opts = {}) {
1016
+ const { open = true } = opts;
1017
+ return `<details class="report-subsection"${open ? " open" : ""}>
1018
+ <summary><h3>${title}</h3></summary>
1019
+ <div>${contentHtml}</div>
1020
+ </details>`;
1021
+ }
1022
+
1023
+ const WARNING_HEADINGS = {
1024
+ "no-lockfile": { icon: "⚠️", title: "JS manifest without lockfile" },
1025
+ "yarn-berry-unsupported": { icon: "⚠️", title: "Yarn 2+/Berry lockfile" },
1026
+ "unresolved-versions": { icon: "⚠️", title: "Maven deps without a concrete version — silently skipped, run a real Maven/Snyk scan to complete" },
1027
+ "private-libs": { icon: "🔒", title: "Private / internal libraries — not on Maven Central, not scanned" },
1028
+ };
1029
+
1030
+ /**
1031
+ * Render one ecosystem's CVE rows:
1032
+ * - "All" sub-section first (direct + transitive split)
1033
+ * - "By <manifest kind>" wrapper section that ALWAYS appears, even with
1034
+ * only one manifest. Each child shows just the relative manifest path
1035
+ * as its title (no extra numbering noise).
1036
+ *
1037
+ * Transitive deps are skipped from per-manifest sub-sections because they
1038
+ * don't have a defining manifest in the source tree.
1039
+ */
1040
+ function renderEcoSection(matches, sectionPrefix, srcRoot, ecoKey) {
1041
+ const direct = matches.filter(m => m.dep.scope !== "transitive");
1042
+ const trans = matches.filter(m => m.dep.scope === "transitive");
1043
+
1044
+ const allInner =
1045
+ minorSection(`All — direct (${direct.length})`, renderCveTable(direct)) +
1046
+ minorSection(`All — transitive (${trans.length})`, renderCveTable(trans, { withAction: true }), { open: trans.length <= 100 });
1047
+ const blocks = [minorSection(`${sectionPrefix}.0 All (${matches.length})`, allInner)];
1048
+
1049
+ // Collect unique manifests that DIRECT deps reference.
1050
+ const manifestMap = new Map();
1051
+ for (const m of direct) {
1052
+ const paths = (m.dep.manifestPaths?.length ? m.dep.manifestPaths
1053
+ : m.dep.pomPaths?.length ? m.dep.pomPaths
1054
+ : []);
1055
+ for (const p of paths) {
1056
+ const list = manifestMap.get(p) || [];
1057
+ list.push(m);
1058
+ manifestMap.set(p, list);
1059
+ }
1060
+ }
1061
+ const sortedManifests = [...manifestMap.entries()]
1062
+ .filter(([p]) => !!p)
1063
+ .sort((a, b) => makeRelative(a[0], srcRoot).localeCompare(makeRelative(b[0], srcRoot)));
1064
+
1065
+ if (sortedManifests.length) {
1066
+ const manifestKind = ECO_MANIFEST_KIND[ecoKey] || "manifest";
1067
+ const perManifest = sortedManifests.map(([manifestPath, mList]) => {
1068
+ const rel = makeRelative(manifestPath, srcRoot);
1069
+ return minorSection(`<code class="path">${esc(rel)}</code> (${mList.length})`,
1070
+ renderCveTable(mList), { open: mList.length <= 30 });
1071
+ }).join("");
1072
+ blocks.push(minorSection(`By ${manifestKind} (${sortedManifests.length} file${sortedManifests.length > 1 ? "s" : ""})`, perManifest, { open: false }));
1073
+ }
1074
+ return blocks.join("");
1075
+ }
1076
+
1077
+ /**
1078
+ * Render retire.js matches as a flat table — they're keyed by vendored file
1079
+ * path, not by manifest, so the by-manifest split doesn't apply here.
1080
+ */
1081
+ function renderRetireTable(matches) {
1082
+ if (!matches || !matches.length) {
1083
+ return `<div class="empty">No vulnerable vendored JavaScript files detected (or retire.js was skipped).</div>`;
1084
+ }
1085
+ const rank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
1086
+ const sorted = [...matches].sort((a, b) =>
1087
+ (rank[(b.cve.severity || "").toUpperCase()] || 0) - (rank[(a.cve.severity || "").toUpperCase()] || 0) ||
1088
+ String(a.dep.vendoredFile || "").localeCompare(String(b.dep.vendoredFile || "")));
1089
+ const rows = sorted.map(m => `<tr>
1090
+ <td>${badge((m.cve.severity || "MEDIUM").toUpperCase())}</td>
1091
+ <td class="dep">${esc(m.dep.artifactId)}<br><span style="color:#6b7280">${esc(m.dep.version || "?")}</span></td>
1092
+ <td class="dep"><code class="path">${esc(m.dep.vendoredFile || "")}</code></td>
1093
+ <td class="cve">${m.cve.id?.startsWith("CVE-") ? `<a href="https://nvd.nist.gov/vuln/detail/${esc(m.cve.id)}" target="_blank" rel="noopener">${esc(m.cve.id)}</a>` : esc(m.cve.id)}</td>
1094
+ <td class="dep">${esc(m.cve.fixVersion || "—")}</td>
1095
+ <td class="desc">${esc(shortDesc(m.cve.description))}</td>
1096
+ </tr>`).join("\n");
1097
+ return `<table>
1098
+ <thead><tr><th>Sev</th><th>Library</th><th>Vendored file</th><th>CVE / GHSA</th><th>Fixed in</th><th>Summary</th></tr></thead>
1099
+ <tbody>${rows}</tbody>
1100
+ </table>`;
1101
+ }
1102
+
1103
+ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches }) {
1104
+ const eolN = eolResults?.length || 0;
1105
+ const obsN = obsoleteResults?.length || 0;
1106
+ const outN = outdatedResults?.length || 0;
1107
+ // Compute global criticality. CRITICAL prod CVE or critical retire finding → CRITICAL.
1108
+ // EOL frameworks bump to at least HIGH.
1109
+ const obsCrit = (obsoleteResults || []).filter(o => (o.severity || "").toUpperCase() === "CRITICAL").length;
1110
+ let level = "INFO";
1111
+ if (prodStats.critical > 0 || retireStats.critical > 0 || obsCrit > 0) level = "CRITICAL";
1112
+ else if (prodStats.high > 0 || retireStats.high > 0 || eolN > 0) level = "HIGH";
1113
+ else if (prodStats.medium > 0 || retireStats.medium > 0 || obsN > 0) level = "MEDIUM";
1114
+ else if (prodStats.low > 0 || retireStats.low > 0 || outN > 0) level = "LOW";
1115
+ else if (prodStats.total > 0) level = "UNKNOWN";
1116
+
1117
+ const bullets = [];
1118
+ 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})`);
1119
+ if (devStats.total) bullets.push(`<strong>${devStats.total}</strong> CVE in dev/test deps`);
1120
+ if (retireStats.total) bullets.push(`<strong>${retireStats.total}</strong> vulnerable vendored JS finding(s) (retire.js)`);
1121
+ if (eolN) bullets.push(`<strong>${eolN}</strong> end-of-life framework${eolN > 1 ? "s" : ""}`);
1122
+ if (obsN) bullets.push(`<strong>${obsN}</strong> obsolete / deprecated lib${obsN > 1 ? "s" : ""}`);
1123
+ if (outN) bullets.push(`<strong>${outN}</strong> outdated lib${outN > 1 ? "s" : ""}`);
1124
+ if (warnings?.length) bullets.push(`<strong>${warnings.length}</strong> scan-completeness alert${warnings.length > 1 ? "s" : ""} — see chapter 0`);
1125
+
1126
+ const topPreview = (topCriticalMatches || []).length ? `<div class="exec-top">
1127
+ <div class="exec-top-title">Top ${topCriticalMatches.length} most critical (preview)</div>
1128
+ <ul class="exec-top-list">${topCriticalMatches.map(m => `<li>
1129
+ ${badge((m.cve.severity || "HIGH").toUpperCase())}
1130
+ <a class="exec-cve-link" href="#ch${m.dep?.scope === "vendored" ? "2" : "1"}">${esc(m.cve.id)}</a>
1131
+ <code>${esc(depDisplayName(m.dep))}</code>
1132
+ <span class="exec-ver">${esc(m.dep.version || "?")}</span>
1133
+ ${m.cve.fixVersion ? `<span class="exec-fix">→ ${esc(m.cve.fixVersion)}</span>` : ""}
1134
+ </li>`).join("")}</ul>
1135
+ </div>` : "";
1136
+
1137
+ return `<div class="exec-summary exec-${esc(level.toLowerCase())}">
1138
+ <div class="exec-head">
1139
+ <span class="exec-level">${esc(level)}</span>
1140
+ <span class="exec-title">Executive Summary</span>
1141
+ </div>
1142
+ <div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
1143
+ <ul class="exec-bullets">
1144
+ ${bullets.map(b => `<li>${b}</li>`).join("") || `<li>No findings detected.</li>`}
1145
+ </ul>
1146
+ ${topPreview}
1147
+ </div>`;
1148
+ }
1149
+
1150
+ function renderWarnings(warnings) {
1151
+ if (!warnings || !warnings.length) return "";
1152
+ const blocks = warnings.map(w => {
1153
+ const meta = WARNING_HEADINGS[w.type] || { icon: "⚠️", title: w.type };
1154
+ const itemLimit = w.type === "private-libs" ? 200 : 50;
1155
+ const items = renderWarningItems(w.items || [], itemLimit);
1156
+ const manifest = w.manifestPath ? `<div class="warn-path"><code>${esc(makeRelative(w.manifestPath, RENDER_CTX.srcRoot))}</code></div>` : "";
1157
+ return `<div class="warn-block warn-${esc(w.type)}">
1158
+ <div class="warn-head">${meta.icon} <strong>${esc(meta.title)}</strong>${w.count ? ` <span class="count">(${w.count})</span>` : ""}</div>
1159
+ ${manifest}
1160
+ <div class="warn-msg">${esc(w.message || "")}</div>
1161
+ ${items}
1162
+ </div>`;
1163
+ }).join("");
1164
+ return `<div class="warnings warnings-chapter">${blocks}</div>`;
1165
+ }
1166
+
1167
+ /**
1168
+ * Render warning items. Each item is either:
1169
+ * - a string → render as a single <code>
1170
+ * - an object { id, manifestPaths } → render the id plus its defining
1171
+ * manifest(s) so the team can locate where the entry is declared.
1172
+ */
1173
+ function renderWarningItems(items, itemLimit) {
1174
+ if (!items.length) return "";
1175
+ const shown = items.slice(0, itemLimit);
1176
+ const lis = shown.map(it => {
1177
+ if (typeof it === "string") return `<li><code>${esc(it)}</code></li>`;
1178
+ const id = it.id || "";
1179
+ const paths = (it.manifestPaths || []).slice(0, 6);
1180
+ const pathsHtml = paths.length
1181
+ ? `<div class="warn-defined">defined in: ${paths.map(p => `<code class="path">${esc(p)}</code>`).join(", ")}${it.manifestPaths.length > paths.length ? ` <span class="warn-defined-more">+${it.manifestPaths.length - paths.length} more</span>` : ""}</div>`
1182
+ : "";
1183
+ return `<li><code>${esc(id)}</code>${pathsHtml}</li>`;
1184
+ }).join("");
1185
+ const overflow = items.length > itemLimit ? `<li class="more">…and ${items.length - itemLimit} more</li>` : "";
1186
+ return `<ul class="warn-items">${lis}${overflow}</ul>`;
1187
+ }
1188
+
1189
+ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings }) {
1190
+ setRenderCtx({ srcRoot: projectInfo?.src || null });
1191
+ // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1192
+ cveMatches = mergeMatches(cveMatches || []);
1193
+ devCveMatches = mergeMatches(devCveMatches || []);
1194
+ retireMatches = retireMatches || [];
1195
+ attachRootUpdates(cveMatches, outdatedResults, resolvedDeps);
1196
+ attachRootUpdates(devCveMatches, outdatedResults, resolvedDeps);
1197
+
1198
+ // (D) Split cpe-filtered matches out so they don't inflate the headline
1199
+ // counts. CPE refinement marks them when the dep version sits outside
1200
+ // every vulnerable range NVD knows about — likely false positive.
1201
+ const prodMatchesActive = cveMatches.filter(m => !m.cpeFiltered);
1202
+ const prodMatchesFP = cveMatches.filter(m => m.cpeFiltered);
1203
+ const devMatchesActive = devCveMatches.filter(m => !m.cpeFiltered);
1204
+ const devMatchesFP = devCveMatches.filter(m => m.cpeFiltered);
1205
+
1206
+ const prodStats = computeStats(prodMatchesActive);
1207
+ const devStats = computeStats(devMatchesActive);
1208
+ const retireStats = computeStats(retireMatches);
1209
+
1210
+ // Recos are built from prod matches only (dev/test issues aren't a release blocker).
1211
+ const recos = buildFixRecommendations(prodMatchesActive, outdatedResults, resolvedDeps);
1212
+
1213
+ const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
1214
+ const vendoredContent = renderRetireTable(retireMatches);
1215
+ const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
1216
+ const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP]);
1217
+
1218
+ const toolbar = `<div class="toolbar">
1219
+ <button class="btn" type="button" data-action="expand-all">⊕ Expand all</button>
1220
+ <button class="btn" type="button" data-action="collapse-all">⊖ Collapse all</button>
1221
+ <button class="btn primary" type="button" data-action="expand-cves">↧ Expand all CVE details</button>
1222
+ <button class="btn" type="button" data-action="collapse-cves">↥ Collapse all CVE details</button>
1223
+ <span class="toolbar-label">Click a section header or a CVE row to toggle.</span>
1224
+ </div>`;
1225
+
1226
+ const exec = renderExecutiveSummary({
1227
+ projectInfo, prodStats, devStats, retireStats,
1228
+ eolResults, obsoleteResults, outdatedResults,
1229
+ warnings, totalDeps: resolvedDeps?.size || 0,
1230
+ topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
1231
+ });
1232
+
1233
+ // (C) Table of contents — rendered as a sticky nav. Only enumerates the
1234
+ // chapters that actually have content (warnings, dev CVE, retire, FP appendix
1235
+ // might be empty).
1236
+ const toc = renderToc({
1237
+ hasWarnings: !!(warnings?.length),
1238
+ prodTotal: prodStats.total,
1239
+ retireTotal: retireMatches.length,
1240
+ devTotal: devStats.total,
1241
+ eolTotal: eolResults?.length || 0,
1242
+ obsoleteTotal: obsoleteResults?.length || 0,
1243
+ outdatedTotal: outdatedResults?.length || 0,
1244
+ fpTotal: prodMatchesFP.length + devMatchesFP.length,
1245
+ });
1246
+
1247
+ return `
1248
+ <header class="report-header">
1249
+ <h1>FAD-Check Report</h1>
1250
+ <div class="report-subtitle">Multi-ecosystem dependency security audit</div>
1251
+ <div class="meta">
1252
+ Project: <strong>${esc(projectInfo.name)}</strong> · <code class="path">${esc(projectInfo.src)}</code><br>
1253
+ Generated: ${esc(projectInfo.generatedAt)}${projectInfo.toolVersion ? ` · fad-check ${esc(projectInfo.toolVersion)}` : ""}${projectInfo.cveDataDate ? ` · CVE data: ${esc(projectInfo.cveDataDate)}` : ""}
1254
+ </div>
1255
+ </header>
1256
+ ${exec}
1257
+ ${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length })}
1258
+ ${toc}
1259
+ ${toolbar}
1260
+
1261
+ ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1262
+ ${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
1263
+ ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1264
+ ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1265
+ ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
1266
+ ${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
1267
+ ${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
1268
+ ${majorSection(`7. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch7" })}
1269
+ ${(prodMatchesFP.length + devMatchesFP.length) ? majorSection(`8. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length})`, fpContent, { open: false, id: "ch8" }) : ""}
1270
+ `;
1271
+ }
1272
+
1273
+ /**
1274
+ * Pick the most impactful matches to preview in the exec summary.
1275
+ * Sort by severity rank, prefer entries with a fix-version (more actionable),
1276
+ * dedupe per dep coord so the preview shows diversity instead of 5 rows of
1277
+ * the same tomcat CVE.
1278
+ */
1279
+ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1280
+ const rank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
1281
+ const all = [...prodMatches, ...retireMatches]
1282
+ .filter(m => (rank[(m.cve?.severity || "").toUpperCase()] || 0) >= 3); // HIGH+
1283
+ all.sort((a, b) => {
1284
+ const sa = rank[(a.cve.severity || "").toUpperCase()] || 0;
1285
+ const sb = rank[(b.cve.severity || "").toUpperCase()] || 0;
1286
+ if (sb !== sa) return sb - sa;
1287
+ // Prefer those with a known fixVersion (actionable)
1288
+ const af = a.cve.fixVersion ? 1 : 0, bf = b.cve.fixVersion ? 1 : 0;
1289
+ if (bf !== af) return bf - af;
1290
+ return (a.cve.id || "").localeCompare(b.cve.id || "");
1291
+ });
1292
+ const seen = new Set();
1293
+ const out = [];
1294
+ for (const m of all) {
1295
+ const dep = m.dep;
1296
+ const k = `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1297
+ if (seen.has(k)) continue;
1298
+ seen.add(k);
1299
+ out.push(m);
1300
+ if (out.length >= n) break;
1301
+ }
1302
+ return out;
1303
+ }
1304
+
1305
+ function renderToc({ hasWarnings, prodTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, fpTotal }) {
1306
+ const entries = [];
1307
+ if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1308
+ entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1309
+ entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
1310
+ entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1311
+ entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1312
+ entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
1313
+ entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
1314
+ entries.push({ id: "ch7", label: `7. Fix Recos` });
1315
+ if (fpTotal) entries.push({ id: "ch8", label: `8. Likely FP (${fpTotal})` });
1316
+ return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1317
+ }
1318
+
1319
+ function renderFalsePositives(matches) {
1320
+ if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1321
+ 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>`;
1322
+ return intro + renderCveTable(matches);
1323
+ }
1324
+
1325
+ const ECO_LABELS = { maven: "Maven", npm: "npm (package-lock)", yarn: "Yarn" };
1326
+ const ECO_MANIFEST_KIND = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
1327
+
1328
+ function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
1329
+ const ecoOrder = ["maven", "npm", "yarn"];
1330
+ const byEco = { maven: [], npm: [], yarn: [] };
1331
+ for (const m of matches) {
1332
+ const et = m.dep?.ecosystemType || (m.dep?.ecosystem === "npm" ? "npm" : "maven");
1333
+ (byEco[et] || byEco.maven).push(m);
1334
+ }
1335
+ const out = [];
1336
+ let letterIdx = 0;
1337
+ for (const eco of ecoOrder) {
1338
+ const list = byEco[eco];
1339
+ if (!list || !list.length) continue;
1340
+ const letter = String.fromCharCode("a".charCodeAt(0) + letterIdx++);
1341
+ out.push(minorSection(
1342
+ `${chapterPrefix}.${letter} ${ECO_LABELS[eco]} (${list.length})`,
1343
+ renderEcoSection(list, `${chapterPrefix}.${letter}`, srcRoot, eco)
1344
+ ));
1345
+ }
1346
+ return out.join("") || `<div class="empty">No CVE matches.</div>`;
1347
+ }
1348
+
1349
+ const TOGGLE_SCRIPT = `
1350
+ <script>
1351
+ (function(){
1352
+ function isInteractive(el){
1353
+ if(!el) return false;
1354
+ var t = el.tagName;
1355
+ return t === 'A' || t === 'BUTTON' || t === 'INPUT' || t === 'SUMMARY' || t === 'DETAILS' || t === 'LABEL';
1356
+ }
1357
+ // CVE row click → toggle its detail panel
1358
+ document.querySelectorAll('tr.cve-row').forEach(function(row){
1359
+ row.addEventListener('click', function(e){
1360
+ var t = e.target;
1361
+ while(t && t !== row){
1362
+ if(isInteractive(t)) return;
1363
+ t = t.parentNode;
1364
+ }
1365
+ var next = row.nextElementSibling;
1366
+ if(next && next.classList.contains('detail-row')){
1367
+ var nowOpen = next.classList.toggle('open');
1368
+ row.classList.toggle('open', nowOpen);
1369
+ }
1370
+ });
1371
+ });
1372
+ // Toolbar buttons
1373
+ document.querySelectorAll('.toolbar .btn').forEach(function(btn){
1374
+ btn.addEventListener('click', function(){
1375
+ var action = btn.getAttribute('data-action');
1376
+ if(action === 'expand-all'){
1377
+ document.querySelectorAll('details').forEach(function(d){ d.open = true; });
1378
+ document.querySelectorAll('tr.cve-row').forEach(function(r){
1379
+ r.classList.add('open');
1380
+ var n = r.nextElementSibling;
1381
+ if(n && n.classList.contains('detail-row')) n.classList.add('open');
1382
+ });
1383
+ } else if(action === 'collapse-all'){
1384
+ document.querySelectorAll('details').forEach(function(d){ d.open = false; });
1385
+ document.querySelectorAll('tr.cve-row.open').forEach(function(r){ r.classList.remove('open'); });
1386
+ document.querySelectorAll('tr.detail-row.open').forEach(function(r){ r.classList.remove('open'); });
1387
+ } else if(action === 'expand-cves'){
1388
+ document.querySelectorAll('tr.cve-row').forEach(function(r){
1389
+ r.classList.add('open');
1390
+ var n = r.nextElementSibling;
1391
+ if(n && n.classList.contains('detail-row')) n.classList.add('open');
1392
+ });
1393
+ } else if(action === 'collapse-cves'){
1394
+ document.querySelectorAll('tr.cve-row.open').forEach(function(r){ r.classList.remove('open'); });
1395
+ document.querySelectorAll('tr.detail-row.open').forEach(function(r){ r.classList.remove('open'); });
1396
+ }
1397
+ });
1398
+ });
1399
+ })();
1400
+ </script>`;
1401
+
1402
+ function generateHtmlReport(payload) {
1403
+ return `<!doctype html>
1404
+ <html><head><meta charset="utf-8"><title>FAD-Check Report</title><style>${CSS}</style></head>
1405
+ <body>${buildBody(payload)}${TOGGLE_SCRIPT}</body></html>`;
1406
+ }
1407
+
1408
+ // Word can't run scripts and doesn't honour display:none reliably enough — so
1409
+ // the .doc variant ships with every section/detail/row already expanded.
1410
+ function generateWordReport(payload) {
1411
+ let body = buildBody(payload);
1412
+ // Force-open every <details> and every cve-row/detail-row in the static markup
1413
+ body = body.replace(/<details ([^>]*?)>/g, (m, attrs) => {
1414
+ if (attrs.includes(" open")) return m;
1415
+ return `<details ${attrs} open>`;
1416
+ });
1417
+ body = body.replace(/<tr class="detail-row">/g, '<tr class="detail-row open">');
1418
+ body = body.replace(/<tr class="cve-row">/g, '<tr class="cve-row open">');
1419
+ // Drop the interactive toolbar (no buttons useful in a print/Word view)
1420
+ body = body.replace(/<div class="toolbar">[\s\S]*?<\/div>/, "");
1421
+ return `<html xmlns:o="urn:schemas-microsoft-com:office:office"
1422
+ xmlns:w="urn:schemas-microsoft-com:office:word"
1423
+ xmlns="http://www.w3.org/TR/REC-html40">
1424
+ <head>
1425
+ <meta charset="utf-8">
1426
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
1427
+ <meta name="ProgId" content="Word.Document">
1428
+ <meta name="Generator" content="Microsoft Word 15">
1429
+ <title>FAD-Check Report</title>
1430
+ <style>${CSS}
1431
+ /* In Word: keep every detail row expanded since scripts are disabled */
1432
+ tr.detail-row { display: table-row !important; }
1433
+ </style>
1434
+ </head>
1435
+ <body>${body}</body>
1436
+ </html>`;
1437
+ }
1438
+
1439
+ async function writeReports({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, outputDir, warnings }) {
1440
+ await fs.promises.mkdir(outputDir, { recursive: true });
1441
+ const payload = { cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings };
1442
+ const htmlPath = path.join(outputDir, "cve-report.html");
1443
+ const docPath = path.join(outputDir, "cve-report.doc");
1444
+ await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
1445
+ await fs.promises.writeFile(docPath, generateWordReport(payload));
1446
+ return { htmlPath, docPath };
1447
+ }
1448
+
1449
+ module.exports = {
1450
+ computeStats,
1451
+ generateHtmlReport,
1452
+ generateWordReport,
1453
+ writeReports,
1454
+ esc,
1455
+ };