fad-checker 2.3.0 → 2.3.2
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 +28 -0
- package/README.md +5 -2
- package/fad-checker.js +19 -6
- package/lib/codecs/gradle.codec.js +1 -1
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/cve-report.js +22 -5
- package/lib/json-export.js +3 -1
- package/lib/nvd.js +14 -3
- package/lib/osv.js +19 -5
- package/lib/path-filter.js +85 -6
- package/lib/retire.js +77 -15
- package/lib/sarif-export.js +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -32,6 +32,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
32
32
|
across every ecosystem. Repeatable; also `excludePath: [...]` in `.fad-env.json`,
|
|
33
33
|
unioned across config layers. **`--no-default-excludes`** walks the normally
|
|
34
34
|
pruned dirs (`node_modules`, `vendor`, `target`, `.git`, …). New `lib/path-filter.js`.
|
|
35
|
+
- **Ignored-directories appendix (report chapter 11 + JSON `excludedDirs`).** The
|
|
36
|
+
HTML/`.doc` report now ends with an appendix listing the ACTUAL directories the
|
|
37
|
+
scan did not walk — resolved by re-walking `--src` once under the same prune
|
|
38
|
+
policy the codecs use (the default-exclude set at any depth + your
|
|
39
|
+
`--exclude-path` rules), each path shown relative to the scan root and tagged
|
|
40
|
+
with the rule that pruned it (`default` vs `--exclude-path`). Surfaced in the
|
|
41
|
+
findings JSON as `excludedDirs[]` + `summary.excludedDirs`. New
|
|
42
|
+
`collectExcludedDirs()` in `lib/path-filter.js`.
|
|
35
43
|
|
|
36
44
|
### Changed
|
|
37
45
|
- **BREAKING:** the persisted-registry store moved from the Maven-only
|
|
@@ -40,6 +48,26 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
40
48
|
the `<ecosystem>=<url>` form (a bare URL is rejected). Re-add any private Maven
|
|
41
49
|
repos with `--add-repo maven <name> <url>`.
|
|
42
50
|
|
|
51
|
+
### Fixed
|
|
52
|
+
- **retire.js now skips the same dirs as the rest of the scan.** The vendored-JS
|
|
53
|
+
scan walks the tree itself and was handed a bare `--ignore node_modules,…` list,
|
|
54
|
+
which retire `path.resolve()`s against its **own working directory** — so a
|
|
55
|
+
`node_modules` (or `target`/`dist`/…) nested anywhere under `--src` was scanned
|
|
56
|
+
whenever fad-checker ran from a different directory than the source tree. retire
|
|
57
|
+
is now driven by a generated `--ignorefile` anchored to `--src` that prunes the
|
|
58
|
+
default SKIP dirs **at any depth** and honors `--exclude-path` /
|
|
59
|
+
`--no-default-excludes`, matching `lib/path-filter.js`.
|
|
60
|
+
- **Offline NVD enrichment (incl. CWEs) no longer silently dropped.** The NVD cache
|
|
61
|
+
enforces a 7-day TTL and a schema version; offline, a TTL-expired or older-schema
|
|
62
|
+
entry was treated as a miss — and since an air-gapped box can't re-fetch, the CVE
|
|
63
|
+
lost **all** its NVD enrichment (CWE list, CVSS vector, references, CPE configs).
|
|
64
|
+
That was the "offline scan was missing some CWE titles that the online scan had".
|
|
65
|
+
Offline now reads the warmed cache regardless of age/schema (a missing field just
|
|
66
|
+
stays missing — strictly better than dropping everything); online still enforces
|
|
67
|
+
TTL + schema so it re-fetches and upgrades. CWE IDs were already persisted in the
|
|
68
|
+
cache body (`_schema:2`) and travel in `--export-cache`; CWE *titles* come from the
|
|
69
|
+
bundled `data/cwe-names.json` (identical online/offline).
|
|
70
|
+
|
|
43
71
|
## [2.1.0]
|
|
44
72
|
|
|
45
73
|
### Added
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/fad-checker)
|
|
4
4
|
[](https://www.npmjs.com/package/fad-checker)
|
|
5
|
-
[](https://github.com/
|
|
5
|
+
[](https://github.com/9pings/fad-checker/blob/main/package.json)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
|
|
8
8
|
> **F**abulous **A**utonomous **D**ependency **C**hecker<br>
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
`fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
|
|
12
12
|
|
|
13
|
-
🌐 **[Project site & docs →](https://
|
|
13
|
+
🌐 **[Project site & docs →](https://9pings.github.io/fad-checker/)**
|
|
14
|
+
|
|
15
|
+
> [!WARNING]
|
|
16
|
+
> **Young project — expect rough edges.** fad-checker is new and under active development, so it may still contain bugs (including false positives and false negatives). Treat its output as a strong first pass, **double-check anything critical**, and please [report issues](https://github.com/9pings/fad-checker/issues) — they get fixed fast.
|
|
14
17
|
|
|
15
18
|
<p align="center"><img src="docs/assets/demo.gif" alt="fad-checker animated terminal demo — a [n/N] checklist warming each vulnerability database, then CVE findings coloured by severity with KEV badges" height="600"></p>
|
|
16
19
|
|
package/fad-checker.js
CHANGED
|
@@ -495,7 +495,7 @@ async function timedPhase(label, fn) {
|
|
|
495
495
|
// (still honor an explicit --report-<type> if a user really wants a path-free one).
|
|
496
496
|
const anyReportRequested = [options.reportHtml, options.reportDoc, options.reportSbom, options.reportCsaf, options.reportJson, options.reportSarif].some(v => v !== undefined);
|
|
497
497
|
if (!anyReportRequested) options.report = false;
|
|
498
|
-
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
|
|
498
|
+
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [], walkOpts });
|
|
499
499
|
return;
|
|
500
500
|
}
|
|
501
501
|
|
|
@@ -716,7 +716,7 @@ async function timedPhase(label, fn) {
|
|
|
716
716
|
// The scan always runs — it feeds the terminal summary, the file outputs and the
|
|
717
717
|
// CI gate. Which files get written is decided by the --report-* family inside
|
|
718
718
|
// (HTML + .doc by default; --no-report writes nothing).
|
|
719
|
-
await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests });
|
|
719
|
+
await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests, walkOpts });
|
|
720
720
|
if (!readOnly) {
|
|
721
721
|
ui.section("Next step");
|
|
722
722
|
ui.info(`run Snyk on the cleaned tree:`);
|
|
@@ -725,7 +725,8 @@ async function timedPhase(label, fn) {
|
|
|
725
725
|
})();
|
|
726
726
|
|
|
727
727
|
async function runReportFlow(resolved, ecoFlags = {}) {
|
|
728
|
-
const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [] } = ecoFlags;
|
|
728
|
+
const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [], walkOpts = {} } = ecoFlags;
|
|
729
|
+
const { excludePath = [], defaultExcludes = true } = walkOpts;
|
|
729
730
|
const registriesFor = eco => regMap[eco] || [];
|
|
730
731
|
const { expandWithTransitives } = require("./lib/cve-match");
|
|
731
732
|
const { writeReports, computeStats } = require("./lib/cve-report");
|
|
@@ -1045,7 +1046,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1045
1046
|
else if (!options.src) { st.skip("no source tree (descriptor import)"); }
|
|
1046
1047
|
else {
|
|
1047
1048
|
try {
|
|
1048
|
-
const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
|
|
1049
|
+
const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline, excludePath, defaultExcludes });
|
|
1049
1050
|
retireMatches = r.matches || [];
|
|
1050
1051
|
if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
|
|
1051
1052
|
const invN = vendoredJsInventory.length;
|
|
@@ -1273,6 +1274,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1273
1274
|
: { html: resolveOut("html"), doc: resolveOut("doc"), sbom: resolveOut("sbom"), csaf: resolveOut("csaf"), json: resolveOut("json"), sarif: resolveOut("sarif") };
|
|
1274
1275
|
const ensureDir = async p => { if (p) await fs.promises.mkdir(path.dirname(path.resolve(p)), { recursive: true }); };
|
|
1275
1276
|
|
|
1277
|
+
// Ignored-directories appendix: re-walk --src ONCE under the same prune policy the
|
|
1278
|
+
// codec walkers use (the default-exclude union at any depth + --exclude-path,
|
|
1279
|
+
// anchored to --src) so the report can list exactly which directories the scan
|
|
1280
|
+
// skipped, and why. Only computed when an output that renders it is requested.
|
|
1281
|
+
let excludedDirs = [];
|
|
1282
|
+
if (options.src && (out.html || out.doc || out.json)) {
|
|
1283
|
+
try {
|
|
1284
|
+
const { collectExcludedDirs } = require("./lib/path-filter");
|
|
1285
|
+
excludedDirs = collectExcludedDirs({ srcRoot: options.src, excludePath, defaultExcludes });
|
|
1286
|
+
} catch { /* best effort — never block the report on the appendix walk */ }
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1276
1289
|
const reportWarnings = [
|
|
1277
1290
|
...(suppressedCount ? [{
|
|
1278
1291
|
type: "suppressed",
|
|
@@ -1299,7 +1312,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1299
1312
|
await ensureDir(out.html); await ensureDir(out.doc);
|
|
1300
1313
|
const { htmlPath, docPath } = await writeReports({
|
|
1301
1314
|
cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
|
|
1302
|
-
eolResults, obsoleteResults, outdatedResults, licenseResults,
|
|
1315
|
+
eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs,
|
|
1303
1316
|
resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
|
|
1304
1317
|
htmlPath: out.html, docPath: out.doc,
|
|
1305
1318
|
});
|
|
@@ -1329,7 +1342,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1329
1342
|
try {
|
|
1330
1343
|
const { writeFindings } = require("./lib/json-export");
|
|
1331
1344
|
await ensureDir(out.json);
|
|
1332
|
-
writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
|
|
1345
|
+
writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
|
|
1333
1346
|
wrote.push(["Findings JSON", out.json]);
|
|
1334
1347
|
} catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
|
|
1335
1348
|
}
|
|
@@ -129,7 +129,7 @@ module.exports = {
|
|
|
129
129
|
const lockGoverned = lockedDirs.has(d) && !inBuildSrc(fp);
|
|
130
130
|
if (lockGoverned) continue; // lockfile already provided resolved deps for this dir
|
|
131
131
|
for (const dep of r.deps) addRec(dep, fp);
|
|
132
|
-
if (!inBuildSrc(fp)) warnings.push({ type: "
|
|
132
|
+
if (!inBuildSrc(fp)) warnings.push({ type: "no-lockfile", manifestPath: fp, message: `no gradle.lockfile next to ${path.relative(dir, fp) || path.basename(fp)} — versions resolved best-effort from the build script + version catalog (dynamic/programmatic deps may be missed). Enable Gradle dependency locking for exact coverage.` });
|
|
133
133
|
for (const u of r.unresolved) warnings.push({ type: "unresolved-versions", manifestPath: fp, message: `could not resolve the version variable for ${u.group}:${u.name} (${u.raw}) — excluded from CVE matching` });
|
|
134
134
|
}
|
|
135
135
|
|
package/lib/codecs/npm.codec.js
CHANGED
|
@@ -21,7 +21,7 @@ const retireScanner = {
|
|
|
21
21
|
kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
|
|
22
22
|
async scan(_deps, opts = {}) {
|
|
23
23
|
const { scanWithRetireFull } = require("../retire");
|
|
24
|
-
const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
|
|
24
|
+
const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes });
|
|
25
25
|
return { matches, meta: { inventory, error } };
|
|
26
26
|
},
|
|
27
27
|
};
|
package/lib/cve-report.js
CHANGED
|
@@ -1188,7 +1188,7 @@ function minorSection(title, contentHtml, opts = {}) {
|
|
|
1188
1188
|
}
|
|
1189
1189
|
|
|
1190
1190
|
const WARNING_HEADINGS = {
|
|
1191
|
-
"no-lockfile": { icon: "⚠️", title: "
|
|
1191
|
+
"no-lockfile": { icon: "⚠️", title: "Manifest without a lockfile — best-effort (ranges skipped)" },
|
|
1192
1192
|
"yarn-berry-unsupported": { icon: "⚠️", title: "Yarn 2+/Berry lockfile" },
|
|
1193
1193
|
"unresolved-versions": { icon: "⚠️", title: "Maven deps without a concrete version — silently skipped, run a real Maven/Snyk scan to complete" },
|
|
1194
1194
|
"private-libs": { icon: "🔒", title: "Private / internal libraries — not on Maven Central, not scanned" },
|
|
@@ -1485,7 +1485,7 @@ function renderLicenseChapter(licenseResults) {
|
|
|
1485
1485
|
return intro + blocks;
|
|
1486
1486
|
}
|
|
1487
1487
|
|
|
1488
|
-
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
|
|
1488
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
|
|
1489
1489
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1490
1490
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1491
1491
|
cveMatches = mergeMatches(cveMatches || []);
|
|
@@ -1567,6 +1567,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1567
1567
|
unmanagedTotal: unmanagedInventory.length,
|
|
1568
1568
|
vendoredJsTotal: vendoredJsInv.length,
|
|
1569
1569
|
fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
|
|
1570
|
+
excludedTotal: excludedDirs?.length || 0,
|
|
1570
1571
|
});
|
|
1571
1572
|
|
|
1572
1573
|
// Overview charts row, rendered right under the compact totals.
|
|
@@ -1601,6 +1602,7 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1601
1602
|
${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
|
|
1602
1603
|
${(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
1604
|
${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
|
|
1605
|
+
${(excludedDirs?.length || 0) ? majorSection(`11. Appendix: Ignored directories (${excludedDirs.length})`, renderExcludedDirs(excludedDirs), { open: false, id: "chexcl" }) : ""}
|
|
1604
1606
|
`;
|
|
1605
1607
|
return wrapTables ? wrapTablesWithCopyButtons(body) : body;
|
|
1606
1608
|
}
|
|
@@ -1664,7 +1666,7 @@ function pickTopEol(eolResults, n) {
|
|
|
1664
1666
|
}).slice(0, n);
|
|
1665
1667
|
}
|
|
1666
1668
|
|
|
1667
|
-
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal }) {
|
|
1669
|
+
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal, excludedTotal }) {
|
|
1668
1670
|
const entries = [];
|
|
1669
1671
|
if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
|
|
1670
1672
|
entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
|
|
@@ -1680,6 +1682,7 @@ function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vend
|
|
|
1680
1682
|
entries.push({ id: "ch8", label: `8. Fix Recos` });
|
|
1681
1683
|
if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
|
|
1682
1684
|
entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
|
|
1685
|
+
if (excludedTotal) entries.push({ id: "chexcl", label: `11. Ignored dirs (${excludedTotal})` });
|
|
1683
1686
|
return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
|
|
1684
1687
|
}
|
|
1685
1688
|
|
|
@@ -1759,6 +1762,20 @@ function renderVendoredJsInventory(inventory, srcRoot) {
|
|
|
1759
1762
|
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>`;
|
|
1760
1763
|
}
|
|
1761
1764
|
|
|
1765
|
+
function renderExcludedDirs(excludedDirs) {
|
|
1766
|
+
if (!excludedDirs || !excludedDirs.length) return `<div class="empty">No directories were excluded from the scan.</div>`;
|
|
1767
|
+
const byDefault = excludedDirs.filter(e => e.type === "default").length;
|
|
1768
|
+
const byGlob = excludedDirs.filter(e => e.type === "exclude-path").length;
|
|
1769
|
+
const intro = `<div class="fp-intro">Directories the scan <strong>did not walk</strong> — pruned by the built-in default-exclude set (package stores, build output, VCS/IDE metadata) at any depth and by your <code>--exclude-path</code> rules, anchored to <code>--src</code>. Paths are relative to the scan root. <strong>${byDefault}</strong> pruned by default rules${byGlob ? `, <strong>${byGlob}</strong> by <code>--exclude-path</code>` : ""}. Nothing under these paths was read, so any dependency, vendored JS or binary inside them is <strong>not</strong> covered — re-run with <code>--no-default-excludes</code> or drop an <code>--exclude-path</code> rule to include one.</div>`;
|
|
1770
|
+
const rows = excludedDirs.map(e => {
|
|
1771
|
+
const tag = e.type === "exclude-path"
|
|
1772
|
+
? pill("--exclude-path", "#b45309")
|
|
1773
|
+
: pill("default", "#6b7280");
|
|
1774
|
+
return `<tr><td class="dep"><code class="path">${esc(e.dir || "")}</code></td><td>${tag}</td><td><span style="color:#6b7280">${esc(e.reason || "")}</span></td></tr>`;
|
|
1775
|
+
}).join("\n");
|
|
1776
|
+
return intro + `<table><thead><tr><th>Directory (relative to --src)</th><th>Rule</th><th>Matched</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1762
1779
|
function renderFalsePositives(matches) {
|
|
1763
1780
|
if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
|
|
1764
1781
|
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>`;
|
|
@@ -2220,12 +2237,12 @@ ${WORD_SECTION_PR}
|
|
|
2220
2237
|
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
2221
2238
|
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
2222
2239
|
// Back-compat: `outputDir` still writes both under the default file names.
|
|
2223
|
-
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
|
|
2240
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
|
|
2224
2241
|
if (outputDir) {
|
|
2225
2242
|
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
2226
2243
|
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
2227
2244
|
}
|
|
2228
|
-
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, parsedManifests };
|
|
2245
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests };
|
|
2229
2246
|
const written = { htmlPath: null, docPath: null };
|
|
2230
2247
|
if (htmlPath) {
|
|
2231
2248
|
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
package/lib/json-export.js
CHANGED
|
@@ -68,7 +68,7 @@ function buildFindings(payload = {}) {
|
|
|
68
68
|
const {
|
|
69
69
|
cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
|
|
70
70
|
outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
|
|
71
|
-
typosquats = [],
|
|
71
|
+
typosquats = [], excludedDirs = [],
|
|
72
72
|
} = payload;
|
|
73
73
|
|
|
74
74
|
const { buildInventory } = require("./unmanaged");
|
|
@@ -105,6 +105,7 @@ function buildFindings(payload = {}) {
|
|
|
105
105
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
106
106
|
malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
|
|
107
107
|
typosquat: typosquats.length,
|
|
108
|
+
excludedDirs: excludedDirs.length,
|
|
108
109
|
},
|
|
109
110
|
cve: cveMatches.map(cveFinding),
|
|
110
111
|
vendored: retireMatches.map(cveFinding),
|
|
@@ -116,6 +117,7 @@ function buildFindings(payload = {}) {
|
|
|
116
117
|
embedded,
|
|
117
118
|
vendoredJs: vendoredJsInventory,
|
|
118
119
|
typosquat: typosquats,
|
|
120
|
+
excludedDirs,
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
|
package/lib/nvd.js
CHANGED
|
@@ -45,11 +45,18 @@ function cachePath(cveId) {
|
|
|
45
45
|
return path.join(NVD_CACHE_DIR, `${cveId}.json`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function readCache(cveId) {
|
|
48
|
+
function readCache(cveId, offline = false) {
|
|
49
49
|
const p = cachePath(cveId);
|
|
50
50
|
if (!fs.existsSync(p)) return null;
|
|
51
51
|
try {
|
|
52
52
|
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
53
|
+
// Offline (air-gapped): the warmed cache is the ONLY source — never reject it
|
|
54
|
+
// for age or an older schema. Dropping it would silently lose ALL NVD
|
|
55
|
+
// enrichment (CWEs, CVSS vector, references, CPE configs) for that CVE, which
|
|
56
|
+
// is exactly the "offline missed some CWE titles" regression. A stale/older
|
|
57
|
+
// body just means a field may be missing — strictly better than nothing.
|
|
58
|
+
// (Online still enforces schema + TTL so we re-fetch and upgrade.)
|
|
59
|
+
if (offline) return data.body ?? null;
|
|
53
60
|
// Schema mismatch → force re-fetch so newly-added fields (e.g. cwes) get populated.
|
|
54
61
|
if ((data._schema || 1) < NVD_CACHE_SCHEMA) return null;
|
|
55
62
|
if (Date.now() - data._fetchedAt < NVD_CACHE_TTL_MS) return data.body;
|
|
@@ -165,7 +172,7 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
165
172
|
|
|
166
173
|
async function fetchOne(cveId, opts = {}) {
|
|
167
174
|
const { fetcher = globalThis.fetch, verbose, offline } = opts;
|
|
168
|
-
const cached = readCache(cveId);
|
|
175
|
+
const cached = readCache(cveId, offline);
|
|
169
176
|
if (cached !== null && cached !== undefined) return cached;
|
|
170
177
|
if (offline) return null;
|
|
171
178
|
const headers = { "User-Agent": "fad-checker-nvd-enrich" };
|
|
@@ -210,7 +217,7 @@ async function enrichMatches(matches, opts = {}) {
|
|
|
210
217
|
const byId = new Map();
|
|
211
218
|
const liveIds = [];
|
|
212
219
|
for (const cveId of uniqueCves) {
|
|
213
|
-
const cached = readCache(cveId);
|
|
220
|
+
const cached = readCache(cveId, offline);
|
|
214
221
|
if (cached !== null && cached !== undefined) {
|
|
215
222
|
byId.set(cveId, cached);
|
|
216
223
|
continue;
|
|
@@ -296,6 +303,10 @@ module.exports = {
|
|
|
296
303
|
enrichMatches,
|
|
297
304
|
fetchOne,
|
|
298
305
|
extractFromNvdRecord,
|
|
306
|
+
readCache,
|
|
307
|
+
writeCache,
|
|
299
308
|
NVD_CACHE_DIR,
|
|
309
|
+
NVD_CACHE_SCHEMA,
|
|
310
|
+
NVD_CACHE_TTL_MS,
|
|
300
311
|
getNvdApiKey,
|
|
301
312
|
};
|
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/path-filter.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @author: N.BRAUN
|
|
15
15
|
* @email: pp9ping@gmail.com
|
|
16
16
|
*/
|
|
17
|
+
const fs = require("fs");
|
|
17
18
|
const path = require("path");
|
|
18
19
|
const { minimatch } = require("minimatch");
|
|
19
20
|
|
|
@@ -21,7 +22,9 @@ const { minimatch } = require("minimatch");
|
|
|
21
22
|
* Compile glob strings into (relPath) → bool matchers. Each glob prunes both the
|
|
22
23
|
* matched directory itself and its whole subtree, so `packages/legacy/**` (or the
|
|
23
24
|
* bare `packages/legacy`) stops the walk at `packages/legacy` — a manifest sitting
|
|
24
|
-
* directly in it is never collected.
|
|
25
|
+
* directly in it is never collected. Each matcher carries `.glob` (the original
|
|
26
|
+
* trimmed pattern) so a caller can report WHICH glob matched (see makeDirFilter's
|
|
27
|
+
* onSkip / collectExcludedDirs).
|
|
25
28
|
*/
|
|
26
29
|
function compileGlobs(globs) {
|
|
27
30
|
return (globs || []).filter(Boolean).map(String).map(g => g.trim()).filter(Boolean).map(g => {
|
|
@@ -31,28 +34,104 @@ function compileGlobs(globs) {
|
|
|
31
34
|
const patterns = base.endsWith("/**")
|
|
32
35
|
? [base, base.slice(0, -3).replace(/\/+$/, "")] // dir + its subtree
|
|
33
36
|
: [base, base + "/**"];
|
|
34
|
-
|
|
37
|
+
const fn = rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
|
|
38
|
+
fn.glob = g;
|
|
39
|
+
return fn;
|
|
35
40
|
});
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Superset of every walker's default-skip basename set (Maven core.js, detectCodecs,
|
|
45
|
+
* npm parse.js, the composer/go/nuget/pypi/ruby/gradle/binary codecs, retire.js).
|
|
46
|
+
* Used as the canonical "what the prune policy excludes" set for the report's
|
|
47
|
+
* ignored-directories appendix (collectExcludedDirs). Individual walkers still use
|
|
48
|
+
* their OWN narrower sets — this union is purely for reporting which dirs the scan
|
|
49
|
+
* skipped. Keep in sync when a codec adds a SKIP entry.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_EXCLUDE_DIRS = new Set([
|
|
52
|
+
// package stores / vendored trees
|
|
53
|
+
"node_modules", "bower_components", "jspm_packages", "vendor", "packages", "site-packages",
|
|
54
|
+
// build output
|
|
55
|
+
"target", "dist", "build", "build-output", "out", "bin", "obj", "coverage", ".next", ".nuxt",
|
|
56
|
+
// language caches / virtualenvs
|
|
57
|
+
"__pycache__", ".venv", "venv", ".tox", ".mypy_cache", ".cache", ".m2", "tmp", "testdata",
|
|
58
|
+
// VCS / IDE / tool metadata
|
|
59
|
+
".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
|
|
60
|
+
]);
|
|
61
|
+
|
|
38
62
|
/**
|
|
39
63
|
* Build a skipDir(absChildDir) predicate.
|
|
40
64
|
* srcRoot scan root the globs are relative to
|
|
41
65
|
* defaultSkip Set of basenames the walker prunes by default (its own SKIP)
|
|
42
66
|
* excludePath user glob strings
|
|
43
67
|
* useDefaults when false, ignore defaultSkip entirely (--no-default-excludes)
|
|
68
|
+
* onSkip optional (absChild, reason) callback fired when a dir is pruned;
|
|
69
|
+
* reason is { type:"default", name } or { type:"exclude-path", glob }.
|
|
44
70
|
*/
|
|
45
|
-
function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true } = {}) {
|
|
71
|
+
function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true, onSkip = null } = {}) {
|
|
46
72
|
const matchers = compileGlobs(excludePath);
|
|
47
73
|
return function skipDir(absChild) {
|
|
48
74
|
const name = path.basename(absChild);
|
|
49
|
-
if (useDefaults && defaultSkip && defaultSkip.has(name))
|
|
75
|
+
if (useDefaults && defaultSkip && defaultSkip.has(name)) {
|
|
76
|
+
if (onSkip) onSkip(absChild, { type: "default", name });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
50
79
|
if (matchers.length && srcRoot) {
|
|
51
80
|
const rel = path.relative(srcRoot, absChild).split(path.sep).join("/");
|
|
52
|
-
if (rel && !rel.startsWith("..")
|
|
81
|
+
if (rel && !rel.startsWith("..")) {
|
|
82
|
+
const hit = matchers.find(m => m(rel));
|
|
83
|
+
if (hit) {
|
|
84
|
+
if (onSkip) onSkip(absChild, { type: "exclude-path", glob: hit.glob });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
53
88
|
}
|
|
54
89
|
return false;
|
|
55
90
|
};
|
|
56
91
|
}
|
|
57
92
|
|
|
58
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Walk `srcRoot` once and return the ACTUAL directories the scan's prune policy
|
|
95
|
+
* excludes — the report's ignored-directories appendix. Applies the same policy the
|
|
96
|
+
* codec walkers do (DEFAULT_EXCLUDE_DIRS by basename + the user's --exclude-path
|
|
97
|
+
* globs, both honoring --no-default-excludes), records each pruned dir relative to
|
|
98
|
+
* the scan root with WHY it was pruned, and never descends into a pruned dir (so a
|
|
99
|
+
* top-level `node_modules` is reported once, not its thousands of children).
|
|
100
|
+
*
|
|
101
|
+
* Returns [{ dir, type, reason }] sorted by path. `dir` is POSIX-relative to srcRoot.
|
|
102
|
+
*/
|
|
103
|
+
function collectExcludedDirs({ srcRoot, excludePath = [], defaultExcludes = true } = {}) {
|
|
104
|
+
if (!srcRoot) return [];
|
|
105
|
+
let root;
|
|
106
|
+
try { root = fs.realpathSync(path.resolve(srcRoot)); } catch { root = path.resolve(srcRoot); }
|
|
107
|
+
const found = [];
|
|
108
|
+
const onSkip = (absChild, reason) => {
|
|
109
|
+
const rel = path.relative(root, absChild).split(path.sep).join("/") || ".";
|
|
110
|
+
found.push(reason.type === "default"
|
|
111
|
+
? { dir: rel, type: "default", reason: `default-exclude (${reason.name})` }
|
|
112
|
+
: { dir: rel, type: "exclude-path", reason: `--exclude-path (${reason.glob})` });
|
|
113
|
+
};
|
|
114
|
+
const skipDir = makeDirFilter({ srcRoot: root, defaultSkip: DEFAULT_EXCLUDE_DIRS, excludePath, useDefaults: defaultExcludes !== false, onSkip });
|
|
115
|
+
// Iterative DFS. We only ever descend into NON-pruned dirs, so the heavy trees
|
|
116
|
+
// (node_modules, …) are cut at the top — the walk stays bounded by the real
|
|
117
|
+
// source tree. A high guard backstops a pathological symlink-free deep tree.
|
|
118
|
+
const stack = [root];
|
|
119
|
+
let guard = 0;
|
|
120
|
+
const GUARD_MAX = 2_000_000;
|
|
121
|
+
while (stack.length && guard < GUARD_MAX) {
|
|
122
|
+
const dir = stack.pop();
|
|
123
|
+
let entries;
|
|
124
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (!e.isDirectory()) continue; // isDirectory() is false for symlinks → no loops
|
|
127
|
+
guard++;
|
|
128
|
+
const absChild = path.join(dir, e.name);
|
|
129
|
+
if (skipDir(absChild)) continue; // onSkip recorded it; do NOT descend
|
|
130
|
+
stack.push(absChild);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
found.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { makeDirFilter, compileGlobs, collectExcludedDirs, DEFAULT_EXCLUDE_DIRS };
|
package/lib/retire.js
CHANGED
|
@@ -142,9 +142,58 @@ async function ensureSignatures({ verbose, offline } = {}) {
|
|
|
142
142
|
return !!r.ok;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
145
|
+
// Directories retire never needs to walk. Mirrors the per-walker default SKIP sets
|
|
146
|
+
// the codecs use (lib/path-filter.js / npm parse.js): package stores, build output,
|
|
147
|
+
// IDE/VCS metadata. Matched by BASENAME at any depth (see buildRetireIgnorePatterns).
|
|
148
|
+
const DEFAULT_RETIRE_SKIP_DIRS = [
|
|
149
|
+
"node_modules", "bower_components", "jspm_packages",
|
|
150
|
+
".git", ".idea", ".vscode", ".gradle", ".mvn",
|
|
151
|
+
"target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the lines for a retire ignorefile, mirroring the rest of the scan's
|
|
156
|
+
* exclude policy (lib/path-filter.js#makeDirFilter) so retire skips the SAME dirs
|
|
157
|
+
* every codec walker prunes.
|
|
158
|
+
*
|
|
159
|
+
* Why an ignorefile and not `--ignore`: retire `path.resolve()`s every `--ignore`
|
|
160
|
+
* entry against ITS OWN cwd (cli.js), so a bare "node_modules" becomes
|
|
161
|
+
* `<cwd>/node_modules` and silently misses `<srcDir>/…/node_modules` whenever fad
|
|
162
|
+
* runs from a different directory than `-s` — and it can never express "this dir
|
|
163
|
+
* at ANY depth". An ignorefile line prefixed with `@` is kept VERBATIM (no resolve)
|
|
164
|
+
* and used as a substring regex, which is the only retire mechanism that can.
|
|
165
|
+
*
|
|
166
|
+
* Two layers, exactly like makeDirFilter:
|
|
167
|
+
* - default skip dirs → `@/<name>/` : segment-bounded substring → matches the dir
|
|
168
|
+
* at ANY depth (the bug: a deeply-nested node_modules), and the surrounding
|
|
169
|
+
* slashes keep lookalike files (`node_modules_shim.js`) from being clipped.
|
|
170
|
+
* Dropped entirely when defaultExcludes === false (--no-default-excludes).
|
|
171
|
+
* - user --exclude-path globs → `@<absSrcDir>/<glob>/` : ANCHORED to the scan
|
|
172
|
+
* root (not any-depth), retire expands `*`→[^/]* and `**`→.* itself.
|
|
173
|
+
*
|
|
174
|
+
* retire walks with POSIX-style paths (its own matchers use `/`), so we emit `/`.
|
|
175
|
+
*/
|
|
176
|
+
function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true } = {}) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
if (defaultExcludes !== false) {
|
|
179
|
+
for (const d of DEFAULT_RETIRE_SKIP_DIRS) lines.push(`@/${d}/`);
|
|
180
|
+
}
|
|
181
|
+
const root = srcDir ? path.resolve(srcDir).split(path.sep).join("/").replace(/\/+$/, "") : "";
|
|
182
|
+
for (const g of excludePath || []) {
|
|
183
|
+
// Normalise like compileGlobs: strip a leading ./ or /, a trailing /, and a
|
|
184
|
+
// trailing /** (the dir's whole subtree is already covered by the segment).
|
|
185
|
+
const base = String(g || "").trim().replace(/^\.?\/+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "");
|
|
186
|
+
if (!base) continue;
|
|
187
|
+
lines.push(root ? `@${root}/${base}/` : `@/${base}/`);
|
|
188
|
+
}
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Pure: build the retire CLI argv. `ignoreFile` (a path-anchored ignorefile, see
|
|
193
|
+
// buildRetireIgnorePatterns) is passed when there's anything to exclude. `jsRepo`
|
|
194
|
+
// (a local signature file) is added when available so retire loads signatures from
|
|
195
|
+
// disk instead of the network.
|
|
196
|
+
function buildRetireArgs({ srcDir, outPath, ignoreFile, jsRepo }) {
|
|
148
197
|
const args = [
|
|
149
198
|
// --verbose makes retire list EVERY identified library, not just the
|
|
150
199
|
// vulnerable ones (its own wording: "by default only vulnerable files are
|
|
@@ -154,8 +203,8 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
|
154
203
|
"--outputformat", "json",
|
|
155
204
|
"--outputpath", outPath,
|
|
156
205
|
"--jspath", srcDir,
|
|
157
|
-
"--ignore", ignoredDirs,
|
|
158
206
|
];
|
|
207
|
+
if (ignoreFile) { args.push("--ignorefile", ignoreFile); }
|
|
159
208
|
if (jsRepo) { args.push("--jsrepo", jsRepo); }
|
|
160
209
|
return args;
|
|
161
210
|
}
|
|
@@ -222,11 +271,13 @@ function extractVendoredInventory(raw, srcDir) {
|
|
|
222
271
|
* Run retire.js against `srcDir`. Returns the parsed JSON output (an array
|
|
223
272
|
* of file-level findings) or null if retire is unavailable.
|
|
224
273
|
*
|
|
225
|
-
* `--outputformat json` is the structured form;
|
|
226
|
-
* dirs
|
|
274
|
+
* `--outputformat json` is the structured form; a generated `--ignorefile`
|
|
275
|
+
* prunes the same dirs the rest of the scan skips (default SKIP set at any depth
|
|
276
|
+
* + the user's --exclude-path globs, anchored to the scan root) so retire never
|
|
277
|
+
* walks node_modules/target/… or anything the auditor excluded.
|
|
227
278
|
*/
|
|
228
279
|
async function runRetire(srcDir, opts = {}) {
|
|
229
|
-
const { verbose, force, offline } = opts;
|
|
280
|
+
const { verbose, force, offline, excludePath, defaultExcludes } = opts;
|
|
230
281
|
// Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
|
|
231
282
|
// surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
|
|
232
283
|
// as a report warning — instead of letting it masquerade as "no vendored JS found".
|
|
@@ -252,16 +303,25 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
252
303
|
}
|
|
253
304
|
|
|
254
305
|
const launcher = findRetireLauncher();
|
|
255
|
-
const ignoredDirs = [
|
|
256
|
-
"node_modules", "bower_components", "jspm_packages",
|
|
257
|
-
".git", ".idea", ".vscode", ".gradle", ".mvn",
|
|
258
|
-
"target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
|
|
259
|
-
].join(",");
|
|
260
306
|
|
|
261
307
|
// retire.js refuses to write to /dev/stdout, so we use a real temp file
|
|
262
308
|
// and read it back. Falls back to stdout if --outputpath is rejected.
|
|
263
309
|
const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
|
|
264
|
-
|
|
310
|
+
|
|
311
|
+
// Pruning policy, written to a temp ignorefile so retire honors it for the tree
|
|
312
|
+
// it ACTUALLY walks (anchored to srcDir, not retire's cwd — the nested
|
|
313
|
+
// node_modules bug) and respects the same --exclude-path the codecs do.
|
|
314
|
+
const ignorePatterns = buildRetireIgnorePatterns({ srcDir, excludePath, defaultExcludes });
|
|
315
|
+
// retire treats a `.json` ignorefile as the structured descriptor form; keep a
|
|
316
|
+
// plain-text extension so the `@`-prefixed regex lines are read as such.
|
|
317
|
+
const tmpIgnore = ignorePatterns.length ? path.join(os.tmpdir(), `fad-checker-retire-ignore-${process.pid}-${Date.now()}.txt`) : null;
|
|
318
|
+
if (tmpIgnore) fs.writeFileSync(tmpIgnore, ignorePatterns.join("\n") + "\n");
|
|
319
|
+
const cleanup = () => {
|
|
320
|
+
try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
|
|
321
|
+
if (tmpIgnore) { try { fs.unlinkSync(tmpIgnore); } catch { /* best effort */ } }
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoreFile: tmpIgnore, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
|
|
265
325
|
if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
|
|
266
326
|
|
|
267
327
|
let execErr = null;
|
|
@@ -284,7 +344,7 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
284
344
|
const reason = retireFailureReason(err.stderr, err.message);
|
|
285
345
|
diag.error = `retire.js scan failed: ${reason}`;
|
|
286
346
|
if (verbose) console.warn(`retire: failed to run — ${reason}`);
|
|
287
|
-
|
|
347
|
+
cleanup();
|
|
288
348
|
return null;
|
|
289
349
|
}
|
|
290
350
|
}
|
|
@@ -299,7 +359,7 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
299
359
|
if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
|
|
300
360
|
return null;
|
|
301
361
|
} finally {
|
|
302
|
-
|
|
362
|
+
cleanup();
|
|
303
363
|
}
|
|
304
364
|
writeCache(srcDir, parsed);
|
|
305
365
|
return parsed;
|
|
@@ -417,6 +477,8 @@ module.exports = {
|
|
|
417
477
|
warmRetireSignatures,
|
|
418
478
|
ensureSignatures,
|
|
419
479
|
buildRetireArgs,
|
|
480
|
+
buildRetireIgnorePatterns,
|
|
481
|
+
DEFAULT_RETIRE_SKIP_DIRS,
|
|
420
482
|
retireFailureReason,
|
|
421
483
|
readCache,
|
|
422
484
|
writeCache,
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
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",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"cli"
|
|
46
46
|
],
|
|
47
47
|
"license": "MIT",
|
|
48
|
-
"repository": "https://github.com/
|
|
49
|
-
"homepage": "https://github.com/
|
|
50
|
-
"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",
|
|
51
51
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
52
52
|
"maintainers": [
|
|
53
53
|
"pp9Ping <pp9Ping@gmail.com>"
|