fad-checker 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -2
- package/data/cwe-names.json +192 -0
- package/data/eol-mapping.json +12 -0
- package/docs/ARCHITECTURE.md +8 -6
- package/fad-checker.js +17 -3
- package/lib/cve-report.js +329 -20
- package/lib/npm/collect.js +25 -1
- package/lib/npm/registry.js +206 -0
- package/lib/nvd.js +7 -1
- package/lib/outdated.js +18 -1
- package/package.json +6 -5
- package/test/cve-report.test.js +92 -0
- package/test/npm-registry.test.js +64 -0
- package/test/outdated.test.js +45 -0
- package/test/webjar.test.js +33 -0
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.
|
|
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 =>
|
|
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 =>
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
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/npm/collect.js
CHANGED
|
@@ -199,6 +199,30 @@ function safeParse(fn, file, verbose) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* WebJars are client-side JS libraries shipped as Maven artifacts. Derive the
|
|
204
|
+
* upstream npm coordinate from a WebJar dep so it can flow through the npm
|
|
205
|
+
* pipeline (registry deprecation/outdated + endoflife EOL) instead of being
|
|
206
|
+
* special-cased.
|
|
207
|
+
*
|
|
208
|
+
* org.webjars.npm — deterministic npm mirror. artifactId == npm name;
|
|
209
|
+
* a scoped "@scope/name" is encoded as "scope__name".
|
|
210
|
+
* org.webjars — hand-curated catalogue; artifactId is the JS lib name
|
|
211
|
+
* (mostly aligned with npm, e.g. jquery/bootstrap).
|
|
212
|
+
* org.webjars.bower* — Bower mirrors; best-effort name match.
|
|
213
|
+
*
|
|
214
|
+
* Versions match the upstream package. Returns { name, version } or null for a
|
|
215
|
+
* non-WebJar coordinate.
|
|
216
|
+
*/
|
|
217
|
+
function webjarToNpm(dep) {
|
|
218
|
+
const g = dep.groupId || "";
|
|
219
|
+
if (g !== "org.webjars" && !g.startsWith("org.webjars.")) return null;
|
|
220
|
+
let name = dep.artifactId || "";
|
|
221
|
+
if (!name) return null;
|
|
222
|
+
if (name.includes("__")) name = "@" + name.replace(/__/g, "/");
|
|
223
|
+
return { name, version: dep.version || null };
|
|
224
|
+
}
|
|
225
|
+
|
|
202
226
|
function hasJsManifests(rootDir) {
|
|
203
227
|
try {
|
|
204
228
|
const stack = [rootDir];
|
|
@@ -221,4 +245,4 @@ function hasJsManifests(rootDir) {
|
|
|
221
245
|
return false;
|
|
222
246
|
}
|
|
223
247
|
|
|
224
|
-
module.exports = { collectNpmDeps, hasJsManifests };
|
|
248
|
+
module.exports = { collectNpmDeps, hasJsManifests, semverCompare, webjarToNpm };
|