fad-checker 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ {
2
+ "_comment": "Common CWE identifiers → short human name. Covers the bulk of CVEs in practice. Source: cwe.mitre.org. Names are abbreviated for compact UI display.",
3
+ "CWE-20": "Improper Input Validation",
4
+ "CWE-22": "Path Traversal",
5
+ "CWE-59": "Link Following",
6
+ "CWE-74": "Improper Neutralization of Special Elements (Injection)",
7
+ "CWE-77": "Command Injection",
8
+ "CWE-78": "OS Command Injection",
9
+ "CWE-79": "Cross-site Scripting (XSS)",
10
+ "CWE-88": "Argument Injection",
11
+ "CWE-89": "SQL Injection",
12
+ "CWE-90": "LDAP Injection",
13
+ "CWE-91": "XML Injection",
14
+ "CWE-93": "CRLF Injection",
15
+ "CWE-94": "Code Injection",
16
+ "CWE-95": "Eval Injection",
17
+ "CWE-113": "HTTP Response Splitting",
18
+ "CWE-115": "Misinterpretation of Input",
19
+ "CWE-116": "Improper Encoding or Escaping of Output",
20
+ "CWE-117": "Improper Output Neutralization for Logs",
21
+ "CWE-118": "Incorrect Access of Indexable Resource",
22
+ "CWE-119": "Improper Restriction of Operations within the Bounds of a Memory Buffer",
23
+ "CWE-120": "Buffer Copy without Checking Size (Classic Buffer Overflow)",
24
+ "CWE-121": "Stack-based Buffer Overflow",
25
+ "CWE-122": "Heap-based Buffer Overflow",
26
+ "CWE-125": "Out-of-bounds Read",
27
+ "CWE-129": "Improper Validation of Array Index",
28
+ "CWE-131": "Incorrect Calculation of Buffer Size",
29
+ "CWE-134": "Use of Externally-Controlled Format String",
30
+ "CWE-178": "Improper Handling of Case Sensitivity",
31
+ "CWE-184": "Incomplete List of Disallowed Inputs",
32
+ "CWE-185": "Incorrect Regular Expression",
33
+ "CWE-190": "Integer Overflow or Wraparound",
34
+ "CWE-191": "Integer Underflow",
35
+ "CWE-193": "Off-by-one Error",
36
+ "CWE-200": "Exposure of Sensitive Information to an Unauthorized Actor",
37
+ "CWE-203": "Observable Discrepancy",
38
+ "CWE-204": "Observable Response Discrepancy",
39
+ "CWE-208": "Observable Timing Discrepancy",
40
+ "CWE-209": "Generation of Error Message Containing Sensitive Information",
41
+ "CWE-212": "Improper Removal of Sensitive Information Before Storage or Transfer",
42
+ "CWE-215": "Insertion of Sensitive Information Into Debugging Code",
43
+ "CWE-226": "Sensitive Information in Resource Not Removed Before Reuse",
44
+ "CWE-241": "Improper Handling of Unexpected Data Type",
45
+ "CWE-248": "Uncaught Exception",
46
+ "CWE-252": "Unchecked Return Value",
47
+ "CWE-256": "Plaintext Storage of a Password",
48
+ "CWE-257": "Storing Passwords in a Recoverable Format",
49
+ "CWE-259": "Use of Hard-coded Password",
50
+ "CWE-260": "Password in Configuration File",
51
+ "CWE-261": "Weak Encoding for Password",
52
+ "CWE-262": "Not Using Password Aging",
53
+ "CWE-269": "Improper Privilege Management",
54
+ "CWE-272": "Least Privilege Violation",
55
+ "CWE-273": "Improper Check for Dropped Privileges",
56
+ "CWE-275": "Permission Issues",
57
+ "CWE-276": "Incorrect Default Permissions",
58
+ "CWE-281": "Improper Preservation of Permissions",
59
+ "CWE-284": "Improper Access Control",
60
+ "CWE-285": "Improper Authorization",
61
+ "CWE-287": "Improper Authentication",
62
+ "CWE-288": "Authentication Bypass Using an Alternate Path or Channel",
63
+ "CWE-290": "Authentication Bypass by Spoofing",
64
+ "CWE-294": "Authentication Bypass by Capture-replay",
65
+ "CWE-295": "Improper Certificate Validation",
66
+ "CWE-296": "Improper Following of a Certificate's Chain of Trust",
67
+ "CWE-297": "Improper Validation of Certificate with Host Mismatch",
68
+ "CWE-298": "Improper Validation of Certificate Expiration",
69
+ "CWE-299": "Improper Check for Certificate Revocation",
70
+ "CWE-300": "Channel Accessible by Non-Endpoint (MITM)",
71
+ "CWE-303": "Incorrect Implementation of Authentication Algorithm",
72
+ "CWE-305": "Authentication Bypass by Primary Weakness",
73
+ "CWE-306": "Missing Authentication for Critical Function",
74
+ "CWE-307": "Improper Restriction of Excessive Authentication Attempts",
75
+ "CWE-311": "Missing Encryption of Sensitive Data",
76
+ "CWE-312": "Cleartext Storage of Sensitive Information",
77
+ "CWE-319": "Cleartext Transmission of Sensitive Information",
78
+ "CWE-320": "Key Management Errors",
79
+ "CWE-321": "Use of Hard-coded Cryptographic Key",
80
+ "CWE-326": "Inadequate Encryption Strength",
81
+ "CWE-327": "Use of a Broken or Risky Cryptographic Algorithm",
82
+ "CWE-328": "Use of Weak Hash",
83
+ "CWE-329": "Generation of Predictable IV with CBC Mode",
84
+ "CWE-330": "Use of Insufficiently Random Values",
85
+ "CWE-331": "Insufficient Entropy",
86
+ "CWE-335": "Incorrect Usage of Seeds in PRNG",
87
+ "CWE-338": "Use of Cryptographically Weak PRNG",
88
+ "CWE-345": "Insufficient Verification of Data Authenticity",
89
+ "CWE-346": "Origin Validation Error",
90
+ "CWE-347": "Improper Verification of Cryptographic Signature",
91
+ "CWE-352": "Cross-Site Request Forgery (CSRF)",
92
+ "CWE-354": "Improper Validation of Integrity Check Value",
93
+ "CWE-358": "Improperly Implemented Security Check for Standard",
94
+ "CWE-362": "Race Condition",
95
+ "CWE-367": "TOCTOU Race Condition",
96
+ "CWE-369": "Divide By Zero",
97
+ "CWE-377": "Insecure Temporary File",
98
+ "CWE-378": "Creation of Temporary File With Insecure Permissions",
99
+ "CWE-384": "Session Fixation",
100
+ "CWE-390": "Detection of Error Condition Without Action",
101
+ "CWE-400": "Uncontrolled Resource Consumption (DoS)",
102
+ "CWE-401": "Missing Release of Memory after Effective Lifetime",
103
+ "CWE-402": "Transmission of Private Resources into a New Sphere",
104
+ "CWE-404": "Improper Resource Shutdown or Release",
105
+ "CWE-405": "Asymmetric Resource Consumption (Amplification)",
106
+ "CWE-407": "Inefficient Algorithmic Complexity",
107
+ "CWE-409": "Improper Handling of Highly Compressed Data (Zip Bomb)",
108
+ "CWE-415": "Double Free",
109
+ "CWE-416": "Use After Free",
110
+ "CWE-419": "Unprotected Primary Channel",
111
+ "CWE-425": "Direct Request (Forced Browsing)",
112
+ "CWE-426": "Untrusted Search Path",
113
+ "CWE-427": "Uncontrolled Search Path Element",
114
+ "CWE-428": "Unquoted Search Path or Element",
115
+ "CWE-434": "Unrestricted Upload of File with Dangerous Type",
116
+ "CWE-436": "Interpretation Conflict",
117
+ "CWE-441": "Unintended Proxy or Intermediary (Confused Deputy)",
118
+ "CWE-444": "HTTP Request/Response Smuggling",
119
+ "CWE-451": "UI Misrepresentation of Critical Information",
120
+ "CWE-459": "Incomplete Cleanup",
121
+ "CWE-470": "Unsafe Reflection",
122
+ "CWE-471": "Modification of Assumed-Immutable Data",
123
+ "CWE-476": "NULL Pointer Dereference",
124
+ "CWE-477": "Use of Obsolete Function",
125
+ "CWE-489": "Active Debug Code",
126
+ "CWE-494": "Download of Code Without Integrity Check",
127
+ "CWE-497": "Exposure of Sensitive System Information to an Unauthorized Control Sphere",
128
+ "CWE-502": "Deserialization of Untrusted Data",
129
+ "CWE-521": "Weak Password Requirements",
130
+ "CWE-522": "Insufficiently Protected Credentials",
131
+ "CWE-523": "Unprotected Transport of Credentials",
132
+ "CWE-532": "Insertion of Sensitive Information into Log File",
133
+ "CWE-540": "Inclusion of Sensitive Information in Source Code",
134
+ "CWE-548": "Exposure of Information Through Directory Listing",
135
+ "CWE-552": "Files or Directories Accessible to External Parties",
136
+ "CWE-565": "Reliance on Cookies without Validation and Integrity Checking",
137
+ "CWE-601": "URL Redirection to Untrusted Site (Open Redirect)",
138
+ "CWE-610": "Externally Controlled Reference to a Resource in Another Sphere",
139
+ "CWE-611": "XML External Entity (XXE)",
140
+ "CWE-613": "Insufficient Session Expiration",
141
+ "CWE-617": "Reachable Assertion",
142
+ "CWE-639": "Authorization Bypass Through User-Controlled Key (IDOR)",
143
+ "CWE-640": "Weak Password Recovery Mechanism for Forgotten Password",
144
+ "CWE-641": "Improper Restriction of Names for Files and Other Resources",
145
+ "CWE-643": "XPath Injection",
146
+ "CWE-645": "Overly Restrictive Account Lockout Mechanism",
147
+ "CWE-652": "XQuery Injection",
148
+ "CWE-665": "Improper Initialization",
149
+ "CWE-668": "Exposure of Resource to Wrong Sphere",
150
+ "CWE-670": "Always-Incorrect Control Flow Implementation",
151
+ "CWE-672": "Operation on a Resource after Expiration or Release",
152
+ "CWE-674": "Uncontrolled Recursion",
153
+ "CWE-682": "Incorrect Calculation",
154
+ "CWE-693": "Protection Mechanism Failure",
155
+ "CWE-697": "Incorrect Comparison",
156
+ "CWE-704": "Incorrect Type Conversion or Cast",
157
+ "CWE-732": "Incorrect Permission Assignment for Critical Resource",
158
+ "CWE-749": "Exposed Dangerous Method or Function",
159
+ "CWE-754": "Improper Check for Unusual or Exceptional Conditions",
160
+ "CWE-755": "Improper Handling of Exceptional Conditions",
161
+ "CWE-763": "Release of Invalid Pointer or Reference",
162
+ "CWE-770": "Allocation of Resources Without Limits or Throttling",
163
+ "CWE-772": "Missing Release of Resource after Effective Lifetime",
164
+ "CWE-776": "XML Entity Expansion (XEE / Billion Laughs)",
165
+ "CWE-787": "Out-of-bounds Write",
166
+ "CWE-789": "Memory Allocation with Excessive Size Value",
167
+ "CWE-798": "Use of Hard-coded Credentials",
168
+ "CWE-829": "Inclusion of Functionality from Untrusted Control Sphere",
169
+ "CWE-834": "Excessive Iteration",
170
+ "CWE-835": "Loop with Unreachable Exit Condition (Infinite Loop)",
171
+ "CWE-838": "Inappropriate Encoding for Output Context",
172
+ "CWE-843": "Type Confusion (Access of Resource Using Incompatible Type)",
173
+ "CWE-862": "Missing Authorization",
174
+ "CWE-863": "Incorrect Authorization",
175
+ "CWE-908": "Use of Uninitialized Resource",
176
+ "CWE-913": "Improper Control of Dynamically-Managed Code Resources",
177
+ "CWE-915": "Mass Assignment",
178
+ "CWE-916": "Use of Password Hash With Insufficient Computational Effort",
179
+ "CWE-917": "Expression Language Injection (EL Injection)",
180
+ "CWE-918": "Server-Side Request Forgery (SSRF)",
181
+ "CWE-920": "Improper Restriction of Power Consumption",
182
+ "CWE-921": "Storage of Sensitive Data in a Mechanism without Access Control",
183
+ "CWE-924": "Improper Enforcement of Message Integrity During Transmission in a Communication Channel",
184
+ "CWE-1004": "Sensitive Cookie Without 'HttpOnly' Flag",
185
+ "CWE-1021": "Improper Restriction of Rendered UI Layers (Clickjacking)",
186
+ "CWE-1188": "Insecure Default Initialization of Resource",
187
+ "CWE-1236": "CSV Formula Injection",
188
+ "CWE-1275": "Sensitive Cookie with Improper SameSite Attribute",
189
+ "CWE-1284": "Improper Validation of Specified Quantity in Input",
190
+ "CWE-1321": "Improperly Controlled Modification of Object Prototype (Prototype Pollution)",
191
+ "CWE-1333": "Inefficient Regular Expression Complexity (ReDoS)"
192
+ }
package/fad-checker.js CHANGED
@@ -540,27 +540,30 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
540
540
  }
541
541
 
542
542
  // Split prod vs dev based on the dep's isDev flag (set at collection time
543
- // from Maven scope=test/provided and npm dev/devOptional/optional).
544
- // CPE-filtered matches are excluded from the CLI headline they're surfaced
545
- // in the HTML report's "Likely false positives" appendix instead.
546
- const prodMatches = cveMatches.filter(m => !m.dep?.isDev && !m.cpeFiltered);
547
- const devMatches = cveMatches.filter(m => m.dep?.isDev && !m.cpeFiltered);
548
- const cpeFilteredCount = cveMatches.filter(m => m.cpeFiltered).length;
549
-
550
- const stats = computeStats(prodMatches);
551
- const devStats = computeStats(devMatches);
552
- console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${prodMatches.length})`));
543
+ // from Maven scope=test/provided and npm dev/devOptional/optional). Keep the
544
+ // full per-bucket list (including cpeFiltered) so the HTML report can render
545
+ // its "Likely false positives" appendix — only the CLI headline excludes
546
+ // cpeFiltered to avoid alarming on triaged-out matches.
547
+ const prodMatches = cveMatches.filter(m => !m.dep?.isDev);
548
+ const devMatches = cveMatches.filter(m => m.dep?.isDev);
549
+ const prodActive = prodMatches.filter(m => !m.cpeFiltered);
550
+ const devActive = devMatches.filter(m => !m.cpeFiltered);
551
+ const cpeFilteredCount = (prodMatches.length - prodActive.length) + (devMatches.length - devActive.length);
552
+
553
+ const stats = computeStats(prodActive);
554
+ const devStats = computeStats(devActive);
555
+ console.log(chalk.bold.cyan(`\n 1. CVE Vulnerabilities (production: ${prodActive.length})`));
553
556
  console.log(` critical=${stats.critical} high=${stats.high} medium=${stats.medium} low=${stats.low} unknown=${stats.unknown}`);
554
557
  const depLabel = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
555
- for (const m of prodMatches.slice(0, 20)) {
558
+ for (const m of prodActive.slice(0, 20)) {
556
559
  const sev = (m.cve.severity || "UNKNOWN").padEnd(8);
557
560
  console.log(` ${chalk.red(sev)} ${m.cve.id} ${depLabel(m.dep)}:${m.dep.version}`);
558
561
  }
559
- if (prodMatches.length > 20) console.log(` ... and ${prodMatches.length - 20} more (see report)`);
562
+ if (prodActive.length > 20) console.log(` ... and ${prodActive.length - 20} more (see report)`);
560
563
  if (cpeFilteredCount) console.log(chalk.gray(` (${cpeFilteredCount} likely false positives moved to report appendix)`));
561
564
 
562
- if (devMatches.length) {
563
- console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devMatches.length})`));
565
+ if (devActive.length) {
566
+ console.log(chalk.bold.cyan(`\n 2. CVE in dev dependencies (${devActive.length})`));
564
567
  console.log(` critical=${devStats.critical} high=${devStats.high} medium=${devStats.medium} low=${devStats.low} unknown=${devStats.unknown}`);
565
568
  }
566
569
  if (retireMatches.length) {
package/lib/cve-match.js CHANGED
@@ -166,11 +166,15 @@ function vendorMatchesGroup(vendor, groupId) {
166
166
  const v = vendor.toLowerCase();
167
167
  const g = groupId.toLowerCase();
168
168
  if (g === v) return true;
169
- // org.apache.commons matches vendor "apache" via dot-segment membership.
170
- // Unbounded substring checks (g.includes(v) / v.includes(g)) used to leak
171
- // FPs — e.g. vendor "open" matching org.opensaml.* because "opensaml"
172
- // contains "open". Dot-segment membership is strict enough.
173
- if (g.split(".").includes(v)) return true;
169
+ const segments = g.split(".");
170
+ // Plain dot-segment match (vendor "apache" ⊂ "org.apache.commons").
171
+ if (segments.includes(v)) return true;
172
+ // NVD/CVEProject often record vendor as a legal entity ("qos.ch sarl",
173
+ // "the apache software foundation"). Split on non-alphanumerics and accept
174
+ // the match if any token is a dot-segment of the groupId. Unbounded
175
+ // substring matching is still avoided.
176
+ const vendorTokens = v.split(/[^a-z0-9]+/).filter(Boolean);
177
+ if (vendorTokens.length > 1 && vendorTokens.some(t => segments.includes(t))) return true;
174
178
  return false;
175
179
  }
176
180
 
package/lib/cve-report.js CHANGED
@@ -8,6 +8,21 @@
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
10
 
11
+ // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
12
+ // back to the raw id (with a "—" placeholder name where the row asks for one).
13
+ const CWE_NAMES = (() => {
14
+ try {
15
+ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "data", "cwe-names.json"), "utf8"));
16
+ delete raw._comment;
17
+ return raw;
18
+ } catch { return {}; }
19
+ })();
20
+ function cweName(id) {
21
+ if (!id) return "";
22
+ const key = String(id).toUpperCase();
23
+ return CWE_NAMES[key] || "";
24
+ }
25
+
11
26
  function esc(s) {
12
27
  if (s == null) return "";
13
28
  return String(s)
@@ -84,6 +99,17 @@ td.cwe .cwe-link { color: #6366f1; text-decoration: none; }
84
99
  td.cwe .cwe-link:hover { text-decoration: underline; }
85
100
  td.cwe .cwe-empty { color: #d1d5db; }
86
101
  td.cwe .cwe-more { color: #9ca3af; font-style: italic; font-size: 10px; }
102
+ td.cwe .cwe-name { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 10px; color: #6b7280; line-height: 1.3; margin-top: 1px; max-width: 220px; }
103
+ .cwe-list { list-style: none; padding: 0; margin: 0; }
104
+ .cwe-list li { padding: 3px 0; font-size: 12px; line-height: 1.5; }
105
+ .cwe-list li .cwe-link { font-family: ui-monospace, monospace; color: #4338ca; text-decoration: none; font-weight: 600; }
106
+ .cwe-list li .cwe-link:hover { text-decoration: underline; }
107
+ .cwe-list li .cwe-name-inline { color: #374151; }
108
+ .cwe-list li .cwe-empty { color: #9ca3af; font-style: italic; }
109
+ /* CWE chip in the exec summary */
110
+ .exec-summary .exec-cwe { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; color: #4b5563; background: #eef2ff; border: 1px solid #c7d2fe; padding: 1px 6px; border-radius: 3px; margin-left: 2px; }
111
+ .exec-summary .exec-cwe .exec-cwe-id { font-family: ui-monospace, monospace; font-weight: 600; color: #4338ca; }
112
+ .exec-summary .exec-cwe .exec-cwe-name { color: #4b5563; }
87
113
  td.published { font-family: ui-monospace, monospace; font-size: 11px; color: #6b7280; white-space: nowrap; }
88
114
  tr.cve-row.cpe-fp td { opacity: .55; }
89
115
  .jump-badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
@@ -196,6 +222,14 @@ details.report-subsection details.report-subsection details.report-subsection >
196
222
  .toolbar .btn.primary { background: #4f46e5; color: #fff; border-color: #4f46e5; }
197
223
  .toolbar .btn.primary:hover { background: #4338ca; }
198
224
  .toolbar .toolbar-label { font-size: 11px; color: #6b7280; }
225
+ /* Table copy controls — one toolbar per table */
226
+ .table-wrap { margin: 8px 0 24px; }
227
+ .table-wrap > table { margin: 0; }
228
+ .table-actions { display: flex; justify-content: flex-end; gap: 6px; margin-bottom: 4px; }
229
+ .btn-copy { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; font-size: 11px; background: #fff; border: 1px solid #d1d5db; border-radius: 4px; cursor: pointer; color: #4b5563; font-family: inherit; transition: all .15s ease; }
230
+ .btn-copy:hover { background: #eef2ff; border-color: #6366f1; color: #4338ca; }
231
+ .btn-copy.flashing-ok { background: #10b981; color: #fff; border-color: #10b981; }
232
+ .btn-copy.flashing-err { background: #dc2626; color: #fff; border-color: #dc2626; }
199
233
  .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
234
  .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
235
  .fix-recipe .recipe-title { font-weight: 600; margin-bottom: 2px; }
@@ -424,7 +458,12 @@ function renderDetailPanel(m) {
424
458
  sections.push(section("Description", `<div class="full-desc">${esc(cve.description)}</div>`));
425
459
  }
426
460
 
427
- // 2. Metadata
461
+ // 2. Weaknesses (CWE) — id + human name, links to mitre.org
462
+ if (Array.isArray(cve.cwes) && cve.cwes.length) {
463
+ sections.push(section("Weaknesses (CWE)", formatCwesVerbose(cve.cwes), { count: cve.cwes.length }));
464
+ }
465
+
466
+ // 3. Metadata
428
467
  const kv = [];
429
468
  if (cve.cvssVector) kv.push(`<div><label>${esc(cve.cvssVersion || "CVSS")}</label><span class="cvss-vector">${esc(cve.cvssVector)}</span></div>`);
430
469
  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>`);
@@ -502,7 +541,29 @@ function formatCwes(cwes) {
502
541
  if (!Array.isArray(cwes) || !cwes.length) return `<span class="cwe-empty">—</span>`;
503
542
  const top = cwes.slice(0, 2);
504
543
  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;
544
+ return top.map(c => {
545
+ const id = String(c);
546
+ const name = cweName(id);
547
+ const num = id.replace(/^CWE-/i, "");
548
+ const link = `<a href="https://cwe.mitre.org/data/definitions/${esc(num)}.html" target="_blank" rel="noopener" class="cwe-link"${name ? ` title="${esc(id)}: ${esc(name)}"` : ""}>${esc(id)}</a>`;
549
+ // Show the human name inline beneath the id (compact, italic) when we know it
550
+ return name ? `${link}<div class="cwe-name">${esc(name)}</div>` : link;
551
+ }).join(" ") + more;
552
+ }
553
+
554
+ // Verbose CWE list for the detail panel: id link + full human name on the
555
+ // same line, no truncation. Used in renderDetailPanel.
556
+ function formatCwesVerbose(cwes) {
557
+ if (!Array.isArray(cwes) || !cwes.length) return "";
558
+ return `<ul class="cwe-list">${cwes.map(c => {
559
+ const id = String(c);
560
+ const name = cweName(id);
561
+ const num = id.replace(/^CWE-/i, "");
562
+ return `<li>
563
+ <a href="https://cwe.mitre.org/data/definitions/${esc(num)}.html" target="_blank" rel="noopener" class="cwe-link">${esc(id)}</a>
564
+ ${name ? `<span class="cwe-name-inline">— ${esc(name)}</span>` : `<span class="cwe-empty">(unknown weakness)</span>`}
565
+ </li>`;
566
+ }).join("")}</ul>`;
506
567
  }
507
568
 
508
569
  function renderCveRow(m, opts = {}) {
@@ -1125,13 +1186,22 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
1125
1186
 
1126
1187
  const topPreview = (topCriticalMatches || []).length ? `<div class="exec-top">
1127
1188
  <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>
1189
+ <ul class="exec-top-list">${topCriticalMatches.map(m => {
1190
+ // First known-CWE on the match gets surfaced in the summary so the
1191
+ // reader has a one-line answer to "what kind of weakness is this?"
1192
+ const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
1193
+ const primary = cwes.find(c => cweName(c)) || cwes[0];
1194
+ const primaryName = primary ? cweName(primary) : "";
1195
+ const cweChip = primary ? `<span class="exec-cwe" title="${esc(primary)}${primaryName ? ": " + esc(primaryName) : ""}"><span class="exec-cwe-id">${esc(primary)}</span>${primaryName ? `<span class="exec-cwe-name">${esc(primaryName)}</span>` : ""}</span>` : "";
1196
+ return `<li>
1197
+ ${badge((m.cve.severity || "HIGH").toUpperCase())}
1198
+ <a class="exec-cve-link" href="#ch${m.dep?.scope === "vendored" ? "2" : "1"}">${esc(m.cve.id)}</a>
1199
+ <code>${esc(depDisplayName(m.dep))}</code>
1200
+ <span class="exec-ver">${esc(m.dep.version || "?")}</span>
1201
+ ${m.cve.fixVersion ? `<span class="exec-fix">→ ${esc(m.cve.fixVersion)}</span>` : ""}
1202
+ ${cweChip}
1203
+ </li>`;
1204
+ }).join("")}</ul>
1135
1205
  </div>` : "";
1136
1206
 
1137
1207
  return `<div class="exec-summary exec-${esc(level.toLowerCase())}">
@@ -1186,7 +1256,7 @@ function renderWarningItems(items, itemLimit) {
1186
1256
  return `<ul class="warn-items">${lis}${overflow}</ul>`;
1187
1257
  }
1188
1258
 
1189
- function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings }) {
1259
+ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
1190
1260
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1191
1261
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1192
1262
  cveMatches = mergeMatches(cveMatches || []);
@@ -1244,7 +1314,7 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1244
1314
  fpTotal: prodMatchesFP.length + devMatchesFP.length,
1245
1315
  });
1246
1316
 
1247
- return `
1317
+ const body = `
1248
1318
  <header class="report-header">
1249
1319
  <h1>FAD-Checker Report</h1>
1250
1320
  <div class="report-subtitle">Multi-ecosystem dependency security audit</div>
@@ -1268,6 +1338,7 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
1268
1338
  ${majorSection(`7. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch7" })}
1269
1339
  ${(prodMatchesFP.length + devMatchesFP.length) ? majorSection(`8. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length})`, fpContent, { open: false, id: "ch8" }) : ""}
1270
1340
  `;
1341
+ return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1271
1342
  }
1272
1343
 
1273
1344
  /**
@@ -1346,6 +1417,127 @@ function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
1346
1417
  return out.join("") || `<div class="empty">No CVE matches.</div>`;
1347
1418
  }
1348
1419
 
1420
+ /**
1421
+ * Wrap every <table>…</table> in the body HTML with a `.table-wrap` div that
1422
+ * carries a small toolbar with a "Copy table" button. The button is wired up
1423
+ * by COPY_SCRIPT below — it copies the table as styled HTML (Word-compatible)
1424
+ * with a TSV fallback for text-only paste targets.
1425
+ *
1426
+ * Skipped for the Word output (wrapTables=false in buildBody) since Word can't
1427
+ * run JS and the bare table is what we want there.
1428
+ */
1429
+ function wrapTablesWithCopyButtons(html) {
1430
+ return html.replace(/(<table\b[^>]*>[\s\S]*?<\/table>)/g, (match) => {
1431
+ return `<div class="table-wrap"><div class="table-actions"><button class="btn-copy" type="button" title="Copy this table — paste it into Word with formatting preserved">📋 Copy table</button></div>${match}</div>`;
1432
+ });
1433
+ }
1434
+
1435
+ const COPY_SCRIPT = `
1436
+ <script>
1437
+ (function(){
1438
+ // Inline styles applied to clipboard-bound table so it survives pasting into
1439
+ // Word with reasonable formatting (Word ignores most external CSS classes,
1440
+ // but honours inline style="…" attributes).
1441
+ function inlineStylesForWord(table){
1442
+ var clone = table.cloneNode(true);
1443
+ // Drop expandable detail rows (they would paste as huge empty cells)
1444
+ clone.querySelectorAll('tr.detail-row').forEach(function(r){ r.remove(); });
1445
+ // Strip our interactive classes so Word doesn't try to interpret them
1446
+ clone.querySelectorAll('tr.cve-row').forEach(function(r){
1447
+ r.classList.remove('cve-row');
1448
+ r.classList.remove('open');
1449
+ r.classList.remove('cpe-fp');
1450
+ });
1451
+ clone.setAttribute('border', '1');
1452
+ clone.setAttribute('cellspacing', '0');
1453
+ clone.setAttribute('cellpadding', '6');
1454
+ clone.setAttribute('width', '100%');
1455
+ clone.setAttribute('style', 'width:100%; border-collapse:collapse; font-family:Calibri,Arial,sans-serif; font-size:10pt; color:#1f2937;');
1456
+ clone.querySelectorAll('th').forEach(function(th){
1457
+ th.setAttribute('style', 'background:#f3f4f6; padding:6px 8px; border:1px solid #d1d5db; text-align:left; font-weight:bold; font-size:9pt; text-transform:uppercase; letter-spacing:.5px; color:#4b5563;');
1458
+ });
1459
+ clone.querySelectorAll('td').forEach(function(td){
1460
+ var prev = td.getAttribute('style') || '';
1461
+ td.setAttribute('style', 'padding:6px 8px; border:1px solid #e5e7eb; vertical-align:top; ' + prev);
1462
+ });
1463
+ // Replace severity badges with plain coloured text so they survive paste
1464
+ clone.querySelectorAll('span[style*="border-radius"]').forEach(function(s){
1465
+ var m = (s.getAttribute('style') || '').match(/background:\\s*(#[0-9a-fA-F]{3,6})/);
1466
+ var bg = m ? m[1] : '#666';
1467
+ s.setAttribute('style', 'background:' + bg + '; color:#fff; padding:2px 6px; font-weight:bold; font-size:9pt;');
1468
+ });
1469
+ return clone;
1470
+ }
1471
+ function wordWrapper(tableHtml){
1472
+ return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"></head><body>' + tableHtml + '</body></html>';
1473
+ }
1474
+ function tableToTsv(table){
1475
+ var rows = Array.prototype.filter.call(table.querySelectorAll('tr'), function(r){ return !r.classList.contains('detail-row'); });
1476
+ return rows.map(function(r){
1477
+ return Array.prototype.map.call(r.querySelectorAll('th, td'), function(c){
1478
+ return (c.innerText || c.textContent || '').replace(/[\\t\\r\\n]+/g, ' ').trim();
1479
+ }).join('\\t');
1480
+ }).join('\\n');
1481
+ }
1482
+ function flash(btn, cls, msg){
1483
+ var old = btn.textContent;
1484
+ btn.textContent = msg;
1485
+ btn.classList.add(cls);
1486
+ setTimeout(function(){
1487
+ btn.textContent = old;
1488
+ btn.classList.remove(cls);
1489
+ }, 1500);
1490
+ }
1491
+ async function copyTable(table, btn){
1492
+ var clone = inlineStylesForWord(table);
1493
+ var html = wordWrapper(clone.outerHTML);
1494
+ var tsv = tableToTsv(table);
1495
+ // Modern path: ClipboardItem with text/html + text/plain
1496
+ try {
1497
+ if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
1498
+ await navigator.clipboard.write([
1499
+ new ClipboardItem({
1500
+ 'text/html': new Blob([html], { type: 'text/html' }),
1501
+ 'text/plain': new Blob([tsv], { type: 'text/plain' })
1502
+ })
1503
+ ]);
1504
+ flash(btn, 'flashing-ok', '✓ Copied!');
1505
+ return;
1506
+ }
1507
+ } catch(e) { /* fall through to legacy path */ }
1508
+ // Legacy path: select clone, execCommand('copy') — gets the HTML
1509
+ try {
1510
+ var temp = document.createElement('div');
1511
+ temp.setAttribute('contenteditable', 'true');
1512
+ temp.style.position = 'fixed';
1513
+ temp.style.left = '-9999px';
1514
+ temp.style.top = '0';
1515
+ temp.innerHTML = clone.outerHTML;
1516
+ document.body.appendChild(temp);
1517
+ var range = document.createRange();
1518
+ range.selectNodeContents(temp);
1519
+ var sel = window.getSelection();
1520
+ sel.removeAllRanges();
1521
+ sel.addRange(range);
1522
+ var ok = document.execCommand('copy');
1523
+ sel.removeAllRanges();
1524
+ document.body.removeChild(temp);
1525
+ flash(btn, ok ? 'flashing-ok' : 'flashing-err', ok ? '✓ Copied!' : '✗ Copy failed');
1526
+ } catch(e) {
1527
+ flash(btn, 'flashing-err', '✗ Copy failed');
1528
+ }
1529
+ }
1530
+ document.querySelectorAll('.btn-copy').forEach(function(btn){
1531
+ btn.addEventListener('click', function(e){
1532
+ e.stopPropagation();
1533
+ var wrap = btn.closest('.table-wrap');
1534
+ var table = wrap && wrap.querySelector('table');
1535
+ if (table) copyTable(table, btn);
1536
+ });
1537
+ });
1538
+ })();
1539
+ </script>`;
1540
+
1349
1541
  const TOGGLE_SCRIPT = `
1350
1542
  <script>
1351
1543
  (function(){
@@ -1402,13 +1594,128 @@ const TOGGLE_SCRIPT = `
1402
1594
  function generateHtmlReport(payload) {
1403
1595
  return `<!doctype html>
1404
1596
  <html><head><meta charset="utf-8"><title>FAD-Checker Report</title><style>${CSS}</style></head>
1405
- <body>${buildBody(payload)}${TOGGLE_SCRIPT}</body></html>`;
1597
+ <body>${buildBody(payload)}${TOGGLE_SCRIPT}${COPY_SCRIPT}</body></html>`;
1598
+ }
1599
+
1600
+ // Word-specific CSS tweaks layered on top of the shared CSS:
1601
+ // - landscape A4 with tight margins to maximise usable column width
1602
+ // - thead repeats on every page break, rows stay together
1603
+ // - sticky/transitions/box-shadow stripped (Word renders them oddly)
1604
+ // - chevron pseudo-elements hidden (Word can't toggle them anyway)
1605
+ // - tables get an explicit 100% width attribute so Word fits-to-page
1606
+ // - smaller font / padding inside tables to cram more columns into the page
1607
+ //
1608
+ // Dimensions are given in cm (A4 landscape = 29.7×21cm). The `mso-page-orientation`
1609
+ // property is the actually-honoured-by-Word landscape switch; without it Word
1610
+ // often defaults to portrait even when @page asks for landscape.
1611
+ const WORD_CSS = `
1612
+ @page WordSection1 {
1613
+ size: 29.7cm 21cm;
1614
+ mso-page-orientation: landscape;
1615
+ margin: 1cm 1cm 1cm 1cm;
1616
+ mso-header-margin: .5cm;
1617
+ mso-footer-margin: .5cm;
1618
+ mso-paper-source: 0;
1619
+ }
1620
+ div.WordSection1 { page: WordSection1; }
1621
+ body { margin: 0; background: #fff; }
1622
+ /* Tables: hard-fit to page width. table-layout:fixed forces Word to honour
1623
+ width:100% even when content would naturally be wider. */
1624
+ table { mso-table-lspace: 0; mso-table-rspace: 0; width: 100% !important; max-width: 100% !important; table-layout: fixed; font-size: 9pt; }
1625
+ th, td { padding: 4px 6px !important; font-size: 9pt; word-break: break-word; overflow-wrap: anywhere; }
1626
+ th { font-size: 8pt; }
1627
+ tr { page-break-inside: avoid; }
1628
+ thead { display: table-header-group; }
1629
+ h1, h2, h3, summary { page-break-after: avoid; }
1630
+ details.report-section, details.report-subsection { page-break-inside: auto; }
1631
+ /* Strip interactive affordances Word can't honour */
1632
+ .toolbar, .toc { display: none !important; }
1633
+ details > summary { cursor: default; list-style: none; }
1634
+ details.report-section > summary h2::before,
1635
+ details.report-subsection > summary h3::before { content: "" !important; display: none !important; }
1636
+ tr.cve-row td:first-child::before { content: "" !important; display: none !important; }
1637
+ tr.cve-row, tr.cve-row.open { background: #fff !important; cursor: default; }
1638
+ /* Detail rows always shown (no JS toggling in Word) */
1639
+ tr.detail-row { display: table-row !important; }
1640
+ /* Remove shadows / sticky / hover effects that confuse Word */
1641
+ .report-header, .exec-summary, .summary .card, .warnings { box-shadow: none !important; }
1642
+ .fix-recipe code { white-space: pre-wrap; word-break: break-word; overflow-x: visible; font-size: 8pt; }
1643
+ /* Long URLs and coordinates: wrap instead of overflow */
1644
+ td.cve, td.dep, .via, .ref-list li, .defined-in code { word-break: break-word; overflow-wrap: anywhere; }
1645
+ /* Tighter colour palette so Word prints cleanly */
1646
+ tr:nth-child(even) { background: #fafafa; }
1647
+ /* Detail panel: shrink so expanded CVE blocks don't dominate the page */
1648
+ .detail-panel { padding: 8px 12px; font-size: 9pt; }
1649
+ .detail-panel .full-desc { font-size: 9pt; line-height: 1.4; }
1650
+ `;
1651
+
1652
+ // Section properties Word reads from a conditional comment. This is the
1653
+ // actually-reliable way to force landscape — @page rules in <style> are
1654
+ // inconsistently honoured across Word versions, but `<w:sectPr>` is.
1655
+ // Page size in twips: A4 landscape = 16838 × 11906 (1cm ≈ 567 twips).
1656
+ const WORD_SECTION_PR = `<!--[if gte mso 9]>
1657
+ <xml>
1658
+ <w:WordDocument>
1659
+ <w:View>Print</w:View>
1660
+ <w:Zoom>100</w:Zoom>
1661
+ <w:DoNotOptimizeForBrowser/>
1662
+ </w:WordDocument>
1663
+ </xml>
1664
+ <![endif]-->
1665
+ <!--[if gte mso 9]>
1666
+ <style>
1667
+ @page WordSection1 {
1668
+ size: 16838twips 11906twips;
1669
+ mso-page-orientation: landscape;
1670
+ margin: 567twips 567twips 567twips 567twips;
1671
+ }
1672
+ </style>
1673
+ <![endif]-->`;
1674
+
1675
+ // Column widths (in %) per known table shape. table-layout:fixed needs these
1676
+ // so Word can lay out the columns deterministically instead of inflating wide
1677
+ // columns based on natural content width.
1678
+ const TABLE_COLGROUPS = {
1679
+ // 8-col CVE table (header starts with <th>Severity</th><th>CVE ID</th>…)
1680
+ 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>/,
1681
+ widths8: [7, 10, 15, 10, 7, 26, 11, 14],
1682
+ widths9: [6, 9, 13, 9, 6, 22, 10, 13, 12] },
1683
+ // 5-col EOL table
1684
+ eol: { match: /<thead><tr><th>Product<\/th><th>Dependency<\/th><th>EOL date<\/th><th>Latest<\/th><th>Notes<\/th><\/tr><\/thead>/,
1685
+ widths: [15, 25, 12, 12, 36] },
1686
+ // 4-col Obsolete table
1687
+ obsolete: { match: /<thead><tr><th>Severity<\/th><th>Obsolete<\/th><th>Replacement<\/th><th>Why<\/th><\/tr><\/thead>/,
1688
+ widths: [10, 25, 25, 40] },
1689
+ // 5-col Outdated table
1690
+ outdated: { match: /<thead><tr><th>Jump<\/th><th>Dependency<\/th><th>Current<\/th><th>Latest<\/th><th>Released<\/th><\/tr><\/thead>/,
1691
+ widths: [12, 38, 18, 18, 14] },
1692
+ // 6-col Retire.js table
1693
+ retire: { match: /<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>/,
1694
+ widths: [8, 18, 24, 14, 12, 24] },
1695
+ };
1696
+
1697
+ function colgroupHtml(widths) {
1698
+ return `<colgroup>${widths.map(w => `<col style="width:${w}%">`).join("")}</colgroup>`;
1699
+ }
1700
+
1701
+ // Inject a <colgroup> right after each table opening tag so Word respects the
1702
+ // width split. Detects which schema the table follows by its <thead>.
1703
+ function injectColgroups(html) {
1704
+ // CVE 8-col / 9-col (action variant)
1705
+ html = html.replace(new RegExp(`(<table[^>]*>)\\s*(${TABLE_COLGROUPS.cve.match.source})`, "g"), (m, tableTag, thead) => {
1706
+ const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths9 : TABLE_COLGROUPS.cve.widths8;
1707
+ return `${tableTag}${colgroupHtml(widths)}${thead}`;
1708
+ });
1709
+ for (const key of ["eol", "obsolete", "outdated", "retire"]) {
1710
+ const def = TABLE_COLGROUPS[key];
1711
+ html = html.replace(new RegExp(`(<table[^>]*>)\\s*(${def.match.source})`, "g"),
1712
+ (m, tableTag, thead) => `${tableTag}${colgroupHtml(def.widths)}${thead}`);
1713
+ }
1714
+ return html;
1406
1715
  }
1407
1716
 
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
1717
  function generateWordReport(payload) {
1411
- let body = buildBody(payload);
1718
+ let body = buildBody({ ...payload, wrapTables: false });
1412
1719
  // Force-open every <details> and every cve-row/detail-row in the static markup
1413
1720
  body = body.replace(/<details ([^>]*?)>/g, (m, attrs) => {
1414
1721
  if (attrs.includes(" open")) return m;
@@ -1418,6 +1725,10 @@ function generateWordReport(payload) {
1418
1725
  body = body.replace(/<tr class="cve-row">/g, '<tr class="cve-row open">');
1419
1726
  // Drop the interactive toolbar (no buttons useful in a print/Word view)
1420
1727
  body = body.replace(/<div class="toolbar">[\s\S]*?<\/div>/, "");
1728
+ // Annotate every <table> with explicit width="100%" so Word resizes to page width
1729
+ body = body.replace(/<table>/g, '<table border="1" cellspacing="0" cellpadding="4" width="100%" style="width:100%;border-collapse:collapse;table-layout:fixed">');
1730
+ // Insert <colgroup> with %-widths so Word can split fixed-layout columns
1731
+ body = injectColgroups(body);
1421
1732
  return `<html xmlns:o="urn:schemas-microsoft-com:office:office"
1422
1733
  xmlns:w="urn:schemas-microsoft-com:office:word"
1423
1734
  xmlns="http://www.w3.org/TR/REC-html40">
@@ -1427,12 +1738,10 @@ function generateWordReport(payload) {
1427
1738
  <meta name="ProgId" content="Word.Document">
1428
1739
  <meta name="Generator" content="Microsoft Word 15">
1429
1740
  <title>FAD-Checker 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>
1741
+ ${WORD_SECTION_PR}
1742
+ <style>${CSS}${WORD_CSS}</style>
1434
1743
  </head>
1435
- <body>${body}</body>
1744
+ <body><div class="WordSection1">${body}</div></body>
1436
1745
  </html>`;
1437
1746
  }
1438
1747
 
package/lib/nvd.js CHANGED
@@ -18,6 +18,10 @@ const { getNvdApiKey } = require("./config");
18
18
 
19
19
  const NVD_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "nvd-cache");
20
20
  const NVD_CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
21
+ // Bump whenever `extractFromNvdRecord` adds a new field — pre-bump cache
22
+ // entries are treated as cache-miss so we re-fetch and pick up the new field.
23
+ // 2: added `cwes` (Common Weakness Enumeration list).
24
+ const NVD_CACHE_SCHEMA = 2;
21
25
  const NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0";
22
26
 
23
27
  function getRateDelay() {
@@ -43,6 +47,8 @@ function readCache(cveId) {
43
47
  if (!fs.existsSync(p)) return null;
44
48
  try {
45
49
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
50
+ // Schema mismatch → force re-fetch so newly-added fields (e.g. cwes) get populated.
51
+ if ((data._schema || 1) < NVD_CACHE_SCHEMA) return null;
46
52
  if (Date.now() - data._fetchedAt < NVD_CACHE_TTL_MS) return data.body;
47
53
  } catch { /* ignore */ }
48
54
  return null;
@@ -50,7 +56,7 @@ function readCache(cveId) {
50
56
 
51
57
  function writeCache(cveId, body) {
52
58
  fs.mkdirSync(NVD_CACHE_DIR, { recursive: true });
53
- fs.writeFileSync(cachePath(cveId), JSON.stringify({ _fetchedAt: Date.now(), body }));
59
+ fs.writeFileSync(cachePath(cveId), JSON.stringify({ _schema: NVD_CACHE_SCHEMA, _fetchedAt: Date.now(), body }));
54
60
  }
55
61
 
56
62
  function bestMetric(metrics) {
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Fucking Autonomous Dependency Checker — multi-ecosystem CVE / EOL / outdated / vendored-JS scanner for Maven, npm and Yarn monorepos. Self-contained HTML + Word report.",
5
5
  "license": "MIT",
6
- "author": "N8tz <n8tz.js@gmail.com>",
6
+ "author": "pp9Ping <pp9Ping@gmail.com>",
7
7
  "maintainers": [
8
- "Nathan Braun <n8tz.js@gmail.com>"
8
+ "pp9Ping <pp9Ping@gmail.com>"
9
9
  ],
10
10
  "contributors": [
11
- "N8tz (https://github.com/N8tz)"
11
+ "pp9Ping <pp9Ping@gmail.com>"
12
12
  ],
13
13
  "bin": {
14
14
  "fad-checker": "fad-checker.js",
@@ -61,6 +61,19 @@ test("vendorMatchesGroup rejects substring leaks (H2)", () => {
61
61
  assert.equal(vendorMatchesGroup("springframework", "org.springframework.boot"), true);
62
62
  });
63
63
 
64
+ test("vendorMatchesGroup tokenizes multi-word legal-entity vendors", () => {
65
+ // NVD records the logback vendor as "qos.ch sarl" — a legal entity name.
66
+ // Neither equality nor plain dot-segment catches it, but tokenization on
67
+ // non-alphanumerics finds "qos"/"ch" as group segments and confirms the
68
+ // match. Without this, real logback CVEs landed in tier "possible".
69
+ assert.equal(vendorMatchesGroup("qos.ch sarl", "ch.qos.logback"), true);
70
+ assert.equal(vendorMatchesGroup("the apache software foundation", "org.apache.commons"), true);
71
+ // A single-token vendor must NOT be tokenized into substrings — that
72
+ // would re-introduce the H2 leak (e.g. "open" → ["open"] alone never
73
+ // matches arbitrary "org.opensaml.*").
74
+ assert.equal(vendorMatchesGroup("open", "org.opensaml.core"), false);
75
+ });
76
+
64
77
  test("matchDepsAgainstCves hides 'possible' tier by default (H3)", () => {
65
78
  // product matches `log4j-core` but vendor `acme` doesn't match groupId.
66
79
  // Without opts.includePossibleTier we expect zero matches.
@@ -60,6 +60,98 @@ test("generateWordReport contains Word XML namespaces", () => {
60
60
  assert.ok(doc.includes("Word.Document"));
61
61
  });
62
62
 
63
+ test("generateHtmlReport wraps tables with copy buttons", () => {
64
+ const html = generateHtmlReport({
65
+ cveMatches: sampleMatches,
66
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
67
+ projectInfo,
68
+ });
69
+ // Wrapper div + copy button injected around every table
70
+ assert.ok(html.includes('class="table-wrap"'), "should wrap tables");
71
+ assert.ok(html.includes('class="btn-copy"'), "should have copy buttons");
72
+ assert.ok(html.includes("📋 Copy table"), "button label");
73
+ // Copy script wired up
74
+ assert.ok(html.includes("navigator.clipboard"), "should include clipboard script");
75
+ assert.ok(html.includes("inlineStylesForWord"), "should inline styles for Word paste");
76
+ });
77
+
78
+ test("generateWordReport does NOT wrap tables (no copy buttons in .doc)", () => {
79
+ const doc = generateWordReport({
80
+ cveMatches: sampleMatches,
81
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
82
+ projectInfo,
83
+ });
84
+ assert.ok(!doc.includes('class="table-wrap"'), "no wrapper in Word output");
85
+ assert.ok(!doc.includes('class="btn-copy"'), "no copy buttons in Word output");
86
+ });
87
+
88
+ test("CWE human names are shown in the CVE table and detail panel", () => {
89
+ const html = generateHtmlReport({
90
+ cveMatches: sampleMatches.map(m => ({
91
+ ...m,
92
+ cve: { ...m.cve, cwes: ["CWE-502", "CWE-917"] },
93
+ })),
94
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
95
+ projectInfo,
96
+ });
97
+ // Detail panel: full name inline
98
+ assert.ok(html.includes("Deserialization of Untrusted Data"), "CWE-502 name in panel");
99
+ assert.ok(html.includes("Expression Language Injection"), "CWE-917 name in panel");
100
+ // Tooltip on the column link
101
+ assert.ok(html.includes('title="CWE-502: Deserialization of Untrusted Data"'), "tooltip");
102
+ // Weaknesses section in detail panel
103
+ assert.ok(html.includes("Weaknesses (CWE)"), "weaknesses section heading");
104
+ assert.ok(html.includes('class="cwe-list"'), "CWE list rendered");
105
+ });
106
+
107
+ test("Executive summary surfaces primary CWE name on each preview row", () => {
108
+ const html = generateHtmlReport({
109
+ cveMatches: [{
110
+ dep: { groupId: "org.apache.logging.log4j", artifactId: "log4j-core", version: "2.14.0", scope: "compile" },
111
+ cve: { id: "CVE-2021-44228", severity: "CRITICAL", score: 10, description: "Log4Shell", fixVersion: "2.15.0", cwes: ["CWE-502", "CWE-917"] },
112
+ confidence: "exact",
113
+ }],
114
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
115
+ projectInfo,
116
+ });
117
+ assert.ok(html.includes('class="exec-cwe"'), "exec-cwe chip in summary");
118
+ assert.ok(html.includes('class="exec-cwe-id">CWE-502'), "id in chip");
119
+ assert.ok(html.includes("Deserialization of Untrusted Data"), "human name in chip");
120
+ });
121
+
122
+ test("Unknown CWE falls back to id-only display", () => {
123
+ const html = generateHtmlReport({
124
+ cveMatches: [{
125
+ dep: { groupId: "x", artifactId: "y", version: "1", scope: "compile" },
126
+ cve: { id: "CVE-2099-9999", severity: "HIGH", description: "n/a", cwes: ["CWE-99999"] },
127
+ }],
128
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
129
+ projectInfo,
130
+ });
131
+ assert.ok(html.includes("CWE-99999"), "unknown CWE id present");
132
+ assert.ok(html.includes("(unknown weakness)"), "fallback label for unknown CWE in detail panel");
133
+ });
134
+
135
+ test("generateWordReport applies width:100% to every table for Word page-fit", () => {
136
+ const doc = generateWordReport({
137
+ cveMatches: sampleMatches,
138
+ eolResults: [], obsoleteResults: [], outdatedResults: [],
139
+ projectInfo,
140
+ });
141
+ // Every <table> rewritten with width="100%" + inline style (incl. fixed table-layout)
142
+ assert.ok(doc.includes('width="100%"'), "tables get width=100% attribute");
143
+ assert.ok(doc.includes("table-layout:fixed"), "tables get fixed layout");
144
+ // Word section + landscape page setup
145
+ assert.ok(doc.includes('class="WordSection1"'), "WordSection1 wrapper");
146
+ assert.ok(doc.includes("mso-page-orientation: landscape"), "landscape declared via mso-* property");
147
+ assert.ok(doc.includes("29.7cm 21cm"), "explicit A4 landscape dimensions");
148
+ // MSO conditional comment block with twips-based <w:sectPr> equivalent
149
+ assert.ok(doc.includes("w:WordDocument"), "Word document XML block");
150
+ assert.ok(doc.includes("16838twips"), "twips-based page size in mso conditional");
151
+ // Colgroups injected so fixed-layout tables have deterministic column widths
152
+ assert.ok(doc.includes('<colgroup>'), "colgroup injected on tables");
153
+ });
154
+
63
155
  test("writeReports writes both files to disk", async () => {
64
156
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-checker-rep-"));
65
157
  const r = await writeReports({
@@ -1,343 +0,0 @@
1
- # fad-checker — Analyse critique complète
2
-
3
- **Date :** 2026-05-22
4
- **Périmètre :** intégralité du code (`fad-checker.js` + `lib/**/*.js` + `data/*.json` + `test/`)
5
- **Méthode :** 4 audits parallèles à effort xhigh (reuse / quality / efficiency / **faux positifs**)
6
- **Verdict global :** architecture saine, mais **4 chemins fail-open en cascade dans le matcher CVE** produisent un taux de faux positifs estimé entre **20 % et 60 %** selon le niveau de confiance retenu. À traiter en priorité avant toute exploitation en production.
7
-
8
- ---
9
-
10
- ## TL;DR — Risque de faux positifs
11
-
12
- | Tier de confiance | Taux de FP estimé sur ~500 deps | Recommandation |
13
- |---|---|---|
14
- | `exact` uniquement | 5–10 % | Utilisable (triage léger) |
15
- | `exact` + `probable` | 20–30 % | Triage manuel obligatoire |
16
- | Tous tiers (`exact` + `probable` + `possible`) | **40–60 %** | Inexploitable sans tri humain |
17
-
18
- Quatre fail-open en cascade :
19
-
20
- 1. **H1** — `isVersionAffected` retourne `true` quand le CVE n'a aucune borne de version (`lib/maven-version.js:101-124`).
21
- 2. **H2** — `vendorMatchesGroup` fait du *substring matching* (`g.includes(v) || v.includes(g)`) — `vendor="open"` matche `org.opensaml.*` (`lib/cve-match.js:164-173`).
22
- 3. **H3** — Tier-3 `possible` émis pour toute collision d'`artifactId`, même quand le vendor est totalement étranger (`lib/cve-match.js:191-198`).
23
- 4. **H4** — Le filtre CPE (« primary FP filter ») se contente de **marquer** `cpeFiltered=true`, il ne supprime jamais (`lib/cpe.js:269-272`).
24
-
25
- Ces quatre interagissent de manière pathologique : H3 laisse passer une masse de matches `possible`, H2 en remonte beaucoup en `probable`, H1 valide toutes les versions, et H4 ne filtre plus rien en aval.
26
-
27
- ---
28
-
29
- ## A. Faux positifs (critère #1 demandé)
30
-
31
- ### HIGH
32
-
33
- #### H1 — `isVersionAffected` fail-open quand pas de bornes
34
- **Fichier :** `lib/maven-version.js:101-124`
35
-
36
- ```js
37
- if (spec.version && spec.version !== "0" && spec.version !== "*") { /* lower */ }
38
- if (spec.lessThan) { ... }
39
- if (spec.lessThanOrEqual) { ... }
40
- if (!spec.lessThan && !spec.lessThanOrEqual && spec.version && …) { /* exact */ }
41
- return true; // ← fall-through "affected"
42
- ```
43
-
44
- Empiriquement : `isVersionAffected("2.14", { status: "affected" })` → `true`. CVEProject contient régulièrement des entrées `versions:[{status:"affected"}]` sans bornes (stub mal formé). Combiné à H2, **toute dep qui hit le `byProduct` tier-2 avec un range sparse est flaggée sur toute version**.
45
-
46
- **FP concret :** CVE listant `affected: [{ product: "log4j", versions: [{ status: "affected" }] }]` → flag `log4j-core 2.99.0` (patché) car pas de borne haute, pas d'exact-match, fall-through → `true`.
47
-
48
- **Fix :**
49
- ```js
50
- if (!spec.version && !spec.lessThan && !spec.lessThanOrEqual) return false;
51
- ```
52
-
53
- ---
54
-
55
- #### H2 — `vendorMatchesGroup` substring-match → cross-vendor leak
56
- **Fichier :** `lib/cve-match.js:164-173`
57
-
58
- ```js
59
- if (g.includes(v) || v.includes(g)) return true;
60
- ```
61
-
62
- `vendorMatchesGroup("spring", "foospringbar")` → `true`. NVD/CVEProject contiennent régulièrement des vendors `"a"`, `"oss"`, `"com"`, `"java"`, `"go"`, `"web"`, `"open"`, `"ibm"`, `"net"` — tous présents en sous-chaîne dans des groupIds réels.
63
-
64
- **FP concrets :**
65
- - CVE `vendor="open"` matche `org.opensaml:opensaml-core` (`"opensaml".includes("open")`).
66
- - CVE `vendor="ibm"` matche `com.ibmcloudant:cloudant-client`.
67
- - CVE `vendor="spring"` matche `org.springframework.*` ET `com.foospringbar.*`.
68
-
69
- **Fix :** restreindre à (a) `g === v`, (b) `g.split(".").includes(v)`, (c) `v.includes(g)` seulement si `g.length >= 4`. Drop des branches substring non bornées.
70
-
71
- ---
72
-
73
- #### H3 — Tier-3 `possible` émis avec vendor totalement étranger
74
- **Fichier :** `lib/cve-match.js:191-198`
75
-
76
- ```js
77
- const productMatches = byProduct[dep.artifactId.toLowerCase()] || [];
78
- for (const e of productMatches) {
79
- if (vendorMatchesGroup(e.vendor, dep.groupId)) {
80
- all.push(...matchOne(dep, [e], "probable"));
81
- } else {
82
- all.push(...matchOne(dep, [e], "possible")); // ← émis quand même
83
- }
84
- }
85
- ```
86
-
87
- Toute dep dont l'`artifactId` collide avec un produit non relié produit un match `possible`. Collisions fréquentes : `core`, `common`, `api`, `client`, `utils`, `parser`, `web`. Une CVE avec `product="core"` flagge **toutes** les deps `*-core`.
88
-
89
- **Fix :** drop le tier `possible` ou ne l'afficher que si la refinement CPE (`refineMatchesWithCpe`) l'a confirmé.
90
-
91
- ---
92
-
93
- #### H4 — CPE refinement flagge sans supprimer
94
- **Fichier :** `lib/cpe.js:269-272`
95
-
96
- ```js
97
- if (!affected && rec.configurations.length) {
98
- m.cpeFiltered = true; // ← marqué, jamais supprimé
99
- }
100
- ```
101
-
102
- `cpeFiltered` est un soft tag. Si `lib/cve-report.js` ne strippe pas `cpeFiltered === true` (à vérifier dans le rendu), **les matches prouvés faux positifs restent dans la punch list**.
103
-
104
- De plus, le skip `if (!rec.configurations.length && !rec.cpes.length) continue;` (`cpe.js:266`) est fail-open : une CVE sans configurations NVD (CVE récente ou réservée) est laissée au tier que le matcher lui a donné.
105
-
106
- **Fix :** (a) filtrer par défaut `cpeFiltered=true` avec flag `--show-filtered`, ou (b) section séparée « likely false positive » dans le report.
107
-
108
- ---
109
-
110
- #### H5 — Le qualificateur CPE `update` (RC, alpha) est ignoré
111
- **Fichier :** `lib/cpe.js:81-100` (`matchVersionRange`)
112
-
113
- Le champ index 6 du CPE 2.3 (`update`) est parsé mais jamais comparé. NVD liste fréquemment les pré-releases via le champ update (`cpe:2.3:a:vendor:product:1.0.0:rc1:*…`). Le hard-pin compare littéralement la `version` ignorant `update` :
114
-
115
- ```js
116
- if (parsed.version && parsed.version !== "*" && parsed.version !== "-") {
117
- try { return compareMavenVersions(depVersion, parsed.version) === 0; }
118
- ```
119
-
120
- → `criteria: cpe:2.3:a:apache:foo:1.0.0:beta1:*…` matche `dep=1.0.0` (release). FP.
121
-
122
- **Fix :** si `update` n'est ni `*` ni `-`, concaténer (`${version}-${update}`) avant `compareMavenVersions` (qui gère déjà `1.0.0-beta1`).
123
-
124
- ### MEDIUM
125
-
126
- #### M1 — `compareMavenVersions` accepte du garbage sans broncher
127
- **Fichier :** `lib/maven-version.js:25-94`
128
-
129
- `compareMavenVersions("${foo}", "1.0")` → `-1`. Une variable Maven non résolue est comparée comme une vraie version. La pipeline filtre `/\$\{/.test(dep.version)` dans `expandWithTransitives`, `queryOsvForDeps`, `checkOutdatedDeps` — **mais pas dans `matchDepsAgainstCves`** (`cve-match.js:151-162`). Donc dep avec version `${foo}` → `isVersionAffected("${foo}", range)` → fall-through `true` (H1).
130
-
131
- **Fix :** skipper les deps avec `${…}` non résolu dans `matchDepsAgainstCves`.
132
-
133
- ---
134
-
135
- #### M2 — OSV : stubs cachés ré-émis sur la mauvaise version
136
- **Fichier :** `lib/osv.js:242-265` (`runMatches`)
137
-
138
- `vulnToMatch` ne refait pas de check range local. Le cache key `(g, a, v)` est versionné, donc pour la même dep+version c'est sûr. Mais le branch « stub only » émet `severity: "UNKNOWN"` sans description — vrai FP si la dep a été upgradée entre la build du cache et le scan.
139
-
140
- `dep.version` avec `1.0.0.Final` ou `1.0.0.RELEASE` est envoyée verbatim à OSV qui ne normalise pas → recall patchy (FN, pas FP).
141
-
142
- **Fix :** toujours évaluer `vuln.affected[].ranges` localement avant d'émettre. Drop des matches stub-only.
143
-
144
- ---
145
-
146
- #### M3 — `parseRange` exporté mais jamais appelé
147
- **Fichier :** `lib/maven-version.js:131-146`
148
-
149
- `parseRange` traite les ranges Maven (`[1.0,2.0)`) mais aucun caller. Une `<version>[1.0,2.0)</version>` est passée verbatim à `compareMavenVersions("[1.0,2.0)", spec.version)`. Le tokenizer split sur `[.\-]`, `[1` devient un segment string → ranking sub-release → typiquement FN, mais peut devenir FP via H1.
150
-
151
- **Fix :** câbler `parseRange` dans la pipeline ; sinon reporter ces deps en `unresolved-versions`.
152
-
153
- ---
154
-
155
- #### M4 — `cpeMatchesDep` ré-introduit le substring leak de H2
156
- **Fichier :** `lib/cpe.js:170-173`
157
-
158
- Le filtre CPE supposé *narrow* contient lui-même la même heuristique substring que H2. Après que H2 fait passer un match, ce leak l'empêche de le re-filtrer.
159
-
160
- ---
161
-
162
- #### M5 — retire.js passé sans validation locale
163
- **Fichier :** `lib/retire.js:148-193`
164
-
165
- Le wrapper a accès à `below`/`atOrAbove` mais ne les compare jamais. Les régressions historiques des signatures retire (matchs sur des bundles non reliés) passent. Pas d'équivalent à `--includeOsvData` / `--ignorefile`.
166
-
167
- retire émet aussi un CVE id synthétique `RETIRE-<component>-<version>` qui ne dédup jamais contre fad/osv/nvd → **même vuln rapportée 2 fois**.
168
-
169
- ---
170
-
171
- #### M6 — Cohérence des clés `byProduct`
172
- **Fichier :** `lib/cve-download.js:163-164` + `lib/cve-match.js:191`
173
-
174
- Index keyé sur `a.product` brut, lookup sur `.toLowerCase()`. Actuellement safe car `extractAffectedRanges` lowercase upstream — mais fragile. À documenter ou normaliser explicitement.
175
-
176
- ### LOW
177
-
178
- - **L1** — `data/known-obsolete.json:92-96` : `struts2-core` flaggé HIGH sans gate de version (Struts 2.5.30 patché remonte « obsolete »).
179
- - **L2** — `findCycleForVersion` (`lib/outdated.js:79-86`) prefix-match OK, pas de FP trouvé.
180
- - **L3** — Tests : aucun ne couvre les edge cases ci-dessus (no-bounds, substring vendor leak, possible tier, update CPE, garbage `${...}`, `parseRange`).
181
-
182
- ### Verdict faux positifs
183
-
184
- **Architecture saine, implémentation fail-open.** Les 4 cascades (H1+H2+H3+H4) doivent être patchées avant prod.
185
-
186
- Patches prioritaires (ordre) :
187
- 1. H1 → one-liner, plus gros gain
188
- 2. H2 → tightening de `vendorMatchesGroup`
189
- 3. H3 → cacher le tier `possible` par défaut
190
- 4. H4 → strip `cpeFiltered:true` du report par défaut
191
- 5. M1 → skip `${...}` dans `matchDepsAgainstCves`
192
- 6. M2 → re-eval local des OSV ranges
193
-
194
- ---
195
-
196
- ## B. Code reuse
197
-
198
- ### HIGH
199
-
200
- - **Helper de cache disque triplicé** : `lib/osv.js:25-44`, `lib/nvd.js:28-45`, `lib/retire.js:27-45`. Pattern identique (`_fetchedAt` + TTL + JSON). `lib/outdated.js:22-37` même chose avec shape différent.
201
- → Extraire `lib/cache-disk.js` exportant `makeCache(dir, ttlMs)`.
202
-
203
- - **`severityFromScore` + `SEVERITY_RANK` dupliqués 7+ fois** : `lib/nvd.js:64-71`, `lib/cve-download.js:82-89`, `lib/osv.js:60-66`, `lib/cve-match.js:9`, `lib/snyk.js:112`, `lib/cve-report.js:{634, 761, 1085, 1280}`, `fad-checker.js:657`.
204
- → `lib/severity.js` + `sortMatchesBySeverity()` helper.
205
-
206
- - **Merge-by-source logic dupliquée** : `fad-checker.js:628-665` (`mergeBySource`) vs `lib/snyk.js:97-120` (`mergeWithFadResults`). Le `"both"` de Snyk désaccorde avec le `"fad+osv"` ailleurs.
207
- → Promouvoir `mergeBySource` dans `lib/cve-match.js`.
208
-
209
- ### MEDIUM
210
-
211
- - **`depLabel` / `depToKey` réinventés 6 fois** : `fad-checker.js:551`, `lib/cpe.js:177-184`, `lib/osv.js:100-102`, `lib/cve-report.js:{648, 669, 773, 1296}`.
212
- - **Walker de répertoires + `SKIP_DIRS` dupliqués 4 fois** : `lib/core.js:9-37`, `lib/npm/parse.js:253-284`, `lib/npm/collect.js:202-222`, `lib/retire.js:77-81`.
213
- - **Deux parsers POM divergents** : `lib/core.js:49-117` vs `lib/transitive.js:111-158`. Légitime (besoins différents) mais extraction parent/coord en commun possible.
214
- - **CLI hand-roll argv parsing** : `fad-checker.js:25-150` walk de `process.argv` pour `--completion`, `--set-nvd-key`, etc. — devrait être des subcommands `commander`.
215
-
216
- ### LOW
217
-
218
- - 3 comparateurs de versions divergents : `compareMavenVersions`, `compareVersionsLoose` (`cve-report.js:705-715`), `semverCompare` (`npm/collect.js:25-37`).
219
- - `parseMavenMetadataLatest` (`outdated.js:212-224`) en regex au lieu d'xml2js (OK car XML trivial).
220
-
221
- ### Modules clean
222
- `maven-version.js`, `maven-repo.js`, `config.js`, `scan-completeness.js`, `cache-archive.js`.
223
-
224
- ---
225
-
226
- ## C. Code quality
227
-
228
- ### HIGH
229
-
230
- - **`fad-checker.js` (665 lignes) = god script** : pré-parse argv mélangé à l'orchestration, business logic dans la CLI.
231
- - **`lib/cve-report.js` (1455 lignes) = mega-module** : CSS inline (~200 lignes), JS inline (`TOGGLE_SCRIPT`), business logic (`buildFixRecommendations`, `versionJump`, `pickTopCriticalMatches`) et rendu HTML dans le même fichier. `RENDER_CTX` module-global (lignes 247-249) reset à chaque `buildBody` — comment auto-avoue le workaround.
232
- → Splitter en `lib/report/{css,html,recommendations,word}.js`. Passer `srcRoot` en paramètre.
233
- - **Stringly-typed partout** : `"fad" | "osv" | "nvd" | "snyk" | "retire"` en chaînes nues + class CSS `.source.snyk` qui couple le HTML à ces littéraux. Renommer une source casse silencieusement le CSS.
234
- → `lib/keys.js` + `SOURCES = Object.freeze({...})`.
235
-
236
- ### MEDIUM
237
-
238
- - **Param sprawl sur `writeReports` / `buildBody` / `renderExecutiveSummary`** — 10 champs disjoints recomposés à chaque appel.
239
- - **CVE matcher : duplication structurelle tier-2/tier-3** (`cve-match.js:175-219`) — flatten en single loop avec ternaire.
240
- - **Lockfile parsers v1 vs v2/v3 collés en un seul `parsePackageLock`** (`npm/parse.js:76-176`) — splitter.
241
- - **~30 `catch {}` silencieux** dans tout le code. Beaucoup légitimes, certains masquent des bugs réels (`osv.js:233`, `outdated.js:187,201`, `fad-checker.js:259`).
242
- - **`RENDER_CTX` global** → thread `srcRoot` via les signatures (3 niveaux max).
243
- - **Skip-dir lists copy-collées 4x** avec divergence `build/` (Maven keep, JS skip) — documenter une fois.
244
- - **`lib/core.js:230-235`** repeat parent-resolution dans `rewritePoms` — consolider.
245
-
246
- ### LOW
247
-
248
- - Cache helpers ré-implémentés (cf. reuse).
249
- - Commentaires narratifs sans valeur (`cve-report.js:404-406`, `cve-report.js:519-521`, `transitive.js:204-206`, `fad-checker.js:202`).
250
- - Pollution `byId` potentielle via `excludedById`/`missingById` (`core.js:240-285`) — appliquer le guard du CLAUDE.md.
251
- - `lib/osv.js:188` arithmétique d'index fragile (`(batchIdx - 1) * BATCH_SIZE + j`) — utiliser `i + j`.
252
- - Verbosité booléenne threadée 4+ niveaux → `lib/log.js`.
253
-
254
- ---
255
-
256
- ## D. Efficiency
257
-
258
- ### HIGH
259
-
260
- - **CVE index loadé eagerly** (`lib/cve-download.js:299-301`) : `JSON.parse(readFileSync)` synchrone même pour un projet de 3 deps. Charger async + lazy par bucket.
261
- - **`cpe.js` re-parse les CPE et walk 2 fois** (`cpe.js:194-245`) : `parseCpe23` appelé répétitivement, walk pour confidence après le walk d'évaluation. 2M+ string-splits sur un projet 800 deps × 50 matches.
262
- → Cache `m._parsed ||= parseCpe23(m.criteria)` + retour du match satisfaisant.
263
- - **NVD enrichment 100 % serial** (`nvd.js:186-203`) : `sleep(600|6000)` entre chaque appel. 200 CVEs uniques = 2 min avec clé, **20 min sans**. NIST policy = 50/30s window → permet parallélisme.
264
- - **TOCTOU `existsSync` + `readFile`** dans `transitive.js:52`, `nvd.js:34`, `osv.js:33`, `retire.js:32-37`, `outdated.js:22-25`. Doublé syscalls.
265
- - **`outdated.checkOutdatedDeps` cache reset global** (`outdated.js:230`) : `cache.entries = {}` si meta TTL périmé → 600 deps refetch même si entrées fraîches.
266
- → TTL par-entrée.
267
-
268
- ### MEDIUM
269
-
270
- - `findEolProduct` re-sort la prefix-list dans la boucle (`outdated.js:46-52`) — sort once at module load.
271
- - `outByKey` Map rebuild 6x dans `cve-report.js` (`:633-679`, `:760-795`, `:993-1003`) — builder once dans `buildBody`.
272
- - CVE bulk JSON `readFileSync` per file (`cve-download.js:274-287`) — borné par cache 24h, MEDIUM.
273
- - `cve-report.js` : `renderDetailPanel` (`:417-493`) + `groupExternalRefs` (`:340`) re-alloués par row → 45k allocations sur un report 5k rows. Pré-classifier à l'enrichment NVD.
274
- - **POMs parsés 2x sur le chemin `rewritePoms`** (`core.js:50` puis re-read à `:219`). `structuredClone` ou skip re-parse en read-only.
275
- - **`transitive.js:314` : `queue.shift()` O(n)** sur 4000+ transitives → 16M memmoves. Switch en cursor.
276
- - **Maven Central : pas de batch endpoint** (`outdated.js:176`) — solrsearch accepte OR boolean. 600 deps → 1 requête au lieu de 600.
277
- - **OSV detail fetch serial** dans `queryBatch` (`osv.js:218-236`) — `p-limit(10)` similaire à fad-checker.js.
278
-
279
- ### LOW
280
-
281
- - `getAllInheritedProps` rebuild via spread — fine en dessous de 10k POMs.
282
- - `xml2js` 3-5x plus lent que `fast-xml-parser` — hors scope.
283
- - Eager `require("chalk")` etc. — invisible.
284
-
285
- ### Note positive
286
- Le matcher CVE (`matchDepsAgainstCves`) est **Map-indexé correctement** (`byPackageName` / `byProduct` pré-bucketés) — pas de O(n²) sur le hot path.
287
-
288
- ---
289
-
290
- ## E. Recommandations priorisées
291
-
292
- | # | Action | Severity | Effort | Impact |
293
- |---|---|---|---|---|
294
- | 1 | Patch `isVersionAffected` fail-closed (H1) | HIGH FP | 1 ligne | Énorme |
295
- | 2 | Tighten `vendorMatchesGroup` substring (H2) | HIGH FP | 10 lignes | Énorme |
296
- | 3 | Drop ou hide tier `possible` (H3) | HIGH FP | 5 lignes | Énorme |
297
- | 4 | Strip `cpeFiltered:true` du report (H4) | HIGH FP | 3 lignes | Gros |
298
- | 5 | Handle CPE `update` qualifier (H5) | HIGH FP | 15 lignes | Moyen |
299
- | 6 | Skip `${...}` dans matcher (M1) | MED FP | 3 lignes | Moyen |
300
- | 7 | OSV ranges re-eval local (M2) | MED FP | 30 lignes | Moyen |
301
- | 8 | Extract `lib/severity.js` + `SEVERITY_RANK` | HIGH reuse | 1h | Maintenance |
302
- | 9 | Extract `lib/cache-disk.js` | HIGH reuse | 1h | Maintenance |
303
- | 10 | NVD enrichment parallèle (token-bucket) | HIGH perf | 30min | 10x speedup |
304
- | 11 | Batch Maven Central Solr (#13 efficiency) | HIGH perf | 1h | 100x speedup |
305
- | 12 | `parseCpe23` memoization (CPE perf) | HIGH perf | 10min | 2-3x speedup |
306
- | 13 | Splitter `cve-report.js` en sous-modules | HIGH quality | 2h | Maintenance |
307
- | 14 | Tests des edge cases FP (`update`, no-bounds, substring) | LOW | 2h | Filet de sécurité |
308
-
309
- **Quick wins (< 1 jour cumulé)** : #1, #2, #3, #4, #6, #12 → diviserait le taux de FP par ~3-5 et accélérerait le rendu de ~2-3×.
310
-
311
- ---
312
-
313
- ## F. Verdict final
314
-
315
- - **Architecture :** **bonne**. Séparation Maven/npm, 3-tier matching, post-CPE refinement, multi-source dedup, caches TTL — tout est en place.
316
- - **Implémentation matcher :** **fail-open systémique**. Les 4 cascades H1→H4 transforment un outil correctement architecturé en générateur de faux positifs.
317
- - **Performance :** correcte en CPU (Map-indexed), **mauvaise en I/O** (NVD serial, Solr unitaire, double-parse POM).
318
- - **Qualité :** 2 mega-modules (`cve-report.js` 1455 LoC, `fad-checker.js` 665 LoC) à splitter, ~30 `catch {}` à auditer, 7+ duplications de `SEVERITY_RANK`.
319
- - **Tests :** 96 tests existants mais happy-path-heavy ; aucune couverture des edge cases FP listés ci-dessus.
320
-
321
- **Recommandation pratique :** sans les patches H1-H4, le rapport doit être traité comme une **liste de départ pour triage manuel**, pas comme un inventaire de vulnérabilités exploitable. Le tier `exact` reste fiable. Tout le reste demande des yeux humains sur la description CVE avant action.
322
-
323
- ---
324
-
325
- ## Annexe — Références fichier:ligne
326
-
327
- **Faux positifs :**
328
- - `lib/maven-version.js:25-94, 101-124, 131-146`
329
- - `lib/cve-match.js:9, 99-109, 151-162, 164-173, 175-219, 191-198`
330
- - `lib/cpe.js:25, 36-68, 81-100, 161-184, 194-245, 266, 269-272`
331
- - `lib/osv.js:25-44, 60-66, 100-102, 188, 218-236, 242-265, 273`
332
- - `lib/nvd.js:28-45, 64-71, 145, 186-203`
333
- - `lib/retire.js:24-45, 77-81, 148-193`
334
- - `lib/outdated.js:22-37, 46-52, 79-86, 168-187, 212-254`
335
- - `lib/cve-download.js:82-89, 163-164, 274-301`
336
- - `lib/transitive.js:51-101, 111-158, 204-235, 273-320`
337
- - `lib/core.js:9-37, 49-149, 219-285`
338
- - `lib/npm/{parse.js:76-291, collect.js:25-224}`
339
- - `lib/cve-report.js:48-247, 340, 404-493, 519-521, 559, 633-1455`
340
- - `fad-checker.js:25-150, 202, 259, 551, 628-665`
341
- - `data/{cpe-coord-map.json, known-obsolete.json:92-96}`
342
-
343
- **Tests manquants :** `test/{cpe,cve-match,maven-version}.test.js` — ajouter edge cases `update`, no-bounds, substring vendor leak, garbage version.