fad-checker 2.2.4 → 2.3.1
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/README.md +12 -9
- package/fad-checker.js +59 -22
- package/lib/codecs/composer.codec.js +4 -1
- package/lib/codecs/go.codec.js +4 -1
- package/lib/codecs/gradle/catalog.js +91 -0
- package/lib/codecs/gradle/parse.js +211 -0
- package/lib/codecs/gradle.codec.js +155 -0
- package/lib/codecs/index.js +3 -2
- package/lib/codecs/maven.codec.js +1 -1
- package/lib/codecs/npm/collect.js +3 -0
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget.codec.js +8 -2
- package/lib/codecs/pypi.codec.js +37 -14
- package/lib/codecs/recipes.js +18 -1
- package/lib/codecs/ruby.codec.js +3 -1
- package/lib/cpe.js +39 -2
- package/lib/cve-match.js +20 -1
- package/lib/cve-report.js +58 -7
- package/lib/deps-descriptor.js +2 -2
- package/lib/osv-db.js +1 -1
- package/lib/osv.js +19 -5
- package/lib/retire.js +1 -1
- package/lib/sarif-export.js +1 -1
- package/package.json +5 -6
package/lib/cpe.js
CHANGED
|
@@ -147,6 +147,31 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
|
147
147
|
return false;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Does ANY vulnerable cpeMatch in this CVE name the dep's product/vendor — IGNORING the
|
|
152
|
+
* version range? This distinguishes "NVD has a CPE for this product but the dep version is
|
|
153
|
+
* out of range" (a genuine false positive worth filtering) from "NVD's CPE never names this
|
|
154
|
+
* product at all" (a coverage gap — NOT a false positive: the dep's own matcher, OSV or the
|
|
155
|
+
* CVE index, found it on a real coordinate, and CPE simply can't speak to it).
|
|
156
|
+
*/
|
|
157
|
+
function cveCpeNamesDep(cveRecord, dep, cpeCoordMap) {
|
|
158
|
+
const map = cpeCoordMap || loadCpeCoordMap();
|
|
159
|
+
const scan = nodes => {
|
|
160
|
+
for (const n of (nodes || [])) {
|
|
161
|
+
for (const m of (n.cpeMatch || n.cpe_match || [])) {
|
|
162
|
+
if (m.vulnerable === false) continue;
|
|
163
|
+
const parsed = parseCpe23Cached(m);
|
|
164
|
+
if (parsed && cpeMatchesDep(parsed, dep, map)) return true;
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(n.children) && scan(n.children)) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
};
|
|
170
|
+
for (const cfg of (cveRecord?.configurations || [])) if (scan(cfg.nodes)) return true;
|
|
171
|
+
for (const uri of (cveRecord?.cpes || [])) { const p = parseCpe23(uri); if (p && cpeMatchesDep(p, dep, map)) return true; }
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
/**
|
|
151
176
|
* A node is "context-only" when it carries no vulnerable cpeMatch (recursively) —
|
|
152
177
|
* e.g. the "…AND runs on linux_kernel" platform node, whose CPEs are all
|
|
@@ -309,8 +334,19 @@ function refineMatchesWithCpe(matches, opts = {}) {
|
|
|
309
334
|
if (!rec.configurations.length && !rec.cpes.length) continue;
|
|
310
335
|
const { affected, confidence } = evaluateCveForDep(rec, m.dep, map);
|
|
311
336
|
m.cpeConfidence = confidence;
|
|
312
|
-
|
|
313
|
-
|
|
337
|
+
// Mark a match as a CPE false-positive ONLY for a genuine VERSION contradiction:
|
|
338
|
+
// NVD's CPE names this product but the dep version is outside the vulnerable range.
|
|
339
|
+
// NEVER filter when:
|
|
340
|
+
// - the match is OSV-confirmed — OSV did a precise, ecosystem-native version match
|
|
341
|
+
// (its data is authoritative for the package ecosystems; don't let NVD override it);
|
|
342
|
+
// - NVD's CPE never names this product (`!productNamed`) — that's a coverage gap, not
|
|
343
|
+
// a false positive (covers every pypi/composer/nuget/go/ruby coord, and any maven/
|
|
344
|
+
// npm artifact whose CPE product name simply differs from its coordinate).
|
|
345
|
+
// Without these guards the filter silently hid REAL CVEs (e.g. cryptography 46.0.5,
|
|
346
|
+
// fixed in 46.0.6) by dumping them into the false-positives appendix.
|
|
347
|
+
const isOsv = String(m.source || "").includes("osv");
|
|
348
|
+
const productNamed = cveCpeNamesDep(rec, m.dep, map);
|
|
349
|
+
if (!affected && productNamed && !isOsv) {
|
|
314
350
|
m.cpeFiltered = true;
|
|
315
351
|
}
|
|
316
352
|
if (affected && confidence === "exact" && m.confidence !== "exact") m.confidence = "exact";
|
|
@@ -325,6 +361,7 @@ module.exports = {
|
|
|
325
361
|
cpeMatchesDep,
|
|
326
362
|
nodeAffectsDep,
|
|
327
363
|
evaluateCveForDep,
|
|
364
|
+
cveCpeNamesDep,
|
|
328
365
|
refineMatchesWithCpe,
|
|
329
366
|
loadCpeCoordMap,
|
|
330
367
|
};
|
package/lib/cve-match.js
CHANGED
|
@@ -122,6 +122,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
122
122
|
* for transitive deps. This mirrors Maven's behaviour where the root
|
|
123
123
|
* project's <dependencyManagement> wins over transitive depMgmt.
|
|
124
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* A transitive coord is itself a Maven artifact (ecosystem "maven"), but for REPORT
|
|
127
|
+
* grouping we attribute its ecosystemType to the build tool that pulled it in: if every
|
|
128
|
+
* root reaching it (the first node of each via-chain) is a Gradle direct dep, it belongs
|
|
129
|
+
* in the Gradle chapter; a Maven (or mixed/unknown) root keeps it in the Maven chapter.
|
|
130
|
+
* @param viaPaths array of chains [[rootCoord, …], …]; @param rootEcoType Map coordKey→type
|
|
131
|
+
*/
|
|
132
|
+
function inheritEcoType(viaPaths, rootEcoType) {
|
|
133
|
+
const roots = (viaPaths || []).map(p => p && p[0]).filter(Boolean);
|
|
134
|
+
const types = new Set(roots.map(r => rootEcoType.get(r)).filter(Boolean));
|
|
135
|
+
return (types.size === 1 && types.has("gradle")) ? "gradle" : "maven";
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
126
139
|
const { verbose } = opts;
|
|
127
140
|
// Root depMgmt: every dep in the Map that has a concrete version becomes a
|
|
@@ -146,6 +159,11 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
146
159
|
|
|
147
160
|
if (verbose) console.log(`🌳 Resolving transitives for ${directs.length} direct deps…`);
|
|
148
161
|
|
|
162
|
+
// Root → ecosystemType, so a transitive pulled only by Gradle roots is reported in the
|
|
163
|
+
// Gradle chapter (not the Maven one) on a pure-Gradle project.
|
|
164
|
+
const rootEcoType = new Map();
|
|
165
|
+
for (const d of directs) rootEcoType.set(`${d.groupId}:${d.artifactId}`, d.ecosystemType || d.ecosystem || "maven");
|
|
166
|
+
|
|
149
167
|
const transitives = await resolveTransitiveDeps(directs, {
|
|
150
168
|
...opts,
|
|
151
169
|
rootDepMgmt,
|
|
@@ -165,7 +183,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
165
183
|
viaPaths: t.viaPaths || [t.via],
|
|
166
184
|
depth: t.depth,
|
|
167
185
|
ecosystem: "maven",
|
|
168
|
-
ecosystemType:
|
|
186
|
+
ecosystemType: inheritEcoType(t.viaPaths || [t.via], rootEcoType),
|
|
169
187
|
});
|
|
170
188
|
added++;
|
|
171
189
|
}
|
|
@@ -281,6 +299,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
|
281
299
|
module.exports = {
|
|
282
300
|
collectResolvedDeps,
|
|
283
301
|
expandWithTransitives,
|
|
302
|
+
inheritEcoType,
|
|
284
303
|
matchDepsAgainstCves,
|
|
285
304
|
resolveDepVersion,
|
|
286
305
|
vendorMatchesGroup,
|
package/lib/cve-report.js
CHANGED
|
@@ -418,6 +418,53 @@ function makeRelative(absPath, srcRoot) {
|
|
|
418
418
|
} catch { return absPath; }
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
// Inventory of the manifest/lockfile descriptors fad actually parsed and fed into the
|
|
422
|
+
// scan: the union of every declared dep's manifestPaths (relative to the src root), with
|
|
423
|
+
// the ecosystem(s) and the count of direct deps each contributed. Transitive deps (resolved
|
|
424
|
+
// from a registry, no local file) and committed binaries (embedded/native — inventoried in
|
|
425
|
+
// chapters 1B/1C, not "descriptors") are excluded.
|
|
426
|
+
function buildScannedManifests(resolvedDeps, srcRoot, parsedManifests = []) {
|
|
427
|
+
const byPath = new Map();
|
|
428
|
+
const touch = (relPath, eco) => {
|
|
429
|
+
let e = byPath.get(relPath);
|
|
430
|
+
if (!e) { e = { path: relPath, ecosystems: new Set(), count: 0 }; byPath.set(relPath, e); }
|
|
431
|
+
if (eco) e.ecosystems.add(eco);
|
|
432
|
+
return e;
|
|
433
|
+
};
|
|
434
|
+
// Files that contributed at least one scanned dependency (count = direct deps).
|
|
435
|
+
for (const dep of (resolvedDeps?.values?.() || [])) {
|
|
436
|
+
if (dep.scope === "transitive") continue;
|
|
437
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
438
|
+
const paths = (dep.manifestPaths && dep.manifestPaths.length) ? dep.manifestPaths
|
|
439
|
+
: (dep.pomPaths && dep.pomPaths.length) ? dep.pomPaths : [];
|
|
440
|
+
for (const p of paths) touch(makeRelative(p, srcRoot), dep.ecosystemType || dep.ecosystem || "?").count++;
|
|
441
|
+
}
|
|
442
|
+
// EVERY descriptor each codec reported parsing — incl. ones that contributed nothing
|
|
443
|
+
// (ranges-only / no lockfile). These land with count 0 so nothing is silently omitted.
|
|
444
|
+
for (const pm of (parsedManifests || [])) {
|
|
445
|
+
if (!pm || !pm.path) continue;
|
|
446
|
+
touch(makeRelative(pm.path, srcRoot), pm.ecosystemType);
|
|
447
|
+
}
|
|
448
|
+
return [...byPath.values()]
|
|
449
|
+
.map(e => ({ path: e.path, ecosystems: [...e.ecosystems].sort(), count: e.count }))
|
|
450
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function renderScannedManifests(list) {
|
|
454
|
+
if (!list.length) return `<div class="empty">No dependency descriptors were parsed (deps came only from registries or committed binaries).</div>`;
|
|
455
|
+
const emptyN = list.filter(e => e.count === 0).length;
|
|
456
|
+
const intro = `<div class="fp-intro">Every manifest / lockfile descriptor fad-checker parsed for this scan (paths relative to the source root) — the complete list, including files that contributed <strong>no scannable dependency</strong> (only version ranges, or no lockfile)${emptyN ? `: ${emptyN} such file${emptyN > 1 ? "s" : ""} shown with <code>0</code>` : ""}. Transitive deps resolved from registries, and committed binaries (chapters 1B/1C), are not descriptors and are excluded here.</div>`;
|
|
457
|
+
const rows = list.map(e => `<tr>
|
|
458
|
+
<td class="dep"><code>${esc(e.path)}</code></td>
|
|
459
|
+
<td>${esc(e.ecosystems.map(x => ECO_LABELS[x] || x).join(", "))}</td>
|
|
460
|
+
<td>${e.count > 0 ? e.count : `<span style="color:#9ca3af">0 <span style="font-style:italic">— ranges / no lockfile</span></span>`}</td>
|
|
461
|
+
</tr>`).join("\n");
|
|
462
|
+
return intro + `<table>
|
|
463
|
+
<thead><tr><th>Descriptor</th><th>Ecosystem</th><th>Direct deps</th></tr></thead>
|
|
464
|
+
<tbody>${rows}</tbody>
|
|
465
|
+
</table>`;
|
|
466
|
+
}
|
|
467
|
+
|
|
421
468
|
// Présentation des coordonnées, déléguée au codec. Règle générique : le codec
|
|
422
469
|
// fournit la coord canonique (maven "g:a", npm "name", composer "vendor/pkg"…) ;
|
|
423
470
|
// Maven la porte déjà son namespace, les autres reçoivent un tag d'écosystème
|
|
@@ -1007,7 +1054,7 @@ function renderObsoleteTable(obs) {
|
|
|
1007
1054
|
if (!obs?.length) return `<div class="empty">No obsolete / deprecated libraries detected.</div>`;
|
|
1008
1055
|
const rows = obs.map(o => `<tr>
|
|
1009
1056
|
<td>${badge((o.severity || "MEDIUM").toUpperCase())}</td>
|
|
1010
|
-
<td class="dep">${esc(depDisplayName(o.dep))}<br><span style="color:#6b7280">${esc(o.dep.version || "?")}</span>${
|
|
1057
|
+
<td class="dep">${esc(depDisplayName(o.dep))}<br><span style="color:#6b7280">${esc(o.dep.version || "?")}</span>${renderDepOrigin(o.dep, RENDER_CTX.srcRoot)}</td>
|
|
1011
1058
|
<td class="dep">${esc(o.replacement || "—")}</td>
|
|
1012
1059
|
<td class="desc">${esc(o.reason || "")}</td>
|
|
1013
1060
|
</tr>`).join("\n");
|
|
@@ -1141,7 +1188,7 @@ function minorSection(title, contentHtml, opts = {}) {
|
|
|
1141
1188
|
}
|
|
1142
1189
|
|
|
1143
1190
|
const WARNING_HEADINGS = {
|
|
1144
|
-
"no-lockfile": { icon: "⚠️", title: "
|
|
1191
|
+
"no-lockfile": { icon: "⚠️", title: "Manifest without a lockfile — best-effort (ranges skipped)" },
|
|
1145
1192
|
"yarn-berry-unsupported": { icon: "⚠️", title: "Yarn 2+/Berry lockfile" },
|
|
1146
1193
|
"unresolved-versions": { icon: "⚠️", title: "Maven deps without a concrete version — silently skipped, run a real Maven/Snyk scan to complete" },
|
|
1147
1194
|
"private-libs": { icon: "🔒", title: "Private / internal libraries — not on Maven Central, not scanned" },
|
|
@@ -1438,7 +1485,7 @@ function renderLicenseChapter(licenseResults) {
|
|
|
1438
1485
|
return intro + blocks;
|
|
1439
1486
|
}
|
|
1440
1487
|
|
|
1441
|
-
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1488
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
|
|
1442
1489
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1443
1490
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1444
1491
|
cveMatches = mergeMatches(cveMatches || []);
|
|
@@ -1504,9 +1551,11 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1504
1551
|
// (C) Table of contents — rendered as a sticky nav. Only enumerates the
|
|
1505
1552
|
// chapters that actually have content (warnings, dev CVE, retire, FP appendix
|
|
1506
1553
|
// might be empty).
|
|
1554
|
+
const scanned = buildScannedManifests(resolvedDeps, projectInfo?.src, parsedManifests);
|
|
1507
1555
|
const toc = renderToc({
|
|
1508
1556
|
hasWarnings: !!(warnings?.length),
|
|
1509
1557
|
prodTotal: prodStats.total,
|
|
1558
|
+
scannedTotal: scanned.length,
|
|
1510
1559
|
retireTotal: retireMatches.length,
|
|
1511
1560
|
devTotal: devStats.total,
|
|
1512
1561
|
eolTotal: eolResults?.length || 0,
|
|
@@ -1528,7 +1577,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1528
1577
|
<h1>FAD-Checker Report</h1>
|
|
1529
1578
|
<div class="report-subtitle">Multi-ecosystem dependency security audit</div>
|
|
1530
1579
|
<div class="meta">
|
|
1531
|
-
Project: <strong>${esc(projectInfo.name)}</strong
|
|
1580
|
+
Project: <strong>${esc(projectInfo.name)}</strong><br>
|
|
1532
1581
|
Generated: ${esc(projectInfo.generatedAt)}${projectInfo.toolVersion ? ` · fad-checker ${esc(projectInfo.toolVersion)}` : ""}${projectInfo.cveDataDate ? ` · CVE data: ${esc(projectInfo.cveDataDate)}` : ""}
|
|
1533
1582
|
</div>
|
|
1534
1583
|
</header>
|
|
@@ -1551,6 +1600,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1551
1600
|
${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
|
|
1552
1601
|
${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
|
|
1553
1602
|
${(prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length) ? majorSection(`9. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length})`, fpContent, { open: false, id: "ch9" }) : ""}
|
|
1603
|
+
${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
|
|
1554
1604
|
`;
|
|
1555
1605
|
return wrapTables ? wrapTablesWithCopyButtons(body) : body;
|
|
1556
1606
|
}
|
|
@@ -1614,7 +1664,7 @@ function pickTopEol(eolResults, n) {
|
|
|
1614
1664
|
}).slice(0, n);
|
|
1615
1665
|
}
|
|
1616
1666
|
|
|
1617
|
-
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1667
|
+
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal }) {
|
|
1618
1668
|
const entries = [];
|
|
1619
1669
|
if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
|
|
1620
1670
|
entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
|
|
@@ -1629,6 +1679,7 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
|
|
|
1629
1679
|
entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
|
|
1630
1680
|
entries.push({ id: "ch8", label: `8. Fix Recos` });
|
|
1631
1681
|
if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
|
|
1682
|
+
entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
|
|
1632
1683
|
return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
|
|
1633
1684
|
}
|
|
1634
1685
|
|
|
@@ -2169,12 +2220,12 @@ ${WORD_SECTION_PR}
|
|
|
2169
2220
|
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
2170
2221
|
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
2171
2222
|
// Back-compat: `outputDir` still writes both under the default file names.
|
|
2172
|
-
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
2223
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
|
|
2173
2224
|
if (outputDir) {
|
|
2174
2225
|
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
2175
2226
|
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
2176
2227
|
}
|
|
2177
|
-
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
2228
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests };
|
|
2178
2229
|
const written = { htmlPath: null, docPath: null };
|
|
2179
2230
|
if (htmlPath) {
|
|
2180
2231
|
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
package/lib/deps-descriptor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/deps-descriptor.js — serialize / deserialize an *anonymized* descriptor of
|
|
3
|
-
* the resolved dependency set, for
|
|
3
|
+
* the resolved dependency set, for air-gapped offline→online→offline audits.
|
|
4
4
|
*
|
|
5
5
|
* Phase 1 (offline): collect deps → serializeDeps → write JSON. No paths, URLs,
|
|
6
6
|
* hostnames or usernames leave the air-gapped machine — only public package
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Phase 3 (offline): `--import-cache` + a normal `--offline` scan re-collects the
|
|
11
11
|
* source locally (real paths) and gets cache hits → full detailed report.
|
|
12
12
|
*
|
|
13
|
-
* See
|
|
13
|
+
* See the anonymized-deps-descriptor design spec.
|
|
14
14
|
*
|
|
15
15
|
* Pure functions: no I/O, no console. The caller does file read/write.
|
|
16
16
|
*
|
package/lib/osv-db.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* and matches EVERY dep against it offline, deterministically, regardless of the per-dep
|
|
10
10
|
* cache. That is exactly the model OSV-Scanner uses for air-gapped scans
|
|
11
11
|
* (`--download-offline-databases`), and it makes fad's offline Maven recall complete and
|
|
12
|
-
* cache-independent for
|
|
12
|
+
* cache-independent for an air-gapped engagement.
|
|
13
13
|
*
|
|
14
14
|
* Maven only for now: range matching needs the ecosystem's version ordering, and fad has
|
|
15
15
|
* a robust Maven comparator (lib/maven-version). npm/PyPI/etc. are a documented follow-up
|
package/lib/osv.js
CHANGED
|
@@ -121,18 +121,32 @@ function severityFromOsv(vuln) {
|
|
|
121
121
|
|
|
122
122
|
function scoreFromVuln(vuln) { return cvssInfoFromVuln(vuln).score; }
|
|
123
123
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Pick the fix version to recommend from OSV's affected ranges. A multi-branch advisory
|
|
126
|
+
* (e.g. Tomcat fixed in 9.0.118, 10.1.55 AND 11.0.22) lists one `fixed` per branch — the
|
|
127
|
+
* old code returned the FIRST, which for an 11.0.21 dep meant "upgrade to 9.0.118", i.e. a
|
|
128
|
+
* DOWNGRADE. Instead pick the smallest fix strictly ABOVE the current version: that's the
|
|
129
|
+
* next patched release on the dep's own branch. If none is greater (the branch has no
|
|
130
|
+
* published fix, or the dep is already patched), return null rather than suggest a downgrade.
|
|
131
|
+
*/
|
|
132
|
+
function fixVersionFromOsv(vuln, depKey, depVersion) {
|
|
133
|
+
const { compareMavenVersions } = require("./maven-version");
|
|
134
|
+
const fixes = [];
|
|
126
135
|
for (const a of vuln.affected || []) {
|
|
127
136
|
const name = a.package?.name?.toLowerCase();
|
|
128
137
|
if (name && name !== depKey.toLowerCase()) continue;
|
|
129
138
|
for (const r of a.ranges || []) {
|
|
130
139
|
for (const ev of r.events || []) {
|
|
131
|
-
if (ev.fixed)
|
|
140
|
+
if (ev.fixed) fixes.push(String(ev.fixed));
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
143
|
}
|
|
135
|
-
return null;
|
|
144
|
+
if (!fixes.length) return null;
|
|
145
|
+
const cmp = (x, y) => { try { return compareMavenVersions(x, y); } catch { return String(x).localeCompare(String(y)); } };
|
|
146
|
+
const sorted = [...fixes].sort(cmp);
|
|
147
|
+
if (!depVersion) return sorted[0]; // no current version to compare against → lowest published fix
|
|
148
|
+
const upgrades = sorted.filter(f => cmp(f, depVersion) > 0);
|
|
149
|
+
return upgrades.length ? upgrades[0] : null;
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
/** Pick the best CVE id from an OSV vuln (prefer CVE-* aliases over GHSA-*). */
|
|
@@ -188,7 +202,7 @@ function vulnToMatch(dep, vuln) {
|
|
|
188
202
|
...(cvss.version ? { cvssVersion: `CVSS:${cvss.version}` } : {}),
|
|
189
203
|
description,
|
|
190
204
|
summary,
|
|
191
|
-
fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep)),
|
|
205
|
+
fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep), dep.version),
|
|
192
206
|
ghsa: (vuln.aliases || []).find(a => a.startsWith("GHSA-")) || (vuln.id?.startsWith("GHSA-") ? vuln.id : null),
|
|
193
207
|
published: vuln.published || null,
|
|
194
208
|
modified: vuln.modified || null,
|
package/lib/retire.js
CHANGED
|
@@ -29,7 +29,7 @@ const RETIRE_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
|
29
29
|
|
|
30
30
|
// retire's own signature DB. By default retire caches it in /tmp/.retire-cache
|
|
31
31
|
// (outside ~/.fad-checker/, with a 1h TTL → a network refetch on expiry). For the
|
|
32
|
-
//
|
|
32
|
+
// air-gapped offline workflow we instead keep a stable local copy INSIDE ~/.fad-checker/
|
|
33
33
|
// so `--export-cache` carries it, and feed it to retire via `--jsrepo <file>`
|
|
34
34
|
// (loaded from file, never the network — no TTL).
|
|
35
35
|
const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signatures");
|
package/lib/sarif-export.js
CHANGED
|
@@ -116,7 +116,7 @@ function buildSarif(matches, opts = {}) {
|
|
|
116
116
|
driver: {
|
|
117
117
|
name: "fad-checker",
|
|
118
118
|
version: String(toolVersion),
|
|
119
|
-
informationUri: "https://github.com/
|
|
119
|
+
informationUri: "https://github.com/9pings/fad-checker",
|
|
120
120
|
rules: [...rulesById.values()],
|
|
121
121
|
},
|
|
122
122
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Scan ALL Maven, Gradle, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sca",
|
|
7
7
|
"software-composition-analysis",
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"supply-chain",
|
|
30
30
|
"offline",
|
|
31
31
|
"air-gapped",
|
|
32
|
-
"passi",
|
|
33
32
|
"maven",
|
|
34
33
|
"npm",
|
|
35
34
|
"yarn",
|
|
@@ -46,9 +45,9 @@
|
|
|
46
45
|
"cli"
|
|
47
46
|
],
|
|
48
47
|
"license": "MIT",
|
|
49
|
-
"repository": "https://github.com/
|
|
50
|
-
"homepage": "https://github.com/
|
|
51
|
-
"bugs": "https://github.com/
|
|
48
|
+
"repository": "https://github.com/9pings/fad-checker",
|
|
49
|
+
"homepage": "https://github.com/9pings/fad-checker#readme",
|
|
50
|
+
"bugs": "https://github.com/9pings/fad-checker/issues",
|
|
52
51
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
53
52
|
"maintainers": [
|
|
54
53
|
"pp9Ping <pp9Ping@gmail.com>"
|