fad-checker 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +93 -20
- package/fad-checker.js +164 -45
- package/lib/codecs/binary/scan.js +71 -0
- package/lib/codecs/binary/sniff.js +37 -0
- package/lib/codecs/binary.codec.js +54 -0
- package/lib/codecs/composer.codec.js +7 -3
- package/lib/codecs/go/registry.js +19 -11
- package/lib/codecs/go.codec.js +7 -3
- package/lib/codecs/index.js +9 -4
- package/lib/codecs/maven/jar-scan.js +8 -3
- package/lib/codecs/maven.codec.js +4 -2
- package/lib/codecs/npm/parse.js +5 -2
- package/lib/codecs/npm/registry.js +29 -18
- package/lib/codecs/npm.codec.js +7 -5
- package/lib/codecs/nuget.codec.js +7 -3
- package/lib/codecs/pypi/registry.js +16 -10
- package/lib/codecs/pypi.codec.js +7 -3
- package/lib/codecs/recipes.js +9 -1
- package/lib/codecs/ruby/registry.js +16 -10
- package/lib/codecs/ruby.codec.js +7 -3
- package/lib/config.js +34 -23
- package/lib/core.js +9 -5
- package/lib/cve-match.js +3 -2
- package/lib/cve-report.js +56 -5
- package/lib/dep-record.js +12 -9
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +8 -1
- package/lib/options-env.js +113 -0
- package/lib/parallel-walk.js +5 -2
- package/lib/path-filter.js +58 -0
- package/lib/registries.js +112 -0
- package/lib/retire.js +66 -0
- package/lib/scan-completeness.js +7 -1
- package/lib/unmanaged.js +82 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -44,35 +44,45 @@ function getNvdApiKey() {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* Per-ecosystem custom registries (Nexus/Artifactory/JBoss, Verdaccio, devpi,
|
|
48
|
+
* Gemfury, Athens, …) the user has configured. Stored under config key
|
|
49
|
+
* `registries`: { <ecosystem>: [{ name, url, auth?, token? }] } where `auth` is
|
|
50
|
+
* "user:pass" (→ Basic) and `token` is a Bearer token.
|
|
50
51
|
*
|
|
51
|
-
* Maven Central
|
|
52
|
-
* the final fallback
|
|
53
|
-
*
|
|
52
|
+
* Public registries (Maven Central, registry.npmjs.org, …) are intentionally
|
|
53
|
+
* NOT stored here — callers append them as the final fallback so the user's
|
|
54
|
+
* registries stay in priority order while the public one is always a safety net.
|
|
54
55
|
*/
|
|
55
|
-
function
|
|
56
|
-
const
|
|
56
|
+
function getRegistryMap() {
|
|
57
|
+
const m = get("registries");
|
|
58
|
+
return (m && typeof m === "object" && !Array.isArray(m)) ? m : {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getRegistries(ecosystem) {
|
|
62
|
+
const list = getRegistryMap()[ecosystem];
|
|
57
63
|
return Array.isArray(list) ? list : [];
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
function
|
|
61
|
-
return set("
|
|
66
|
+
function setRegistryMap(map) {
|
|
67
|
+
return set("registries", map && Object.keys(map).length ? map : null);
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
function
|
|
65
|
-
const
|
|
66
|
-
list
|
|
67
|
-
|
|
70
|
+
function addRegistry(ecosystem, name, url, { auth = null, token = null } = {}) {
|
|
71
|
+
const map = getRegistryMap();
|
|
72
|
+
const list = (map[ecosystem] || []).filter(r => r.name !== name);
|
|
73
|
+
list.push({ name, url, ...(auth ? { auth } : {}), ...(token ? { token } : {}) });
|
|
74
|
+
map[ecosystem] = list;
|
|
75
|
+
setRegistryMap(map);
|
|
68
76
|
return list;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
function removeRegistry(ecosystem, name) {
|
|
80
|
+
const map = getRegistryMap();
|
|
81
|
+
const before = (map[ecosystem] || []).length;
|
|
82
|
+
map[ecosystem] = (map[ecosystem] || []).filter(r => r.name !== name);
|
|
83
|
+
if (!map[ecosystem].length) delete map[ecosystem];
|
|
84
|
+
setRegistryMap(map);
|
|
85
|
+
return before !== (map[ecosystem]?.length || 0);
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
module.exports = {
|
|
@@ -83,8 +93,9 @@ module.exports = {
|
|
|
83
93
|
set,
|
|
84
94
|
get,
|
|
85
95
|
getNvdApiKey,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
getRegistryMap,
|
|
97
|
+
getRegistries,
|
|
98
|
+
setRegistryMap,
|
|
99
|
+
addRegistry,
|
|
100
|
+
removeRegistry,
|
|
90
101
|
};
|
package/lib/core.js
CHANGED
|
@@ -19,7 +19,9 @@ const SKIP_DIRS = new Set([
|
|
|
19
19
|
|
|
20
20
|
const coord = v => (v == null ? null : String(v).trim() || null);
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const defaultPomSkip = child => SKIP_DIRS.has(path.basename(child));
|
|
23
|
+
|
|
24
|
+
function findPomFiles(dir, skipDir = defaultPomSkip) {
|
|
23
25
|
const out = [];
|
|
24
26
|
const stack = [dir];
|
|
25
27
|
while (stack.length) {
|
|
@@ -29,8 +31,9 @@ function findPomFiles(dir) {
|
|
|
29
31
|
catch { continue; }
|
|
30
32
|
for (const e of entries) {
|
|
31
33
|
if (e.isDirectory()) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const child = path.join(cur, e.name);
|
|
35
|
+
if (skipDir(child, e.name)) continue;
|
|
36
|
+
stack.push(child);
|
|
34
37
|
} else if (e.name === "pom.xml") {
|
|
35
38
|
out.push(path.join(cur, e.name));
|
|
36
39
|
}
|
|
@@ -41,11 +44,11 @@ function findPomFiles(dir) {
|
|
|
41
44
|
|
|
42
45
|
// Parallel equivalent of findPomFiles — same result, but readdir runs concurrently
|
|
43
46
|
// so the walk isn't serialized one round-trip at a time on a high-latency filesystem.
|
|
44
|
-
async function findPomFilesAsync(dir) {
|
|
47
|
+
async function findPomFilesAsync(dir, skipDir = defaultPomSkip) {
|
|
45
48
|
const { walkDirs } = require("./parallel-walk");
|
|
46
49
|
const out = [];
|
|
47
50
|
await walkDirs(dir, {
|
|
48
|
-
skipDir
|
|
51
|
+
skipDir,
|
|
49
52
|
onDir: (cur, entries) => {
|
|
50
53
|
for (const e of entries) if (e.isFile() && e.name === "pom.xml") out.push(path.join(cur, e.name));
|
|
51
54
|
},
|
|
@@ -359,6 +362,7 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
|
|
|
359
362
|
|
|
360
363
|
module.exports = {
|
|
361
364
|
coord,
|
|
365
|
+
SKIP_DIRS,
|
|
362
366
|
findPomFiles,
|
|
363
367
|
findPomFilesAsync,
|
|
364
368
|
newMetadataStore,
|
package/lib/cve-match.js
CHANGED
|
@@ -125,14 +125,14 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
125
125
|
// Embedded-binary coords don't participate in Maven Central resolution (a
|
|
126
126
|
// fat-jar already ships its deps, discovered by recursion) and must not act as
|
|
127
127
|
// a depMgmt override for the declared tree.
|
|
128
|
-
if (dep.provenance === "embedded") continue;
|
|
128
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
129
129
|
if (dep.version && !/\$\{/.test(dep.version)) {
|
|
130
130
|
rootDepMgmt.set(`${dep.groupId}:${dep.artifactId}`, { version: dep.version });
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
const directs = [...resolvedDeps.values()]
|
|
135
|
-
.filter(d => d.provenance !== "embedded")
|
|
135
|
+
.filter(d => d.provenance !== "embedded" && d.provenance !== "binary")
|
|
136
136
|
.filter(d => d.version && !/\$\{/.test(d.version))
|
|
137
137
|
.filter(d => d.scope !== "test" || opts.includeTestDeps)
|
|
138
138
|
.filter(d => d.scope !== "parent");
|
|
@@ -227,6 +227,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
|
227
227
|
// They are caught by the OSV pipeline (which is multi-ecosystem) and
|
|
228
228
|
// the CPE refinement step (post-NVD).
|
|
229
229
|
if (dep.ecosystem === "npm") continue;
|
|
230
|
+
if (dep.provenance === "binary") continue; // no resolved coordinate yet (Plan 2 identifies it)
|
|
230
231
|
const key = `${dep.groupId}:${dep.artifactId}`.toLowerCase();
|
|
231
232
|
// Tier 1: exact packageName match
|
|
232
233
|
const t1 = matchOne(dep, byPackage[key], "exact");
|
package/lib/cve-report.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require("fs");
|
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
14
14
|
const { computePriority, sortByPriority } = require("./priority");
|
|
15
|
+
const { buildInventory } = require("./unmanaged");
|
|
15
16
|
|
|
16
17
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
17
18
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
@@ -1362,7 +1363,7 @@ function renderLicenseChapter(licenseResults) {
|
|
|
1362
1363
|
return intro + blocks;
|
|
1363
1364
|
}
|
|
1364
1365
|
|
|
1365
|
-
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1366
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1366
1367
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1367
1368
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1368
1369
|
cveMatches = mergeMatches(cveMatches || []);
|
|
@@ -1397,6 +1398,10 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1397
1398
|
const licenseTotal = licenseResults?.assessed?.length || 0;
|
|
1398
1399
|
const licenseFlagged = licenseResults?.flagged?.length || 0;
|
|
1399
1400
|
const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
|
|
1401
|
+
const unmanagedInventory = buildInventory(resolvedDeps || new Map());
|
|
1402
|
+
const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
|
|
1403
|
+
const vendoredJsInv = vendoredJsInventory || [];
|
|
1404
|
+
const vendoredJsContent = renderVendoredJsInventory(vendoredJsInv, projectInfo?.src);
|
|
1400
1405
|
const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
|
|
1401
1406
|
|
|
1402
1407
|
const toolbar = `<div class="toolbar">
|
|
@@ -1429,6 +1434,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1429
1434
|
licenseTotal,
|
|
1430
1435
|
licenseFlagged,
|
|
1431
1436
|
embeddedTotal: embStats.total,
|
|
1437
|
+
unmanagedTotal: unmanagedInventory.length,
|
|
1438
|
+
vendoredJsTotal: vendoredJsInv.length,
|
|
1432
1439
|
fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
|
|
1433
1440
|
});
|
|
1434
1441
|
|
|
@@ -1449,6 +1456,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1449
1456
|
${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
|
|
1450
1457
|
${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
|
|
1451
1458
|
${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
|
|
1459
|
+
${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
|
|
1460
|
+
${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
|
|
1452
1461
|
${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
|
|
1453
1462
|
${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
|
|
1454
1463
|
${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
|
|
@@ -1493,12 +1502,14 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
|
|
|
1493
1502
|
return out;
|
|
1494
1503
|
}
|
|
1495
1504
|
|
|
1496
|
-
function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1505
|
+
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1497
1506
|
const entries = [];
|
|
1498
1507
|
if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
|
|
1499
1508
|
entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
|
|
1500
1509
|
if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
|
|
1501
|
-
entries.push({ id: "
|
|
1510
|
+
if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
|
|
1511
|
+
if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
|
|
1512
|
+
entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
|
|
1502
1513
|
entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
|
|
1503
1514
|
entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
|
|
1504
1515
|
entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
|
|
@@ -1528,6 +1539,46 @@ function renderEmbeddedChapter(matches, srcRoot) {
|
|
|
1528
1539
|
return intro + blocks;
|
|
1529
1540
|
}
|
|
1530
1541
|
|
|
1542
|
+
// Unmanaged native-binary inventory (Part C). Committed .dll/.exe/.so/.dylib that no
|
|
1543
|
+
// package manager governs, identified by checksum (deps.dev + CIRCL). Self-contained
|
|
1544
|
+
// inline styles, matching the report's no-external-CSS philosophy.
|
|
1545
|
+
function pill(text, bg, fg = "#fff") {
|
|
1546
|
+
return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${bg};color:${fg};font-weight:600;font-size:11px">${esc(text)}</span>`;
|
|
1547
|
+
}
|
|
1548
|
+
function renderUnmanagedInventory(inventory, srcRoot) {
|
|
1549
|
+
if (!inventory.length) return `<div class="empty">No committed native binaries found.</div>`;
|
|
1550
|
+
const intro = `<div class="fp-intro">Committed native binaries (<code>.dll</code>/<code>.exe</code>/<code>.so</code>/<code>.dylib</code>) — not governed by any package manager. Identified by checksum (deps.dev + CIRCL). <strong>should-be-managed</strong> = a published package committed as a blob; <strong>name≠checksum</strong> = the filename disagrees with what the hash resolves to; <strong>not recognised by any source</strong> = neither deps.dev nor CIRCL knows this exact file (vendored/custom build — verify its origin manually). Identification is by checksum only; a file absent from both databases stays unidentified even when its name is suggestive (e.g. <code>openssl.exe</code>).</div>`;
|
|
1551
|
+
const rows = inventory.map(e => {
|
|
1552
|
+
let rel = e.path;
|
|
1553
|
+
if (srcRoot) { try { rel = path.relative(srcRoot, e.path); } catch { /* keep abs */ } }
|
|
1554
|
+
const id = e.identity
|
|
1555
|
+
? `${esc(e.identity.ecosystem ? e.identity.ecosystem + ":" : "")}${esc(e.identity.name || "")}${e.identity.version ? "@" + esc(e.identity.version) : ""} <span style="color:#6b7280">(${esc(e.identity.source || "")})</span>`
|
|
1556
|
+
: `<span style="color:#6b7280">unknown</span>`;
|
|
1557
|
+
const flags = [
|
|
1558
|
+
e.knownMalicious ? pill("⚠ MALICIOUS", "#7c0008") : null,
|
|
1559
|
+
e.integrity === "pristine" ? pill("pristine", "#166534") : (e.integrity === "known-good" ? pill("known-good", "#15803d") : null),
|
|
1560
|
+
e.shouldBeManaged ? pill("should-be-managed", "#2563eb") : null,
|
|
1561
|
+
e.nameMismatch ? pill("name≠checksum", "#ea580c") : null,
|
|
1562
|
+
e.noOnlineInfo ? pill("not recognised by any source", "#6b7280") : null,
|
|
1563
|
+
].filter(Boolean).join(" ");
|
|
1564
|
+
return `<tr><td class="dep"><code class="path">${esc(rel)}</code></td><td>${id}</td><td>${flags}</td><td><code class="path" style="color:#6b7280">${esc((e.hashes?.sha256 || "").slice(0, 16))}…</code></td></tr>`;
|
|
1565
|
+
}).join("\n");
|
|
1566
|
+
return intro + `<table><thead><tr><th>File</th><th>Identity (by checksum)</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function renderVendoredJsInventory(inventory, srcRoot) {
|
|
1570
|
+
if (!inventory || !inventory.length) return `<div class="empty">No vendored JavaScript libraries identified.</div>`;
|
|
1571
|
+
const vulnN = inventory.filter(e => e.vulnerable).length;
|
|
1572
|
+
const intro = `<div class="fp-intro">Standalone JavaScript libraries committed into the tree (jQuery, Bootstrap, PDF.js, …) that <strong>no package manager governs</strong> — unknown provenance, integrity and patch story, identified by signature (retire.js). This is a cyber-hygiene inventory: even a non-vulnerable copy is unmanaged third-party code. <strong>${vulnN}</strong> of ${inventory.length} carry known vulnerabilities (detailed in chapter 2).</div>`;
|
|
1573
|
+
const rows = inventory.map(e => {
|
|
1574
|
+
const sevPill = e.vulnerable
|
|
1575
|
+
? pill(`${e.vulnCount} vuln${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
|
|
1576
|
+
: pill("no known vuln", "#15803d");
|
|
1577
|
+
return `<tr><td>${esc(e.component || "?")}</td><td>${esc(e.version || "?")}</td><td class="dep"><code class="path">${esc(e.file || "")}</code></td><td><span style="color:#6b7280">${esc(e.detection || "")}</span></td><td>${sevPill}</td></tr>`;
|
|
1578
|
+
}).join("\n");
|
|
1579
|
+
return intro + `<table><thead><tr><th>Library</th><th>Version</th><th>File</th><th>Detection</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1531
1582
|
function renderFalsePositives(matches) {
|
|
1532
1583
|
if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
|
|
1533
1584
|
const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
|
|
@@ -1911,12 +1962,12 @@ ${WORD_SECTION_PR}
|
|
|
1911
1962
|
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
1912
1963
|
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
1913
1964
|
// Back-compat: `outputDir` still writes both under the default file names.
|
|
1914
|
-
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
1965
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
1915
1966
|
if (outputDir) {
|
|
1916
1967
|
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
1917
1968
|
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
1918
1969
|
}
|
|
1919
|
-
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
1970
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
1920
1971
|
const written = { htmlPath: null, docPath: null };
|
|
1921
1972
|
if (htmlPath) {
|
|
1922
1973
|
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
package/lib/dep-record.js
CHANGED
|
@@ -35,17 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function makeDepRecord(input) {
|
|
38
|
-
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
|
|
38
|
+
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest", hashes = null, declaredName = null } = input;
|
|
39
39
|
const concrete = version && !/\$\{/.test(version) ? version : null;
|
|
40
40
|
const manifestPaths = manifestPath ? [manifestPath] : [];
|
|
41
|
-
// Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear
|
|
42
|
-
//
|
|
43
|
-
// they'd merge and the
|
|
44
|
-
// them by their unique physical location instead. provenance
|
|
45
|
-
// so the report/exports can label & group them; "both" is set
|
|
46
|
-
// across provenances ever happens.
|
|
47
|
-
const
|
|
48
|
-
|
|
41
|
+
// Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear) and standalone
|
|
42
|
+
// committed native binaries (.dll/.so/…) must not share the Map key of a declared
|
|
43
|
+
// dep with the same coordinate — they'd merge and the unmanaged report chapter
|
|
44
|
+
// would lose them. Key them by their unique physical location instead. provenance
|
|
45
|
+
// stays on the record so the report/exports can label & group them; "both" is set
|
|
46
|
+
// later if a merge across provenances ever happens.
|
|
47
|
+
const byLocation = (provenance === "embedded" || provenance === "binary") && manifestPath;
|
|
48
|
+
const coordKey = byLocation
|
|
49
|
+
? `${provenance}:${manifestPath}`
|
|
49
50
|
: coordKeyFor(ecosystem, namespace, name);
|
|
50
51
|
return {
|
|
51
52
|
ecosystem,
|
|
@@ -56,6 +57,8 @@ function makeDepRecord(input) {
|
|
|
56
57
|
versions: concrete ? [concrete] : [],
|
|
57
58
|
coordKey,
|
|
58
59
|
provenance,
|
|
60
|
+
hashes,
|
|
61
|
+
declaredName,
|
|
59
62
|
scope,
|
|
60
63
|
isDev: !!isDev,
|
|
61
64
|
manifestPaths,
|
package/lib/hash-id.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hash-id.js — identity-by-checksum for unmanaged artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Two known-good sources, tried in order:
|
|
5
|
+
* 1. deps.dev query-by-hash → exact package coordinate (whole published archive).
|
|
6
|
+
* 2. CIRCL hashlookup → known OS/distro/CDN/NSRL file + free KnownMalicious flag.
|
|
7
|
+
*
|
|
8
|
+
* Cache-backed (~/.fad-checker/hash-id-cache.json, 24h) and --offline-aware: offline
|
|
9
|
+
* reads cache only and never touches the network (project air-gapped principle).
|
|
10
|
+
*
|
|
11
|
+
* @author: N.BRAUN
|
|
12
|
+
* @email: pp9ping@gmail.com
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
|
|
18
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
19
|
+
const CACHE_PATH = path.join(CACHE_DIR, "hash-id-cache.json");
|
|
20
|
+
const CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
21
|
+
const DEPSDEV = "https://api.deps.dev/v3/query";
|
|
22
|
+
const CIRCL = "https://hashlookup.circl.lu/lookup/sha256";
|
|
23
|
+
|
|
24
|
+
const SYSTEM_TO_ECO = { MAVEN: "maven", NPM: "npm", NUGET: "nuget", PYPI: "pypi", RUBYGEMS: "ruby", CARGO: "cargo", GO: "go" };
|
|
25
|
+
|
|
26
|
+
function sha1ToBase64(hex) { return Buffer.from(hex, "hex").toString("base64"); }
|
|
27
|
+
|
|
28
|
+
function parseDepsDev(body) {
|
|
29
|
+
const vk = body?.results?.[0]?.version?.versionKey;
|
|
30
|
+
if (!vk?.name) return null;
|
|
31
|
+
return { ecosystem: SYSTEM_TO_ECO[vk.system] || (vk.system || "").toLowerCase() || null, name: vk.name, version: vk.version || null, source: "deps.dev" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseCircl(body) {
|
|
35
|
+
if (!body || body.message || !(body.FileName || body.ProductCode)) return null;
|
|
36
|
+
const malicious = Array.isArray(body.KnownMalicious) ? body.KnownMalicious.length > 0 : !!body.KnownMalicious;
|
|
37
|
+
return {
|
|
38
|
+
ecosystem: null,
|
|
39
|
+
name: body.ProductCode?.ProductName || body.FileName || null,
|
|
40
|
+
version: body.ProductCode?.ProductVersion || null,
|
|
41
|
+
source: `circl:${body.db || "hashlookup"}`,
|
|
42
|
+
trust: body["hashlookup:trust"] != null ? body["hashlookup:trust"] : null,
|
|
43
|
+
knownMalicious: malicious,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadCache() { try { const d = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); if (Date.now() - (d._fetchedAt || 0) < CACHE_TTL_MS) return d.entries || {}; } catch { /* ignore */ } return {}; }
|
|
48
|
+
function saveCache(entries) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify({ _fetchedAt: Date.now(), entries })); } catch { /* ignore */ } }
|
|
49
|
+
|
|
50
|
+
async function lookupHash(hashes, opts = {}) {
|
|
51
|
+
const { fetcher = globalThis.fetch, offline = false, cache } = opts;
|
|
52
|
+
const entries = cache || loadCache();
|
|
53
|
+
const key = hashes.sha256 || hashes.sha1;
|
|
54
|
+
if (!key) return null;
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(entries, key)) return entries[key];
|
|
56
|
+
if (offline) return null;
|
|
57
|
+
|
|
58
|
+
let identity = null;
|
|
59
|
+
// 1) deps.dev (SHA1 base64)
|
|
60
|
+
if (hashes.sha1) {
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetcher(`${DEPSDEV}?hash.type=SHA1&hash.value=${encodeURIComponent(sha1ToBase64(hashes.sha1))}`, { headers: { "User-Agent": "fad-checker-hashid" } });
|
|
63
|
+
if (r.ok) identity = parseDepsDev(await r.json());
|
|
64
|
+
} catch { /* ignore, try CIRCL */ }
|
|
65
|
+
}
|
|
66
|
+
// 2) CIRCL (SHA-256)
|
|
67
|
+
if (!identity && hashes.sha256) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetcher(`${CIRCL}/${hashes.sha256}`, { headers: { "User-Agent": "fad-checker-hashid", Accept: "application/json" } });
|
|
70
|
+
if (r.ok) identity = parseCircl(await r.json());
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
entries[key] = identity;
|
|
74
|
+
if (!cache) saveCache(entries);
|
|
75
|
+
return identity;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { sha1ToBase64, parseDepsDev, parseCircl, lookupHash, loadCache, saveCache, CACHE_PATH };
|
package/lib/json-export.js
CHANGED
|
@@ -65,10 +65,13 @@ const SEV = ["critical", "high", "medium", "low", "none", "unknown"];
|
|
|
65
65
|
*/
|
|
66
66
|
function buildFindings(payload = {}) {
|
|
67
67
|
const {
|
|
68
|
-
cveMatches = [], retireMatches = [], eolResults = [], obsoleteResults = [],
|
|
68
|
+
cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
|
|
69
69
|
outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
|
|
70
70
|
} = payload;
|
|
71
71
|
|
|
72
|
+
const { buildInventory } = require("./unmanaged");
|
|
73
|
+
const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
|
|
74
|
+
|
|
72
75
|
const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
|
|
73
76
|
let kev = 0;
|
|
74
77
|
for (const m of cveMatches) {
|
|
@@ -90,6 +93,8 @@ function buildFindings(payload = {}) {
|
|
|
90
93
|
outdated: outdatedResults.length,
|
|
91
94
|
licensesFlagged: licenseResults?.flagged?.length || 0,
|
|
92
95
|
vendored: retireMatches.length,
|
|
96
|
+
unmanaged: unmanaged.length,
|
|
97
|
+
vendoredJs: vendoredJsInventory.length,
|
|
93
98
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
94
99
|
},
|
|
95
100
|
cve: cveMatches.map(cveFinding),
|
|
@@ -98,6 +103,8 @@ function buildFindings(payload = {}) {
|
|
|
98
103
|
obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
|
|
99
104
|
outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
|
|
100
105
|
licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
|
|
106
|
+
unmanaged,
|
|
107
|
+
vendoredJs: vendoredJsInventory,
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/options-env.js — layered option resolution.
|
|
3
|
+
*
|
|
4
|
+
* Layers (highest → lowest): CLI flags > config file (--config / ./.fad-env.json,
|
|
5
|
+
* JSON) > FAD_CHECKER_ENV (a CLI-flag string) > global ~/.fad-checker/config.json
|
|
6
|
+
* > commander defaults. Scalar options follow precedence; `registries` are unioned
|
|
7
|
+
* elsewhere (lib/registries.js). The source flag has aliases (src/source).
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
11
|
+
*/
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { Command } = require("commander");
|
|
15
|
+
|
|
16
|
+
/** Quote/escape-aware shell-ish tokenizer (single+double quotes, backslash). */
|
|
17
|
+
function tokenize(str) {
|
|
18
|
+
const out = [];
|
|
19
|
+
let cur = "", q = null, esc = false, has = false;
|
|
20
|
+
for (const ch of String(str)) {
|
|
21
|
+
if (esc) { cur += ch; esc = false; has = true; continue; }
|
|
22
|
+
if (ch === "\\" && q !== "'") { esc = true; continue; }
|
|
23
|
+
if (q) { if (ch === q) q = null; else cur += ch; has = true; continue; }
|
|
24
|
+
if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
|
|
25
|
+
if (/\s/.test(ch)) { if (has) { out.push(cur); cur = ""; has = false; } continue; }
|
|
26
|
+
cur += ch; has = true;
|
|
27
|
+
}
|
|
28
|
+
if (has) out.push(cur);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadConfigFile(p) {
|
|
33
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
34
|
+
try { return JSON.parse(raw); }
|
|
35
|
+
catch (e) { throw new Error(`invalid JSON in config file ${p}: ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Map `source` → `src` (src wins if both present). Returns a NEW object. */
|
|
39
|
+
function normalizeSource(obj) {
|
|
40
|
+
const o = { ...obj };
|
|
41
|
+
if (o.source != null && o.src == null) o.src = o.source;
|
|
42
|
+
delete o.source;
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a CLI-flag string into { options, repos } using a throwaway clone of the
|
|
48
|
+
* real program. Only options whose source !== "default" are returned, so unset
|
|
49
|
+
* flags don't clobber higher layers. `repos` (variadic --repo) returned separately.
|
|
50
|
+
*/
|
|
51
|
+
function parseEnvFlags(str, program) {
|
|
52
|
+
const tokens = tokenize(str);
|
|
53
|
+
if (!tokens.length) return { options: {}, repos: [] };
|
|
54
|
+
const clone = new Command();
|
|
55
|
+
clone.exitOverride().allowUnknownOption(true).configureOutput({ writeErr() {}, writeOut() {} });
|
|
56
|
+
for (const o of program.options) clone.addOption(o);
|
|
57
|
+
try { clone.parse(tokens, { from: "user" }); } catch { /* tolerate */ }
|
|
58
|
+
const all = clone.opts();
|
|
59
|
+
const options = {};
|
|
60
|
+
for (const name of Object.keys(all)) {
|
|
61
|
+
const src = clone.getOptionValueSource(name);
|
|
62
|
+
if (src && src !== "default") options[name] = all[name];
|
|
63
|
+
}
|
|
64
|
+
const repos = Array.isArray(options.repo) ? options.repo : [];
|
|
65
|
+
delete options.repo;
|
|
66
|
+
return { options: normalizeSource(options), repos };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve { fileLayer, envLayer, envRepos }. */
|
|
70
|
+
function loadLayers({ cwd = process.cwd(), configPath = null, envStr = process.env.FAD_CHECKER_ENV, program = null } = {}) {
|
|
71
|
+
let fileLayer = {};
|
|
72
|
+
const chosen = configPath || path.join(cwd, ".fad-env.json");
|
|
73
|
+
if (configPath) fileLayer = loadConfigFile(chosen);
|
|
74
|
+
else if (fs.existsSync(chosen)) fileLayer = loadConfigFile(chosen);
|
|
75
|
+
fileLayer = normalizeSource(fileLayer || {});
|
|
76
|
+
let envLayer = {}, envRepos = [];
|
|
77
|
+
if (envStr && program) {
|
|
78
|
+
// If FAD_CHECKER_ENV points to a readable file, treat its content as flags too.
|
|
79
|
+
let s = envStr;
|
|
80
|
+
try { if (fs.existsSync(envStr) && fs.statSync(envStr).isFile()) s = fs.readFileSync(envStr, "utf8"); } catch { /* inline */ }
|
|
81
|
+
const parsed = parseEnvFlags(s, program);
|
|
82
|
+
envLayer = parsed.options; envRepos = parsed.repos;
|
|
83
|
+
}
|
|
84
|
+
return { fileLayer, envLayer, envRepos };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Merge layers onto the parsed program. A file/env/global value fills an option
|
|
89
|
+
* ONLY when the CLI did not set it (source default/undefined). Order: file > env
|
|
90
|
+
* > global. Returns the effective options object (a copy of program.opts()).
|
|
91
|
+
*/
|
|
92
|
+
function applyLayers(program, layers = {}, globalStore = {}) {
|
|
93
|
+
const eff = normalizeSource(program.opts());
|
|
94
|
+
const fileLayer = normalizeSource(layers.fileLayer || {});
|
|
95
|
+
const envLayer = normalizeSource(layers.envLayer || {});
|
|
96
|
+
const cliSet = name => {
|
|
97
|
+
const s = program.getOptionValueSource(name);
|
|
98
|
+
return s && s !== "default";
|
|
99
|
+
};
|
|
100
|
+
const candidates = new Set([...Object.keys(fileLayer), ...Object.keys(envLayer), ...Object.keys(globalStore || {})]);
|
|
101
|
+
candidates.delete("registries"); // unioned separately
|
|
102
|
+
candidates.delete("excludePath"); // unioned separately
|
|
103
|
+
candidates.delete("source");
|
|
104
|
+
for (const name of candidates) {
|
|
105
|
+
if (cliSet(name)) continue; // CLI wins
|
|
106
|
+
if (name in fileLayer) eff[name] = fileLayer[name];
|
|
107
|
+
else if (name in envLayer) eff[name] = envLayer[name];
|
|
108
|
+
else if (globalStore && name in globalStore) eff[name] = globalStore[name];
|
|
109
|
+
}
|
|
110
|
+
return eff;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { tokenize, loadConfigFile, normalizeSource, parseEnvFlags, loadLayers, applyLayers };
|
package/lib/parallel-walk.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* into something close to a single round-trip deep, which is dramatically faster.
|
|
10
10
|
*
|
|
11
11
|
* `onDir(absDir, entries)` is called once per visited directory with its Dirent[].
|
|
12
|
-
* `skipDir(name)` prunes a child directory
|
|
12
|
+
* `skipDir(absChildDir, name)` prunes a child directory (caller's own skip policy);
|
|
13
|
+
* it receives the child's absolute path (for path-relative globs) plus its basename.
|
|
13
14
|
*
|
|
14
15
|
* @author: N.BRAUN
|
|
15
16
|
* @email: pp9ping@gmail.com
|
|
@@ -34,7 +35,9 @@ async function walkDirs(root, { skipDir = () => false, concurrency = DEFAULT_CON
|
|
|
34
35
|
fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
|
|
35
36
|
if (onDir) onDir(cur, entries);
|
|
36
37
|
for (const e of entries) {
|
|
37
|
-
if (e.isDirectory()
|
|
38
|
+
if (!e.isDirectory()) continue;
|
|
39
|
+
const child = path.join(cur, e.name);
|
|
40
|
+
if (!skipDir(child, e.name)) queue.push(child);
|
|
38
41
|
}
|
|
39
42
|
}).catch(() => { /* unreadable dir → skip, same as readdirSync catch */ })
|
|
40
43
|
.finally(() => { active--; pump(); });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/path-filter.js — directory-walk pruning policy shared by every codec walker.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* - default skips: each walker's own basename set (node_modules, vendor, target,
|
|
6
|
+
* .git, …). Bypassable with useDefaults=false (CLI --no-default-excludes).
|
|
7
|
+
* - user globs: --exclude-path / `excludePath` config, matched gitignore-style
|
|
8
|
+
* against the directory's path RELATIVE to the scan root (`srcRoot`). A bare
|
|
9
|
+
* `foo/bar` matches that directory and its whole subtree (`foo/bar/**`).
|
|
10
|
+
*
|
|
11
|
+
* makeDirFilter() returns a predicate over a child directory's ABSOLUTE path, so
|
|
12
|
+
* it drops straight into parallel-walk's skipDir and the serial readdir walkers.
|
|
13
|
+
*
|
|
14
|
+
* @author: N.BRAUN
|
|
15
|
+
* @email: pp9ping@gmail.com
|
|
16
|
+
*/
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { minimatch } = require("minimatch");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile glob strings into (relPath) → bool matchers. Each glob prunes both the
|
|
22
|
+
* matched directory itself and its whole subtree, so `packages/legacy/**` (or the
|
|
23
|
+
* bare `packages/legacy`) stops the walk at `packages/legacy` — a manifest sitting
|
|
24
|
+
* directly in it is never collected.
|
|
25
|
+
*/
|
|
26
|
+
function compileGlobs(globs) {
|
|
27
|
+
return (globs || []).filter(Boolean).map(String).map(g => g.trim()).filter(Boolean).map(g => {
|
|
28
|
+
// Paths match relative to srcRoot, so they're already root-anchored. Accept
|
|
29
|
+
// `truc`, `/truc` and `./truc` as the same thing (strip a leading ./ or /).
|
|
30
|
+
const base = g.replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
31
|
+
const patterns = base.endsWith("/**")
|
|
32
|
+
? [base, base.slice(0, -3).replace(/\/+$/, "")] // dir + its subtree
|
|
33
|
+
: [base, base + "/**"];
|
|
34
|
+
return rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a skipDir(absChildDir) predicate.
|
|
40
|
+
* srcRoot scan root the globs are relative to
|
|
41
|
+
* defaultSkip Set of basenames the walker prunes by default (its own SKIP)
|
|
42
|
+
* excludePath user glob strings
|
|
43
|
+
* useDefaults when false, ignore defaultSkip entirely (--no-default-excludes)
|
|
44
|
+
*/
|
|
45
|
+
function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true } = {}) {
|
|
46
|
+
const matchers = compileGlobs(excludePath);
|
|
47
|
+
return function skipDir(absChild) {
|
|
48
|
+
const name = path.basename(absChild);
|
|
49
|
+
if (useDefaults && defaultSkip && defaultSkip.has(name)) return true;
|
|
50
|
+
if (matchers.length && srcRoot) {
|
|
51
|
+
const rel = path.relative(srcRoot, absChild).split(path.sep).join("/");
|
|
52
|
+
if (rel && !rel.startsWith("..") && matchers.some(m => m(rel))) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { makeDirFilter, compileGlobs };
|