fad-checker 2.0.0 → 2.1.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +147 -25
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/eol-mapping.json +5 -5
  6. package/data/license-policy.json +97 -0
  7. package/fad-checker-report/cve-report.doc +630 -0
  8. package/fad-checker-report/cve-report.html +748 -0
  9. package/fad-checker.js +556 -245
  10. package/lib/cache-archive.js +3 -0
  11. package/lib/codecs/codec.interface.js +3 -0
  12. package/lib/codecs/composer/parse.js +3 -0
  13. package/lib/codecs/composer/registry.js +13 -5
  14. package/lib/codecs/composer.codec.js +3 -0
  15. package/lib/codecs/go/parse.js +65 -0
  16. package/lib/codecs/go/registry.js +74 -0
  17. package/lib/codecs/go.codec.js +76 -0
  18. package/lib/codecs/index.js +7 -2
  19. package/lib/codecs/maven/jar-scan.js +199 -0
  20. package/lib/codecs/maven.codec.js +17 -1
  21. package/lib/codecs/npm/collect.js +37 -17
  22. package/lib/codecs/npm/parse.js +117 -10
  23. package/lib/codecs/npm/registry.js +14 -5
  24. package/lib/codecs/npm.codec.js +4 -1
  25. package/lib/codecs/nuget/parse.js +3 -0
  26. package/lib/codecs/nuget/registry.js +11 -4
  27. package/lib/codecs/nuget.codec.js +7 -2
  28. package/lib/codecs/pypi/parse.js +111 -14
  29. package/lib/codecs/pypi/registry.js +24 -5
  30. package/lib/codecs/pypi.codec.js +30 -6
  31. package/lib/codecs/recipes.js +28 -1
  32. package/lib/codecs/ruby/parse.js +42 -0
  33. package/lib/codecs/ruby/registry.js +76 -0
  34. package/lib/codecs/ruby.codec.js +66 -0
  35. package/lib/codecs/select.js +3 -0
  36. package/lib/codecs/yarn.codec.js +3 -0
  37. package/lib/config.js +3 -0
  38. package/lib/core.js +38 -1
  39. package/lib/cpe.js +30 -5
  40. package/lib/csaf-export.js +159 -0
  41. package/lib/cve-download.js +3 -0
  42. package/lib/cve-match.js +27 -6
  43. package/lib/cve-report.js +157 -28
  44. package/lib/dep-record.js +15 -2
  45. package/lib/deps-descriptor.js +113 -0
  46. package/lib/epss.js +115 -0
  47. package/lib/gate.js +45 -0
  48. package/lib/json-export.js +110 -0
  49. package/lib/kev.js +88 -0
  50. package/lib/license-policy.js +110 -0
  51. package/lib/maven-license.js +52 -0
  52. package/lib/maven-repo.js +3 -0
  53. package/lib/maven-version.js +8 -3
  54. package/lib/nvd.js +17 -6
  55. package/lib/osv.js +75 -23
  56. package/lib/outdated.js +10 -2
  57. package/lib/priority.js +90 -0
  58. package/lib/purl.js +77 -0
  59. package/lib/retire.js +80 -13
  60. package/lib/sarif-export.js +134 -0
  61. package/lib/sbom-export.js +153 -0
  62. package/lib/scan-completeness.js +3 -0
  63. package/lib/snyk.js +3 -0
  64. package/lib/suppress.js +113 -0
  65. package/lib/transitive.js +6 -3
  66. package/lib/ui.js +90 -0
  67. package/package.json +49 -2
  68. package/CLAUDE.md +0 -147
  69. package/docs/ARCHITECTURE.md +0 -182
  70. package/docs/USAGE.md +0 -187
  71. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  72. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  73. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  74. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  75. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  76. package/test/cli-ecosystem.test.js +0 -30
  77. package/test/codec-edge-cases.test.js +0 -131
  78. package/test/codec-integration.test.js +0 -119
  79. package/test/codecs.test.js +0 -92
  80. package/test/composer.test.js +0 -71
  81. package/test/core.test.js +0 -153
  82. package/test/cpe.test.js +0 -166
  83. package/test/cve-download.test.js +0 -39
  84. package/test/cve-match.test.js +0 -217
  85. package/test/cve-report.test.js +0 -180
  86. package/test/dep-record.test.js +0 -31
  87. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  88. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  89. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  90. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  91. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  92. package/test/fixtures/csharp-config/packages.config +0 -4
  93. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  94. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  95. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  96. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  97. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  98. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  99. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  100. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  101. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  102. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  103. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  104. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  105. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  106. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  107. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  108. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  109. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  110. package/test/fixtures/php-app/composer.json +0 -5
  111. package/test/fixtures/php-app/composer.lock +0 -10
  112. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  113. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  114. package/test/fixtures/polyglot/js/package.json +0 -1
  115. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  116. package/test/fixtures/polyglot/php/composer.lock +0 -1
  117. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  118. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  119. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  120. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  121. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  122. package/test/fixtures/python-poetry/poetry.lock +0 -7
  123. package/test/fixtures/python-reqs/requirements.txt +0 -5
  124. package/test/fixtures/python-uv/uv.lock +0 -3
  125. package/test/fixtures/simple/app/pom.xml +0 -28
  126. package/test/fixtures/simple/lib/pom.xml +0 -18
  127. package/test/fixtures/simple/pom.xml +0 -24
  128. package/test/maven-repo.test.js +0 -111
  129. package/test/maven-version.test.js +0 -57
  130. package/test/monorepo.test.js +0 -132
  131. package/test/npm-registry.test.js +0 -64
  132. package/test/npm.test.js +0 -150
  133. package/test/nuget.test.js +0 -66
  134. package/test/osv.test.js +0 -62
  135. package/test/outdated.test.js +0 -101
  136. package/test/pypi.test.js +0 -72
  137. package/test/snyk.test.js +0 -64
  138. package/test/transitive.test.js +0 -305
  139. package/test/webjar.test.js +0 -33
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/deps-descriptor.js — serialize / deserialize an *anonymized* descriptor of
3
+ * the resolved dependency set, for PASSI-style offline→online→offline audits.
4
+ *
5
+ * Phase 1 (offline): collect deps → serializeDeps → write JSON. No paths, URLs,
6
+ * hostnames or usernames leave the air-gapped machine — only public package
7
+ * coordinates (ecosystem / namespace / name / version) + scope.
8
+ * Phase 2 (online): read JSON → deserializeDeps → run the scan flow to warm the
9
+ * coordinate-keyed caches, then `--export-cache`.
10
+ * Phase 3 (offline): `--import-cache` + a normal `--offline` scan re-collects the
11
+ * source locally (real paths) and gets cache hits → full detailed report.
12
+ *
13
+ * See docs/superpowers/specs/2026-05-30-anonymized-deps-descriptor-passi-design.md
14
+ *
15
+ * Pure functions: no I/O, no console. The caller does file read/write.
16
+ *
17
+ * @author: N.BRAUN
18
+ * @email: pp9ping@gmail.com
19
+ */
20
+ const { makeDepRecord, coordKeyFor } = require("./dep-record");
21
+
22
+ const SCHEMA = "fad-deps/1";
23
+
24
+ // Fields kept in the descriptor — everything required to query the public vuln
25
+ // databases and to group the report. Anything path/URL/host-bearing is dropped:
26
+ // manifestPath(s) / pomPaths, resolved (registry URL), integrity, from
27
+ // (parent chain), depth, lockType.
28
+ const ANON_NOTE = "Anonymized: public coordinates only — no paths, URLs, or host info. Review before transfer.";
29
+
30
+ /**
31
+ * serializeDeps(resolvedMap, opts) -> descriptor object (ready for JSON.stringify).
32
+ *
33
+ * opts:
34
+ * generator — tool id/version string for traceability (no env info)
35
+ * generatedAt — ISO timestamp (override for deterministic tests)
36
+ * note — human-readable banner (defaults to the anonymization notice)
37
+ */
38
+ function serializeDeps(resolvedMap, opts = {}) {
39
+ const { generator = "fad-checker", generatedAt = new Date().toISOString(), note = ANON_NOTE } = opts;
40
+ const deps = [];
41
+ const byEcosystem = {};
42
+ for (const d of resolvedMap.values()) {
43
+ if (!d || !d.ecosystem || !d.name) continue;
44
+ const versions = Array.isArray(d.versions) && d.versions.length
45
+ ? [...new Set(d.versions)]
46
+ : (d.version ? [d.version] : []);
47
+ deps.push({
48
+ ecosystem: d.ecosystem,
49
+ ecosystemType: d.ecosystemType || d.ecosystem,
50
+ namespace: d.namespace || "",
51
+ name: d.name,
52
+ version: d.version || (versions[0] || null),
53
+ versions,
54
+ scope: d.scope || "prod",
55
+ isDev: !!d.isDev,
56
+ });
57
+ byEcosystem[d.ecosystem] = (byEcosystem[d.ecosystem] || 0) + 1;
58
+ }
59
+ // Stable order so two runs over the same tree produce identical files (easier
60
+ // to diff/review and to detect tampering during transfer).
61
+ deps.sort((a, b) => (a.ecosystem + "\0" + a.namespace + "\0" + a.name).localeCompare(b.ecosystem + "\0" + b.namespace + "\0" + b.name));
62
+ return {
63
+ schema: SCHEMA,
64
+ generator,
65
+ generatedAt,
66
+ note,
67
+ summary: { total: deps.length, byEcosystem },
68
+ deps,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * deserializeDeps(descriptor) -> { resolved, activeIds, runMaven, runNpm }.
74
+ *
75
+ * Rebuilds depRecords with EMPTY manifestPaths (the descriptor carries none) and
76
+ * a RECOMPUTED coordKey (never trusts the input). Throws on schema mismatch.
77
+ */
78
+ function deserializeDeps(descriptor) {
79
+ if (!descriptor || typeof descriptor !== "object") throw new Error("invalid descriptor: not an object");
80
+ if (descriptor.schema !== SCHEMA) {
81
+ throw new Error(`unsupported descriptor schema: got "${descriptor.schema}", expected "${SCHEMA}"`);
82
+ }
83
+ const list = Array.isArray(descriptor.deps) ? descriptor.deps : [];
84
+ const resolved = new Map();
85
+ const ecosystems = new Set();
86
+ for (const e of list) {
87
+ if (!e || !e.ecosystem || !e.name) continue;
88
+ const rec = makeDepRecord({
89
+ ecosystem: e.ecosystem,
90
+ ecosystemType: e.ecosystemType || e.ecosystem,
91
+ namespace: e.namespace || "",
92
+ name: e.name,
93
+ version: e.version || null,
94
+ scope: e.scope || "prod",
95
+ isDev: !!e.isDev,
96
+ // manifestPath intentionally omitted → manifestPaths/pomPaths = []
97
+ });
98
+ // Restore the full multi-version set (makeDepRecord only derives it from the
99
+ // single `version`); CVE/OSV matching iterates `versions`.
100
+ if (Array.isArray(e.versions) && e.versions.length) rec.versions = [...new Set(e.versions)];
101
+ resolved.set(coordKeyFor(e.ecosystem, e.namespace || "", e.name), rec);
102
+ ecosystems.add(e.ecosystem);
103
+ }
104
+ const activeIds = [...ecosystems];
105
+ return {
106
+ resolved,
107
+ activeIds,
108
+ runMaven: ecosystems.has("maven"),
109
+ runNpm: ecosystems.has("npm") || ecosystems.has("yarn"),
110
+ };
111
+ }
112
+
113
+ module.exports = { serializeDeps, deserializeDeps, SCHEMA, ANON_NOTE };
package/lib/epss.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * lib/epss.js — enrich matches with EPSS (Exploit Prediction Scoring System).
3
+ *
4
+ * EPSS gives the probability (0-1) a CVE will be exploited in the next 30 days,
5
+ * plus a percentile rank against all scored CVEs. FIRST.org recomputes it daily.
6
+ *
7
+ * API: https://api.first.org/data/v1/epss?cve=CVE-a,CVE-b,… (batch ≤ ~100)
8
+ * Cache: ~/.fad-checker/epss-cache.json, 24h TTL (aligned with the daily refresh).
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const os = require("os");
16
+
17
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
18
+ const CACHE_PATH = path.join(CACHE_DIR, "epss-cache.json");
19
+ const CACHE_TTL_MS = 24 * 3600 * 1000;
20
+ const EPSS_BASE = "https://api.first.org/data/v1/epss";
21
+ const BATCH = 100;
22
+
23
+ function loadCache() {
24
+ try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); }
25
+ catch { return { meta: { fetchedAt: 0 }, entries: {} }; }
26
+ }
27
+
28
+ function saveCache(data) {
29
+ try {
30
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
31
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(data));
32
+ } catch { /* ignore */ }
33
+ }
34
+
35
+ /** Pure: FIRST.org JSON → Map<cveId, {score, percentile}>. */
36
+ function parseEpssResponse(json) {
37
+ const out = new Map();
38
+ for (const row of json?.data || []) {
39
+ if (!row?.cve) continue;
40
+ const score = parseFloat(row.epss);
41
+ const percentile = parseFloat(row.percentile);
42
+ out.set(row.cve, {
43
+ score: Number.isFinite(score) ? score : null,
44
+ percentile: Number.isFinite(percentile) ? percentile : null,
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function chunk(arr, n) {
51
+ const out = [];
52
+ for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Enrich matches in place with cve.epssScore / cve.epssPercentile.
58
+ * opts: { offline, verbose, fetcher, onProgress }
59
+ */
60
+ async function enrichEpss(matches, opts = {}) {
61
+ const { offline, verbose, fetcher = globalThis.fetch, onProgress } = opts;
62
+ const ids = new Set();
63
+ for (const m of matches || []) if (m.cve?.id?.startsWith("CVE-")) ids.add(m.cve.id);
64
+ if (!ids.size) return matches;
65
+
66
+ const cache = loadCache();
67
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_TTL_MS;
68
+ if (!fresh && !offline) cache.entries = {};
69
+
70
+ const byId = new Map();
71
+ const liveIds = [];
72
+ for (const id of ids) {
73
+ if (cache.entries[id]) byId.set(id, cache.entries[id]);
74
+ else if (offline) byId.set(id, null);
75
+ else liveIds.push(id);
76
+ }
77
+
78
+ let done = 0;
79
+ const total = liveIds.length;
80
+ const report = () => { if (onProgress) onProgress(done, total); };
81
+ for (const group of chunk(liveIds, BATCH)) {
82
+ try {
83
+ const url = `${EPSS_BASE}?cve=${encodeURIComponent(group.join(","))}`;
84
+ const r = await fetcher(url, { headers: { "User-Agent": "fad-checker-epss" } });
85
+ if (r.ok) {
86
+ const parsed = parseEpssResponse(await r.json());
87
+ for (const id of group) {
88
+ // Not every CVE has an EPSS score; cache the null so we don't refetch.
89
+ const v = parsed.get(id) || { score: null, percentile: null };
90
+ cache.entries[id] = v;
91
+ byId.set(id, v);
92
+ }
93
+ } else if (verbose) {
94
+ console.warn(` EPSS HTTP ${r.status}`);
95
+ }
96
+ } catch (err) {
97
+ if (verbose) console.warn(` EPSS fetch failed: ${err.message}`);
98
+ }
99
+ done += group.length;
100
+ report();
101
+ }
102
+
103
+ cache.meta = { fetchedAt: fresh ? cache.meta.fetchedAt : Date.now() };
104
+ if (!offline) saveCache(cache);
105
+
106
+ for (const m of matches || []) {
107
+ const v = byId.get(m.cve?.id);
108
+ if (!v) continue;
109
+ if (v.score != null) m.cve.epssScore = v.score;
110
+ if (v.percentile != null) m.cve.epssPercentile = v.percentile;
111
+ }
112
+ return matches;
113
+ }
114
+
115
+ module.exports = { enrichEpss, parseEpssResponse, CACHE_PATH };
package/lib/gate.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * lib/gate.js — CI gating: decide whether a finding set should fail the build.
3
+ *
4
+ * Levels: none | low | medium | high | critical | kev. A severity level fails
5
+ * when any (non-suppressed) match is at or above it; `kev` fails only on a CISA
6
+ * known-exploited finding — the modern "patch what's actually attacked" gate.
7
+ * Pure — the caller sets process.exitCode.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+
13
+ const SEV_RANK = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
14
+
15
+ /**
16
+ * evaluateGate(matches, level) → { failed, reason, count, level }.
17
+ * `matches` should already exclude cpe-filtered/dev — typically the prodActive set.
18
+ */
19
+ function evaluateGate(matches, level) {
20
+ const lvl = String(level || "none").toLowerCase();
21
+ const active = (matches || []).filter(m => !m.suppressed);
22
+ if (lvl === "none") return { failed: false, reason: "gating disabled", count: 0, level: lvl };
23
+
24
+ if (lvl === "kev") {
25
+ const hits = active.filter(m => m.cve?.kev);
26
+ return {
27
+ failed: hits.length > 0,
28
+ count: hits.length,
29
+ level: lvl,
30
+ reason: hits.length ? `${hits.length} known-exploited (CISA KEV) finding(s)` : "no known-exploited finding",
31
+ };
32
+ }
33
+
34
+ const threshold = SEV_RANK[lvl];
35
+ if (threshold == null) return { failed: false, reason: `unknown level "${lvl}"`, count: 0, level: lvl };
36
+ const hits = active.filter(m => (SEV_RANK[(m.cve?.severity || "none").toLowerCase()] ?? 0) >= threshold);
37
+ return {
38
+ failed: hits.length > 0,
39
+ count: hits.length,
40
+ level: lvl,
41
+ reason: hits.length ? `${hits.length} finding(s) at or above ${lvl}` : `no finding at or above ${lvl}`,
42
+ };
43
+ }
44
+
45
+ module.exports = { evaluateGate, SEV_RANK };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * lib/json-export.js — emit a single machine-readable findings document.
3
+ *
4
+ * Unlike the CycloneDX SBOM (component-centric) or CSAF VEX (status-centric),
5
+ * this is fad-checker's own flat findings format: every chapter (CVE, EOL,
6
+ * obsolete, outdated, licenses, vendored) in one JSON, easy to diff between
7
+ * audits and post-process. buildFindings is pure; writeFindings writes it.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+ const fs = require("fs");
13
+ const { purlFor } = require("./purl");
14
+
15
+ function coordOf(dep) {
16
+ const ns = dep.namespace || dep.groupId || "";
17
+ const name = dep.name || dep.artifactId;
18
+ if (dep.ecosystem === "maven" && ns) return `${ns}:${name}`;
19
+ if (dep.ecosystem === "composer" && ns) return `${ns}/${name}`;
20
+ return name;
21
+ }
22
+
23
+ function depBrief(dep) {
24
+ return {
25
+ ecosystem: dep.ecosystem,
26
+ coord: coordOf(dep),
27
+ version: dep.version || null,
28
+ scope: dep.scope || null,
29
+ isDev: !!dep.isDev,
30
+ provenance: dep.provenance || "manifest",
31
+ purl: purlFor(dep),
32
+ manifestPaths: dep.manifestPaths || dep.pomPaths || [],
33
+ };
34
+ }
35
+
36
+ function cveFinding(m) {
37
+ const c = m.cve;
38
+ return {
39
+ id: c.id,
40
+ severity: c.severity || "UNKNOWN",
41
+ cvss: c.score ?? null,
42
+ cvssVector: c.cvssVector || null,
43
+ epss: c.epssScore ?? null,
44
+ epssPercentile: c.epssPercentile ?? null,
45
+ kev: !!c.kev,
46
+ kevDueDate: c.kevDueDate || null,
47
+ priority: c.priority ? { band: c.priority.band, score: c.priority.score } : null,
48
+ cwes: c.cwes || [],
49
+ fixVersion: c.fixVersion || null,
50
+ source: m.source || null,
51
+ confidence: m.confidence || null,
52
+ cpeFiltered: !!m.cpeFiltered,
53
+ suppressed: !!m.suppressed,
54
+ suppressedReason: m.suppressedReason || null,
55
+ dep: depBrief(m.dep),
56
+ };
57
+ }
58
+
59
+ const SEV = ["critical", "high", "medium", "low", "none", "unknown"];
60
+
61
+ /**
62
+ * Build the findings document.
63
+ * payload: { cveMatches, retireMatches, eolResults, obsoleteResults,
64
+ * outdatedResults, licenseResults, resolvedDeps, projectInfo, toolVersion }
65
+ */
66
+ function buildFindings(payload = {}) {
67
+ const {
68
+ cveMatches = [], retireMatches = [], eolResults = [], obsoleteResults = [],
69
+ outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
70
+ } = payload;
71
+
72
+ const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
73
+ let kev = 0;
74
+ for (const m of cveMatches) {
75
+ if (m.cpeFiltered || m.suppressed) continue;
76
+ const s = (m.cve.severity || "unknown").toLowerCase();
77
+ sevCounts[s != null && sevCounts[s] != null ? s : "unknown"]++;
78
+ if (m.cve.kev) kev++;
79
+ }
80
+
81
+ return {
82
+ tool: { name: "fad-checker", version: String(toolVersion) },
83
+ generatedAt: projectInfo.generatedAt || null,
84
+ project: { name: projectInfo.name || null, src: projectInfo.src || null },
85
+ summary: {
86
+ dependencies: resolvedDeps?.size ?? null,
87
+ cve: { ...sevCounts, kev, total: cveMatches.filter(m => !m.cpeFiltered && !m.suppressed).length },
88
+ eol: eolResults.length,
89
+ obsolete: obsoleteResults.length,
90
+ outdated: outdatedResults.length,
91
+ licensesFlagged: licenseResults?.flagged?.length || 0,
92
+ vendored: retireMatches.length,
93
+ suppressed: cveMatches.filter(m => m.suppressed).length,
94
+ },
95
+ cve: cveMatches.map(cveFinding),
96
+ vendored: retireMatches.map(cveFinding),
97
+ eol: eolResults.map(e => ({ product: e.product, eol: e.eol, dep: depBrief(e.dep) })),
98
+ obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
99
+ outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
100
+ licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
101
+ };
102
+ }
103
+
104
+ function writeFindings(payload, outputPath) {
105
+ const doc = buildFindings(payload);
106
+ fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n");
107
+ return doc;
108
+ }
109
+
110
+ module.exports = { buildFindings, writeFindings };
package/lib/kev.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * lib/kev.js — enrich matches with CISA KEV (Known Exploited Vulnerabilities).
3
+ *
4
+ * KEV is a single authoritative catalogue of CVEs CISA has observed being
5
+ * exploited in the wild. Membership is the strongest "patch this now" signal.
6
+ *
7
+ * Feed: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
8
+ * Cache: ~/.fad-checker/kev-cache.json, 24h TTL.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const os = require("os");
16
+
17
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
18
+ const CACHE_PATH = path.join(CACHE_DIR, "kev-cache.json");
19
+ const CACHE_TTL_MS = 24 * 3600 * 1000;
20
+ const KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
21
+
22
+ function readCache() {
23
+ try {
24
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
25
+ if (Date.now() - data._fetchedAt < CACHE_TTL_MS) return data.body;
26
+ } catch { /* ignore */ }
27
+ return null;
28
+ }
29
+
30
+ function writeCache(body) {
31
+ try {
32
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
33
+ fs.writeFileSync(CACHE_PATH, JSON.stringify({ _fetchedAt: Date.now(), body }));
34
+ } catch { /* ignore */ }
35
+ }
36
+
37
+ /** Pure: KEV catalogue JSON → { byId: { cveID: {dateAdded, dueDate, ransomware} } }. */
38
+ function indexKevCatalog(json) {
39
+ const byId = {};
40
+ for (const v of json?.vulnerabilities || []) {
41
+ if (!v?.cveID) continue;
42
+ byId[v.cveID] = {
43
+ dateAdded: v.dateAdded || null,
44
+ dueDate: v.dueDate || null,
45
+ ransomware: /^known$/i.test(v.knownRansomwareCampaignUse || ""),
46
+ };
47
+ }
48
+ return { byId };
49
+ }
50
+
51
+ /**
52
+ * Enrich matches in place: sets cve.kev / cve.kevDateAdded / cve.kevDueDate /
53
+ * cve.kevRansomware for CVEs present in the catalogue.
54
+ * opts: { offline, verbose, fetcher }
55
+ */
56
+ async function enrichKev(matches, opts = {}) {
57
+ const { offline, verbose, fetcher = globalThis.fetch } = opts;
58
+ const hasCve = (matches || []).some(m => m.cve?.id?.startsWith("CVE-"));
59
+ if (!hasCve) return matches;
60
+
61
+ let index = readCache();
62
+ if (!index && !offline) {
63
+ try {
64
+ const r = await fetcher(KEV_URL, { headers: { "User-Agent": "fad-checker-kev" } });
65
+ if (r.ok) {
66
+ index = indexKevCatalog(await r.json());
67
+ writeCache(index);
68
+ } else if (verbose) {
69
+ console.warn(` KEV HTTP ${r.status}`);
70
+ }
71
+ } catch (err) {
72
+ if (verbose) console.warn(` KEV fetch failed: ${err.message}`);
73
+ }
74
+ }
75
+ if (!index) return matches;
76
+
77
+ for (const m of matches || []) {
78
+ const hit = index.byId[m.cve?.id];
79
+ if (!hit) continue;
80
+ m.cve.kev = true;
81
+ m.cve.kevDateAdded = hit.dateAdded;
82
+ m.cve.kevDueDate = hit.dueDate;
83
+ m.cve.kevRansomware = hit.ransomware;
84
+ }
85
+ return matches;
86
+ }
87
+
88
+ module.exports = { enrichKev, indexKevCatalog, CACHE_PATH };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * lib/license-policy.js — normalize free-form license strings to SPDX ids and
3
+ * classify them into policy categories (permissive / copyleft / proprietary).
4
+ *
5
+ * Data-driven: data/license-policy.json holds the id→category table and an
6
+ * alias map for the messy real-world strings registries and POMs emit
7
+ * ("Apache 2.0", "The MIT License", "GNU GPLv3", …). Pure — no I/O at call time.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const POLICY = (() => {
16
+ try {
17
+ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "data", "license-policy.json"), "utf8"));
18
+ return { categories: raw.categories || {}, aliases: raw.aliases || {} };
19
+ } catch { return { categories: {}, aliases: {} }; }
20
+ })();
21
+
22
+ // Case-insensitive index of canonical SPDX ids for direct matches.
23
+ const CANON_BY_LOWER = new Map(Object.keys(POLICY.categories).map(id => [id.toLowerCase(), id]));
24
+
25
+ const FLAGGED_CATEGORIES = new Set(["strong-copyleft", "network-copyleft", "proprietary", "unknown"]);
26
+ // Most-restrictive-wins order when a dep has several licenses.
27
+ const CATEGORY_RANK = { permissive: 1, unknown: 2, "weak-copyleft": 3, proprietary: 4, "strong-copyleft": 5, "network-copyleft": 6 };
28
+
29
+ /** Normalize one license token to a canonical SPDX id, or null if unknown. */
30
+ function normalizeSpdx(raw) {
31
+ if (raw == null) return null;
32
+ // npm sometimes uses { type, url } objects.
33
+ const s = (typeof raw === "object" ? (raw.type || raw.name || "") : String(raw)).trim();
34
+ if (!s) return null;
35
+ const lower = s.toLowerCase();
36
+ if (CANON_BY_LOWER.has(lower)) return CANON_BY_LOWER.get(lower);
37
+ if (POLICY.aliases[lower]) return POLICY.aliases[lower];
38
+ // Tolerate a trailing "+" (or-later) and "-only" decorations.
39
+ const stripped = lower.replace(/\+$/, "").trim();
40
+ if (CANON_BY_LOWER.has(stripped)) return CANON_BY_LOWER.get(stripped);
41
+ if (POLICY.aliases[stripped]) return POLICY.aliases[stripped];
42
+ return null;
43
+ }
44
+
45
+ /** Split an SPDX expression / array / "A OR B" / "A/B" string into raw tokens. */
46
+ function splitExpression(raw) {
47
+ if (Array.isArray(raw)) return raw.flatMap(splitExpression);
48
+ if (raw == null) return [];
49
+ if (typeof raw === "object") return [raw.type || raw.name || ""].filter(Boolean);
50
+ return String(raw)
51
+ .replace(/[()]/g, " ")
52
+ .split(/\s+(?:OR|AND)\s+|\s+WITH\s+|[,/|]/i)
53
+ .map(t => t.trim())
54
+ .filter(Boolean);
55
+ }
56
+
57
+ /** Category for a canonical SPDX id (defaults to "unknown"). */
58
+ function classify(spdxId) {
59
+ if (!spdxId) return "unknown";
60
+ return POLICY.categories[spdxId] || "unknown";
61
+ }
62
+
63
+ /**
64
+ * Resolve a dep's raw license value(s) into { ids, raw, category }.
65
+ * `ids` are canonical SPDX (raw token kept when unmapped); `category` is the
66
+ * most restrictive among them.
67
+ */
68
+ function resolveDepLicense(rawValue) {
69
+ const tokens = splitExpression(rawValue);
70
+ if (!tokens.length) return { ids: [], raw: [], category: "unknown" };
71
+ const ids = [];
72
+ const rawKept = [];
73
+ let best = "permissive";
74
+ let bestRank = 0;
75
+ let anyKnown = false;
76
+ for (const tok of tokens) {
77
+ const id = normalizeSpdx(tok);
78
+ if (id) { ids.push(id); anyKnown = true; } else rawKept.push(tok);
79
+ const cat = id ? classify(id) : "unknown";
80
+ const rank = CATEGORY_RANK[cat] || CATEGORY_RANK.unknown;
81
+ if (rank > bestRank) { bestRank = rank; best = cat; }
82
+ }
83
+ // All tokens unmapped → unknown overall.
84
+ if (!anyKnown) best = "unknown";
85
+ return { ids, raw: rawKept, category: best };
86
+ }
87
+
88
+ /**
89
+ * Assess a flat list of license findings.
90
+ * findings: [{ dep, licenses: <raw string|array|object>, source }]
91
+ * Returns { assessed: [{dep, source, ids, raw, category}], byCategory, flagged }.
92
+ */
93
+ function assessLicenses(findings) {
94
+ const assessed = [];
95
+ const byCategory = {};
96
+ const flagged = [];
97
+ for (const f of findings || []) {
98
+ const r = resolveDepLicense(f.licenses);
99
+ const entry = { dep: f.dep, source: f.source || null, ids: r.ids, raw: r.raw, category: r.category };
100
+ assessed.push(entry);
101
+ (byCategory[r.category] = byCategory[r.category] || []).push(entry);
102
+ if (FLAGGED_CATEGORIES.has(r.category)) flagged.push(entry);
103
+ }
104
+ return { assessed, byCategory, flagged };
105
+ }
106
+
107
+ module.exports = {
108
+ normalizeSpdx, splitExpression, classify, resolveDepLicense, assessLicenses,
109
+ FLAGGED_CATEGORIES, CATEGORY_RANK,
110
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * lib/maven-license.js — best-effort Maven license detection, network-free.
3
+ *
4
+ * A declaring POM only lists a dependency's coordinate, not its license — the
5
+ * license lives in the dependency's OWN POM on Maven Central. transitive.js
6
+ * already caches those POMs under ~/.fad-checker/poms-cache/. We read the cached
7
+ * POMs (no network) and scrape the <licenses><license><name> block. Deps whose
8
+ * POM was never cached simply yield no license (→ unknown in the policy view).
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { POM_CACHE_DIR } = require("./transitive");
16
+
17
+ function cachedPomPath(g, a, v, dir) {
18
+ return path.join(dir, `${String(g).replace(/[/\\]/g, "_")}__${a}__${v}.pom`);
19
+ }
20
+
21
+ /** Pure: extract license names from a POM's <licenses> block. */
22
+ function licensesFromPomXml(xml) {
23
+ if (!xml) return [];
24
+ const block = xml.match(/<licenses>([\s\S]*?)<\/licenses>/i);
25
+ if (!block) return [];
26
+ const names = [];
27
+ const re = /<name>\s*([^<]+?)\s*<\/name>/gi;
28
+ let m;
29
+ while ((m = re.exec(block[1]))) names.push(m[1].trim());
30
+ return names.filter(Boolean);
31
+ }
32
+
33
+ /**
34
+ * Collect license findings for every Maven dep with a cached POM.
35
+ * opts: { cacheDir = POM_CACHE_DIR }
36
+ * Returns [{ dep, licenses: [name…], source: "pom" }].
37
+ */
38
+ function collectMavenLicenses(resolved, opts = {}) {
39
+ const cacheDir = opts.cacheDir || POM_CACHE_DIR;
40
+ const out = [];
41
+ for (const dep of resolved.values()) {
42
+ if (dep.ecosystem !== "maven" || !dep.version) continue;
43
+ const p = cachedPomPath(dep.namespace || dep.groupId, dep.name || dep.artifactId, dep.version, cacheDir);
44
+ let xml;
45
+ try { xml = fs.readFileSync(p, "utf8"); } catch { continue; }
46
+ const names = licensesFromPomXml(xml);
47
+ if (names.length) out.push({ dep, licenses: names, source: "pom" });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ module.exports = { collectMavenLicenses, licensesFromPomXml };
package/lib/maven-repo.js CHANGED
@@ -17,6 +17,9 @@
17
17
  *
18
18
  * The first 2xx wins. Misses are silently aggregated; the caller decides
19
19
  * what to do with "not found anywhere".
20
+ *
21
+ * @author: N.BRAUN
22
+ * @email: pp9ping@gmail.com
20
23
  */
21
24
  const MAVEN_CENTRAL = { name: "central", url: "https://repo1.maven.org/maven2/" };
22
25
 
@@ -9,6 +9,9 @@
9
9
  * alpha < beta < milestone < rc < snapshot < "" (release) < sp
10
10
  * - Trailing zeros are insignificant: 1.0 == 1.0.0 == 1.
11
11
  * - Known release qualifiers (final, release, ga) are treated as "".
12
+ *
13
+ * @author: N.BRAUN
14
+ * @email: pp9ping@gmail.com
12
15
  */
13
16
 
14
17
  // Lower number == lower precedence
@@ -65,12 +68,14 @@ function cmpSegments(a, b) {
65
68
  }
66
69
  if (a.kind === "num" && b.kind === "num") return a.value - b.value;
67
70
  if (a.kind === "num") {
68
- // number vs qualifier — numbers are "newer" than pre-release qualifiers
69
- const r = qualifierRank(b.value);
71
+ // number vs qualifier — numbers are "newer" than pre-release qualifiers.
72
+ // qualOf() reads the qualifier from BOTH str and qual+num segments (a bare
73
+ // `.value` is undefined for qual+num and would mis-rank e.g. "1.0.rc1").
74
+ const r = qualifierRank(qualOf(b));
70
75
  return r < QUALIFIER_ORDER[""] ? 1 : -1;
71
76
  }
72
77
  if (b.kind === "num") {
73
- const r = qualifierRank(a.value);
78
+ const r = qualifierRank(qualOf(a));
74
79
  return r < QUALIFIER_ORDER[""] ? -1 : 1;
75
80
  }
76
81
  if (a.kind === "qual+num" && b.kind === "qual+num") {