fad-checker 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +91 -17
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/license-policy.json +97 -0
- package/fad-checker-report/cve-report.doc +630 -0
- package/fad-checker-report/cve-report.html +748 -0
- package/fad-checker.js +278 -53
- package/lib/cache-archive.js +3 -0
- package/lib/codecs/codec.interface.js +3 -0
- package/lib/codecs/composer/parse.js +3 -0
- package/lib/codecs/composer/registry.js +13 -5
- package/lib/codecs/composer.codec.js +3 -0
- package/lib/codecs/go/parse.js +65 -0
- package/lib/codecs/go/registry.js +74 -0
- package/lib/codecs/go.codec.js +76 -0
- package/lib/codecs/index.js +7 -2
- package/lib/codecs/maven/jar-scan.js +199 -0
- package/lib/codecs/maven.codec.js +17 -1
- package/lib/codecs/npm/collect.js +11 -0
- package/lib/codecs/npm/parse.js +3 -0
- package/lib/codecs/npm/registry.js +10 -2
- package/lib/codecs/npm.codec.js +3 -0
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +3 -0
- package/lib/codecs/pypi/parse.js +7 -2
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +3 -0
- package/lib/codecs/recipes.js +28 -1
- package/lib/codecs/ruby/parse.js +42 -0
- package/lib/codecs/ruby/registry.js +76 -0
- package/lib/codecs/ruby.codec.js +66 -0
- package/lib/codecs/select.js +3 -0
- package/lib/codecs/yarn.codec.js +3 -0
- package/lib/config.js +3 -0
- package/lib/core.js +3 -0
- package/lib/cpe.js +30 -5
- package/lib/csaf-export.js +159 -0
- package/lib/cve-download.js +3 -0
- package/lib/cve-match.js +12 -1
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +3 -0
- package/lib/epss.js +115 -0
- package/lib/gate.js +45 -0
- package/lib/json-export.js +110 -0
- package/lib/kev.js +88 -0
- package/lib/license-policy.js +110 -0
- package/lib/maven-license.js +52 -0
- package/lib/maven-repo.js +3 -0
- package/lib/maven-version.js +8 -3
- package/lib/nvd.js +13 -3
- package/lib/osv.js +68 -19
- package/lib/outdated.js +3 -0
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +3 -0
- package/lib/sarif-export.js +134 -0
- package/lib/sbom-export.js +153 -0
- package/lib/scan-completeness.js +3 -0
- package/lib/snyk.js +3 -0
- package/lib/suppress.js +113 -0
- package/lib/transitive.js +3 -0
- package/lib/ui.js +3 -0
- 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
|
|
package/lib/maven-version.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
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");
|
package/lib/priority.js
ADDED
|
@@ -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 };
|