fad-checker 2.0.1 → 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 (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +90 -16
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/license-policy.json +97 -0
  6. package/fad-checker-report/cve-report.doc +630 -0
  7. package/fad-checker-report/cve-report.html +748 -0
  8. package/fad-checker.js +278 -53
  9. package/lib/cache-archive.js +3 -0
  10. package/lib/codecs/codec.interface.js +3 -0
  11. package/lib/codecs/composer/parse.js +3 -0
  12. package/lib/codecs/composer/registry.js +13 -5
  13. package/lib/codecs/composer.codec.js +3 -0
  14. package/lib/codecs/go/parse.js +65 -0
  15. package/lib/codecs/go/registry.js +74 -0
  16. package/lib/codecs/go.codec.js +76 -0
  17. package/lib/codecs/index.js +7 -2
  18. package/lib/codecs/maven/jar-scan.js +199 -0
  19. package/lib/codecs/maven.codec.js +17 -1
  20. package/lib/codecs/npm/collect.js +11 -0
  21. package/lib/codecs/npm/parse.js +3 -0
  22. package/lib/codecs/npm/registry.js +10 -2
  23. package/lib/codecs/npm.codec.js +3 -0
  24. package/lib/codecs/nuget/parse.js +3 -0
  25. package/lib/codecs/nuget/registry.js +11 -4
  26. package/lib/codecs/nuget.codec.js +3 -0
  27. package/lib/codecs/pypi/parse.js +7 -2
  28. package/lib/codecs/pypi/registry.js +24 -5
  29. package/lib/codecs/pypi.codec.js +3 -0
  30. package/lib/codecs/recipes.js +28 -1
  31. package/lib/codecs/ruby/parse.js +42 -0
  32. package/lib/codecs/ruby/registry.js +76 -0
  33. package/lib/codecs/ruby.codec.js +66 -0
  34. package/lib/codecs/select.js +3 -0
  35. package/lib/codecs/yarn.codec.js +3 -0
  36. package/lib/config.js +3 -0
  37. package/lib/core.js +3 -0
  38. package/lib/cpe.js +30 -5
  39. package/lib/csaf-export.js +159 -0
  40. package/lib/cve-download.js +3 -0
  41. package/lib/cve-match.js +12 -1
  42. package/lib/cve-report.js +157 -28
  43. package/lib/dep-record.js +15 -2
  44. package/lib/deps-descriptor.js +3 -0
  45. package/lib/epss.js +115 -0
  46. package/lib/gate.js +45 -0
  47. package/lib/json-export.js +110 -0
  48. package/lib/kev.js +88 -0
  49. package/lib/license-policy.js +110 -0
  50. package/lib/maven-license.js +52 -0
  51. package/lib/maven-repo.js +3 -0
  52. package/lib/maven-version.js +8 -3
  53. package/lib/nvd.js +13 -3
  54. package/lib/osv.js +68 -19
  55. package/lib/outdated.js +3 -0
  56. package/lib/priority.js +90 -0
  57. package/lib/purl.js +77 -0
  58. package/lib/retire.js +3 -0
  59. package/lib/sarif-export.js +134 -0
  60. package/lib/sbom-export.js +153 -0
  61. package/lib/scan-completeness.js +3 -0
  62. package/lib/snyk.js +3 -0
  63. package/lib/suppress.js +113 -0
  64. package/lib/transitive.js +3 -0
  65. package/lib/ui.js +3 -0
  66. package/package.json +48 -2
@@ -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") {
package/lib/nvd.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * Rate limit: 5 req / 30s unauthenticated, 50 req / 30s with NVD_API_KEY env var.
11
11
  *
12
12
  * Cache: ~/.fad-checker/nvd-cache/<cve-id>.json, 7-day TTL.
13
+ *
14
+ * @author: N.BRAUN
15
+ * @email: pp9ping@gmail.com
13
16
  */
14
17
  const fs = require("fs");
15
18
  const path = require("path");
@@ -62,11 +65,15 @@ function writeCache(cveId, body) {
62
65
  function bestMetric(metrics) {
63
66
  // NVD 2.0 returns metrics organised by version: cvssMetricV40, V31, V30, V2
64
67
  const order = ["cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"];
68
+ // Proper CVSS version label — NOT "cvssMetricV31".replace(...) which yielded the
69
+ // malformed "CVSS:V31" that downstream consumers (csaf cvssV3Version, sbom
70
+ // cvssMethod, the report) test with .includes("3.1") and so always missed.
71
+ const VER = { cvssMetricV40: "CVSS:4.0", cvssMetricV31: "CVSS:3.1", cvssMetricV30: "CVSS:3.0", cvssMetricV2: "CVSS:2.0" };
65
72
  for (const k of order) {
66
73
  if (Array.isArray(metrics?.[k]) && metrics[k].length) {
67
74
  const cv = metrics[k][0].cvssData;
68
75
  return {
69
- version: k.replace("cvssMetric", "CVSS:"),
76
+ version: VER[k],
70
77
  score: cv.baseScore,
71
78
  severity: (cv.baseSeverity || severityFromScore(cv.baseScore) || "UNKNOWN").toUpperCase(),
72
79
  vector: cv.vectorString,
@@ -266,8 +273,11 @@ async function enrichMatches(matches, opts = {}) {
266
273
  }
267
274
  if (m.cve.severity === "UNKNOWN" || !m.cve.severity) m.cve.severity = data.severity;
268
275
  if (m.cve.score == null) m.cve.score = data.score;
269
- m.cve.cvssVector = data.cvssVector || null;
270
- m.cve.cvssVersion = data.cvssVersion || null;
276
+ // Only overwrite the vector/version when NVD actually has one — an NVD record
277
+ // without CVSS metrics must not clobber an OSV-derived vector/version (which is
278
+ // what feeds CSAF cvssV3Version + SBOM cvssMethod).
279
+ if (data.cvssVector) m.cve.cvssVector = data.cvssVector;
280
+ if (data.cvssVersion) m.cve.cvssVersion = data.cvssVersion;
271
281
  m.cve.nvdRefs = data.references || []; // [{url, tags, source}]
272
282
  m.cve.cpes = data.cpes || [];
273
283
  m.cve.cwes = data.cwes || [];
package/lib/osv.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * GET /v1/vulns/{id} to fetch full details
13
13
  *
14
14
  * Cached responses live in ~/.fad-checker/osv-cache/ for 12h.
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -50,31 +53,74 @@ function normalizeSeverity(s) {
50
53
  return SEVERITY_ALIASES[up] || up;
51
54
  }
52
55
 
53
- /** Map an OSV severity score string ("9.8", "CVSS:3.1/AV:N/.../I:H/A:H") to a level. */
54
- function severityFromOsv(vuln) {
55
- // Severity may live in `severity[]` (CVSS vector strings) or
56
- // `database_specific.severity` (GHSA-style: "CRITICAL", "HIGH", "MODERATE"...).
57
- const direct = vuln.database_specific?.severity;
58
- if (direct) return { severity: normalizeSeverity(direct), score: scoreFromVuln(vuln) };
59
- const sev = vuln.severity?.[0]?.score || "";
60
- const numeric = parseFloat(sev.match(/(\d+(\.\d+)?)/)?.[1]);
61
- if (Number.isFinite(numeric)) {
62
- if (numeric >= 9) return { severity: "CRITICAL", score: numeric };
63
- if (numeric >= 7) return { severity: "HIGH", score: numeric };
64
- if (numeric >= 4) return { severity: "MEDIUM", score: numeric };
65
- if (numeric > 0) return { severity: "LOW", score: numeric };
56
+ function severityFromScore(n) {
57
+ if (!Number.isFinite(n)) return "UNKNOWN";
58
+ if (n >= 9) return "CRITICAL";
59
+ if (n >= 7) return "HIGH";
60
+ if (n >= 4) return "MEDIUM";
61
+ if (n > 0) return "LOW";
62
+ return "NONE";
63
+ }
64
+
65
+ // Compute a CVSS v3.0/3.1 base score from its vector string. Returns null for
66
+ // non-v3 vectors (v2/v4) or anything unparseable. OSV stores the *vector* in
67
+ // `severity[].score` for CVSS_V3 there is NO bare base number to read, so the
68
+ // old "first number in the string" approach extracted the VERSION ("3.1") as the
69
+ // score, mislabelling HIGH findings as ~low everywhere downstream (SARIF
70
+ // security-severity, priority banding, SBOM/CSAF ratings).
71
+ function cvss3BaseScore(vector) {
72
+ const v = String(vector || "");
73
+ if (!/^CVSS:3\.[01]\//.test(v)) return null;
74
+ const p = {};
75
+ for (const kv of v.split("/").slice(1)) {
76
+ const [k, val] = kv.split(":");
77
+ if (k && val) p[k] = val;
66
78
  }
67
- return { severity: "UNKNOWN", score: null };
79
+ const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[p.AV];
80
+ const AC = { L: 0.77, H: 0.44 }[p.AC];
81
+ const UI = { N: 0.85, R: 0.62 }[p.UI];
82
+ const scopeChanged = p.S === "C";
83
+ const PR = scopeChanged
84
+ ? { N: 0.85, L: 0.68, H: 0.5 }[p.PR]
85
+ : { N: 0.85, L: 0.62, H: 0.27 }[p.PR];
86
+ const imp = { H: 0.56, L: 0.22, N: 0 };
87
+ const C = imp[p.C], I = imp[p.I], A = imp[p.A];
88
+ if ([AV, AC, UI, PR, C, I, A].some(x => x == null)) return null;
89
+ const iss = 1 - (1 - C) * (1 - I) * (1 - A);
90
+ const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
91
+ if (impact <= 0) return 0;
92
+ const exploit = 8.22 * AV * AC * PR * UI;
93
+ const base = Math.min(scopeChanged ? 1.08 * (impact + exploit) : impact + exploit, 10);
94
+ return Math.ceil(base * 10) / 10; // CVSS roundup to one decimal
68
95
  }
69
96
 
70
- function scoreFromVuln(vuln) {
97
+ /** Best CVSS info from an OSV vuln: { score, vector, version } (version like "3.1"). */
98
+ function cvssInfoFromVuln(vuln) {
71
99
  for (const s of vuln.severity || []) {
72
- const m = String(s.score || "").match(/(\d+(\.\d+)?)/);
73
- if (m) return parseFloat(m[1]);
100
+ const str = String(s.score || "");
101
+ const mver = str.match(/^CVSS:(\d\.\d)\//);
102
+ if (mver) {
103
+ const version = mver[1];
104
+ const score = (version === "3.0" || version === "3.1") ? cvss3BaseScore(str) : null;
105
+ return { score, vector: str, version };
106
+ }
107
+ const mnum = str.match(/^\s*(\d+(\.\d+)?)\s*$/); // a bare numeric score ("9.8")
108
+ if (mnum) return { score: parseFloat(mnum[1]), vector: null, version: null };
74
109
  }
75
- return null;
110
+ return { score: null, vector: null, version: null };
76
111
  }
77
112
 
113
+ /** Map an OSV vuln to { severity, score }. */
114
+ function severityFromOsv(vuln) {
115
+ const direct = vuln.database_specific?.severity;
116
+ const { score } = cvssInfoFromVuln(vuln);
117
+ if (direct) return { severity: normalizeSeverity(direct), score };
118
+ if (score != null) return { severity: severityFromScore(score), score };
119
+ return { severity: "UNKNOWN", score: null };
120
+ }
121
+
122
+ function scoreFromVuln(vuln) { return cvssInfoFromVuln(vuln).score; }
123
+
78
124
  /** Extract the first fix version from OSV affected ranges (semver/ecosystem events). */
79
125
  function fixVersionFromOsv(vuln, depKey) {
80
126
  for (const a of vuln.affected || []) {
@@ -97,7 +143,7 @@ function pickPrimaryId(vuln) {
97
143
  }
98
144
 
99
145
  // OSV ecosystem name per codec id. OSV.dev natively supports all of these.
100
- const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI" };
146
+ const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI", go: "Go", ruby: "RubyGems" };
101
147
 
102
148
  /** OSV ecosystem string for a dep, derived from its codec id. */
103
149
  function osvEcosystemFor(dep) { return OSV_ECO[dep.ecosystem] || "Maven"; }
@@ -115,6 +161,7 @@ function osvPkgName(dep) {
115
161
  function vulnToMatch(dep, vuln) {
116
162
  const id = pickPrimaryId(vuln);
117
163
  const { severity, score } = severityFromOsv(vuln);
164
+ const cvss = cvssInfoFromVuln(vuln);
118
165
  // `details` (long markdown) is the substantive description; `summary` is just the title.
119
166
  // We keep them both: summary as headline, details as body.
120
167
  const summary = (vuln.summary || "").trim();
@@ -137,6 +184,8 @@ function vulnToMatch(dep, vuln) {
137
184
  id,
138
185
  severity,
139
186
  score,
187
+ ...(cvss.vector ? { cvssVector: cvss.vector } : {}),
188
+ ...(cvss.version ? { cvssVersion: `CVSS:${cvss.version}` } : {}),
140
189
  description,
141
190
  summary,
142
191
  fixVersion: fixVersionFromOsv(vuln, osvPkgName(dep)),
package/lib/outdated.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * EOL data comes from endoflife.date (with on-disk cache).
5
5
  * Obsolete data is curated locally (data/known-obsolete.json).
6
6
  * Outdated comes from Maven Central's maven-metadata.xml.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lib/priority.js — composite prioritisation of a CVE match.
3
+ *
4
+ * Blends three independent signals the report already carries:
5
+ * - CVSS base score (severity of the flaw if exploited)
6
+ * - EPSS percentile (likelihood it WILL be exploited — lib/epss.js)
7
+ * - CISA KEV membership (it IS being exploited in the wild — lib/kev.js)
8
+ *
9
+ * KEV always wins (band "exploited", score floored at 90). Otherwise the score
10
+ * is an 80/20 blend of CVSS and EPSS percentile. Pure — no I/O.
11
+ *
12
+ * @author: N.BRAUN
13
+ * @email: pp9ping@gmail.com
14
+ */
15
+
16
+ const SEV_SCORE = { CRITICAL: 9.5, HIGH: 7.5, MEDIUM: 5, LOW: 2, NONE: 0, UNKNOWN: 0 };
17
+
18
+ function severityToScore(sev) {
19
+ return SEV_SCORE[(sev || "UNKNOWN").toUpperCase()] ?? 0;
20
+ }
21
+
22
+ function clamp(n, lo, hi) {
23
+ return Math.max(lo, Math.min(hi, n));
24
+ }
25
+
26
+ /**
27
+ * Compute a priority object for a cve sub-record.
28
+ * Returns { score (0-100), band, cvss, epssPercentile, kev, sortKey }.
29
+ */
30
+ function computePriority(cve = {}) {
31
+ // A literal 0 is treated as "no CVSS" and we fall back to the severity label:
32
+ // enrichers emit null for scoreless CVEs, so a 0 here is a placeholder, and a
33
+ // CRITICAL-labelled finding must not band as "low" just because score===0.
34
+ const cvss = (typeof cve.score === "number" && cve.score > 0) ? cve.score : severityToScore(cve.severity);
35
+ const epssKnown = typeof cve.epssPercentile === "number";
36
+ const epssPercentile = epssKnown ? clamp(cve.epssPercentile, 0, 1) : 0;
37
+ const kev = !!cve.kev;
38
+
39
+ // When EPSS is known, blend 80% CVSS + 20% exploit-likelihood (so a known-low
40
+ // EPSS slightly deprioritises a high-CVSS flaw). When EPSS is absent we don't
41
+ // dilute — a CVSS 9.8 with no EPSS data must still read as critical.
42
+ let score = epssKnown
43
+ ? clamp(cvss * 10 * 0.8 + epssPercentile * 100 * 0.2, 0, 100)
44
+ : clamp(cvss * 10, 0, 100);
45
+ let band;
46
+ if (kev) {
47
+ score = Math.max(score, 90);
48
+ band = "exploited";
49
+ } else if (score >= 90) band = "critical";
50
+ else if (score >= 70) band = "high";
51
+ else if (score >= 40) band = "medium";
52
+ else band = "low";
53
+
54
+ const rounded = Math.round(score * 10) / 10;
55
+ return {
56
+ score: rounded,
57
+ band,
58
+ cvss,
59
+ epssPercentile,
60
+ kev,
61
+ // Descending-sort tuple, aligned with the displayed score: exploited
62
+ // first, then the blended score (which already folds in CVSS + EPSS).
63
+ sortKey: [kev ? 1 : 0, rounded],
64
+ };
65
+ }
66
+
67
+ /** Attach m.cve.priority to every match in place. */
68
+ function attachPriority(matches) {
69
+ for (const m of matches || []) {
70
+ if (m && m.cve) m.cve.priority = computePriority(m.cve);
71
+ }
72
+ return matches;
73
+ }
74
+
75
+ /** Compare two matches descending by priority sortKey, then CVE id ascending. */
76
+ function comparePriority(a, b) {
77
+ const pa = a.cve?.priority || computePriority(a.cve);
78
+ const pb = b.cve?.priority || computePriority(b.cve);
79
+ for (let i = 0; i < pa.sortKey.length; i++) {
80
+ if (pb.sortKey[i] !== pa.sortKey[i]) return pb.sortKey[i] - pa.sortKey[i];
81
+ }
82
+ return (a.cve?.id || "").localeCompare(b.cve?.id || "");
83
+ }
84
+
85
+ /** Return a new array sorted descending by priority. */
86
+ function sortByPriority(matches) {
87
+ return [...(matches || [])].sort(comparePriority);
88
+ }
89
+
90
+ module.exports = { computePriority, attachPriority, comparePriority, sortByPriority, severityToScore };