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.
- package/CHANGELOG.md +50 -0
- package/README.md +90 -16
- 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
package/lib/purl.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/purl.js — build Package URL (purl) strings from a depRecord.
|
|
3
|
+
*
|
|
4
|
+
* purl spec: pkg:<type>/<namespace>/<name>@<version>?qualifiers#subpath
|
|
5
|
+
* We only emit type/namespace/name@version — enough for CycloneDX `bom-ref`
|
|
6
|
+
* and CSAF `product_identification_helper.purl`.
|
|
7
|
+
*
|
|
8
|
+
* Pure (no I/O). Shared by lib/sbom-export.js and lib/csaf-export.js.
|
|
9
|
+
*
|
|
10
|
+
* @author: N.BRAUN
|
|
11
|
+
* @email: pp9ping@gmail.com
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// purl type per ecosystem. Maven keeps the dotted groupId as a single namespace
|
|
15
|
+
// segment (not slash-separated); npm/composer carry a namespace; pypi/nuget don't.
|
|
16
|
+
const TYPE = {
|
|
17
|
+
maven: "maven",
|
|
18
|
+
npm: "npm",
|
|
19
|
+
composer: "composer",
|
|
20
|
+
pypi: "pypi",
|
|
21
|
+
nuget: "nuget",
|
|
22
|
+
go: "golang",
|
|
23
|
+
ruby: "gem",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Per the purl spec, each namespace segment and the name are percent-encoded,
|
|
27
|
+
// but the dots inside a Maven groupId and the separators are preserved by
|
|
28
|
+
// encodeURIComponent (it leaves "." "-" "_" "~" alone), so it is the right tool.
|
|
29
|
+
function enc(s) {
|
|
30
|
+
return encodeURIComponent(String(s));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// PEP 503 normalisation for PyPI names (purl mandates the canonical form).
|
|
34
|
+
function normalizePypi(name) {
|
|
35
|
+
return String(name).toLowerCase().replace(/[-_.]+/g, "-");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a purl for a depRecord. Returns null when the dep lacks a usable
|
|
40
|
+
* ecosystem/name. Version is omitted when absent.
|
|
41
|
+
*/
|
|
42
|
+
function purlFor(dep) {
|
|
43
|
+
if (!dep || typeof dep !== "object") return null;
|
|
44
|
+
const eco = dep.ecosystem || dep.ecosystemType;
|
|
45
|
+
const type = TYPE[eco];
|
|
46
|
+
if (!type) return null;
|
|
47
|
+
|
|
48
|
+
const rawName = dep.name || dep.artifactId;
|
|
49
|
+
if (!rawName) return null;
|
|
50
|
+
let namespace = dep.namespace || dep.groupId || "";
|
|
51
|
+
let name = rawName;
|
|
52
|
+
|
|
53
|
+
if (eco === "npm" && !namespace && name.startsWith("@") && name.includes("/")) {
|
|
54
|
+
// Scoped package: "@scope/pkg" → namespace "@scope", name "pkg".
|
|
55
|
+
const slash = name.indexOf("/");
|
|
56
|
+
namespace = name.slice(0, slash);
|
|
57
|
+
name = name.slice(slash + 1);
|
|
58
|
+
} else if (eco === "pypi") {
|
|
59
|
+
name = normalizePypi(name);
|
|
60
|
+
namespace = "";
|
|
61
|
+
} else if (eco === "nuget") {
|
|
62
|
+
namespace = "";
|
|
63
|
+
} else if (eco === "go" && !namespace && name.includes("/")) {
|
|
64
|
+
// Go module path: namespace = everything up to the last "/", name = last segment.
|
|
65
|
+
const slash = name.lastIndexOf("/");
|
|
66
|
+
namespace = name.slice(0, slash);
|
|
67
|
+
name = name.slice(slash + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Namespace may itself be slash-separated (Go module paths); encode each
|
|
71
|
+
// segment but keep the separators.
|
|
72
|
+
const nsPart = namespace ? `${String(namespace).split("/").map(enc).join("/")}/` : "";
|
|
73
|
+
const verPart = dep.version ? `@${enc(dep.version)}` : "";
|
|
74
|
+
return `pkg:${type}/${nsPart}${enc(name)}${verPart}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { purlFor };
|
package/lib/retire.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* The CLI is expected at node_modules/.bin/retire (declared in
|
|
13
13
|
* package.json deps). When bundled with bun, we also try `retire`
|
|
14
14
|
* on PATH as a fallback.
|
|
15
|
+
*
|
|
16
|
+
* @author: N.BRAUN
|
|
17
|
+
* @email: pp9ping@gmail.com
|
|
15
18
|
*/
|
|
16
19
|
const fs = require("fs");
|
|
17
20
|
const path = require("path");
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sarif-export.js — emit a SARIF 2.1.0 log from CVE matches.
|
|
3
|
+
*
|
|
4
|
+
* SARIF is the integration standard for GitHub Code Scanning and GitLab: any
|
|
5
|
+
* tool that emits it shows up in their dashboards with no custom glue. One
|
|
6
|
+
* `reportingDescriptor` (rule) per unique CVE, one `result` per (CVE, dep)
|
|
7
|
+
* match, located at the manifest that declares the dep.
|
|
8
|
+
*
|
|
9
|
+
* buildSarif is pure; writeSarif writes the JSON to disk.
|
|
10
|
+
*
|
|
11
|
+
* @author: N.BRAUN
|
|
12
|
+
* @email: pp9ping@gmail.com
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const { purlFor } = require("./purl");
|
|
17
|
+
|
|
18
|
+
// CVSS/severity → SARIF result level.
|
|
19
|
+
function sarifLevel(sev) {
|
|
20
|
+
switch ((sev || "").toUpperCase()) {
|
|
21
|
+
case "CRITICAL":
|
|
22
|
+
case "HIGH": return "error";
|
|
23
|
+
case "MEDIUM": return "warning";
|
|
24
|
+
default: return "note";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function relUri(p, srcRoot) {
|
|
29
|
+
if (!p) return null;
|
|
30
|
+
if (srcRoot && path.isAbsolute(p)) {
|
|
31
|
+
const r = path.relative(srcRoot, p);
|
|
32
|
+
if (r && !r.startsWith("..")) return r.split(path.sep).join("/");
|
|
33
|
+
}
|
|
34
|
+
return String(p).split(path.sep).join("/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function depCoord(dep) {
|
|
38
|
+
const ns = dep.namespace || dep.groupId || "";
|
|
39
|
+
const name = dep.name || dep.artifactId;
|
|
40
|
+
if (dep.ecosystem === "maven" && ns) return `${ns}:${name}`;
|
|
41
|
+
if (dep.ecosystem === "composer" && ns) return `${ns}/${name}`;
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a SARIF 2.1.0 object from matches.
|
|
47
|
+
* opts: { projectInfo, toolVersion }
|
|
48
|
+
*/
|
|
49
|
+
function buildSarif(matches, opts = {}) {
|
|
50
|
+
const { projectInfo = {}, toolVersion = "0" } = opts;
|
|
51
|
+
const srcRoot = projectInfo.src && projectInfo.src.startsWith("(") ? null : projectInfo.src;
|
|
52
|
+
|
|
53
|
+
const rulesById = new Map();
|
|
54
|
+
const results = [];
|
|
55
|
+
|
|
56
|
+
for (const m of matches || []) {
|
|
57
|
+
const id = m.cve?.id;
|
|
58
|
+
if (!id) continue;
|
|
59
|
+
const cve = m.cve;
|
|
60
|
+
|
|
61
|
+
if (!rulesById.has(id)) {
|
|
62
|
+
const rule = {
|
|
63
|
+
id,
|
|
64
|
+
name: id,
|
|
65
|
+
shortDescription: { text: `${id} affecting ${depCoord(m.dep)}` },
|
|
66
|
+
helpUri: id.startsWith("CVE-") ? `https://nvd.nist.gov/vuln/detail/${id}` : `https://osv.dev/vulnerability/${id}`,
|
|
67
|
+
properties: {},
|
|
68
|
+
};
|
|
69
|
+
// GitHub Code Scanning reads `security-severity` (a CVSS number string).
|
|
70
|
+
if (cve.score != null) rule.properties["security-severity"] = String(cve.score);
|
|
71
|
+
const tags = ["security", "external/cwe", "dependency"];
|
|
72
|
+
if (cve.kev) tags.push("known-exploited", "cisa-kev");
|
|
73
|
+
rule.properties.tags = tags;
|
|
74
|
+
if (Array.isArray(cve.cwes)) rule.properties.cwe = cve.cwes;
|
|
75
|
+
rulesById.set(id, rule);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const coord = depCoord(m.dep);
|
|
79
|
+
const purl = purlFor(m.dep);
|
|
80
|
+
const bits = [`${coord}@${m.dep.version || "?"}`];
|
|
81
|
+
if (cve.kev) bits.push("CISA-KEV (exploited)");
|
|
82
|
+
if (cve.epssPercentile != null) bits.push(`EPSS ${Math.round(cve.epssPercentile * 100)}%`);
|
|
83
|
+
if (cve.fixVersion) bits.push(`fix ≥ ${cve.fixVersion}`);
|
|
84
|
+
const text = `${id} in ${bits.join(" · ")}${cve.description ? ` — ${cve.description.slice(0, 300)}` : ""}`;
|
|
85
|
+
|
|
86
|
+
const locations = (m.dep.manifestPaths || m.dep.pomPaths || [])
|
|
87
|
+
.map(p => relUri(p, srcRoot))
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map(uri => ({ physicalLocation: { artifactLocation: { uri } } }));
|
|
90
|
+
|
|
91
|
+
const props = {};
|
|
92
|
+
if (purl) props.purl = purl;
|
|
93
|
+
if (cve.epssScore != null) props.epss = cve.epssScore;
|
|
94
|
+
if (cve.epssPercentile != null) props.epssPercentile = cve.epssPercentile;
|
|
95
|
+
if (cve.kev) props.kev = true;
|
|
96
|
+
if (cve.priority?.band) props.priorityBand = cve.priority.band;
|
|
97
|
+
if (m.dep.provenance && m.dep.provenance !== "manifest") props.provenance = m.dep.provenance;
|
|
98
|
+
if (m.cpeFiltered) props.cpeFiltered = true;
|
|
99
|
+
if (m.source) props.sources = m.source;
|
|
100
|
+
|
|
101
|
+
results.push({
|
|
102
|
+
ruleId: id,
|
|
103
|
+
level: sarifLevel(cve.severity),
|
|
104
|
+
message: { text },
|
|
105
|
+
...(locations.length ? { locations } : {}),
|
|
106
|
+
partialFingerprints: { fadKey: `${coord}@${m.dep.version || "?"}|${id}` },
|
|
107
|
+
properties: props,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
113
|
+
version: "2.1.0",
|
|
114
|
+
runs: [{
|
|
115
|
+
tool: {
|
|
116
|
+
driver: {
|
|
117
|
+
name: "fad-checker",
|
|
118
|
+
version: String(toolVersion),
|
|
119
|
+
informationUri: "https://github.com/n8tz/fad-checker",
|
|
120
|
+
rules: [...rulesById.values()],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
results,
|
|
124
|
+
}],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeSarif(matches, outputPath, opts = {}) {
|
|
129
|
+
const doc = buildSarif(matches, opts);
|
|
130
|
+
fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n");
|
|
131
|
+
return doc;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { buildSarif, writeSarif, sarifLevel };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sbom-export.js — emit a CycloneDX 1.6 SBOM with vulnerabilities inline
|
|
3
|
+
* (a VDR — Vulnerability Disclosure Report) from the resolved deps + matches.
|
|
4
|
+
*
|
|
5
|
+
* buildCycloneDx is pure (testable); writeCycloneDx writes the JSON to disk.
|
|
6
|
+
*
|
|
7
|
+
* @author: N.BRAUN
|
|
8
|
+
* @email: pp9ping@gmail.com
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const { purlFor } = require("./purl");
|
|
12
|
+
|
|
13
|
+
// NVD "CVSS:3.1" → CycloneDX rating method enum. Tolerates the legacy
|
|
14
|
+
// "CVSS:V31"/"V30"/"V40"/"V2" labels left in pre-fix NVD caches.
|
|
15
|
+
function cvssMethod(cvssVersion) {
|
|
16
|
+
const v = String(cvssVersion || "");
|
|
17
|
+
if (v.includes("4.0") || /v40\b/i.test(v)) return "CVSSv4";
|
|
18
|
+
if (v.includes("3.1") || /v31\b/i.test(v)) return "CVSSv31";
|
|
19
|
+
if (v.includes("3.0") || v === "CVSS:3" || /v30\b/i.test(v)) return "CVSSv3";
|
|
20
|
+
if (v.includes("2.0") || v === "CVSS:2" || /v2\b/i.test(v)) return "CVSSv2";
|
|
21
|
+
return "other";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Point the advisory source at the right database for the id type — a GHSA/OSV id
|
|
25
|
+
// has no page on nvd.nist.gov, so the old hardcoded NVD url was a dead link.
|
|
26
|
+
function advisorySource(id, source) {
|
|
27
|
+
if (/^CVE-/i.test(id)) return { name: "NVD", url: `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(id)}` };
|
|
28
|
+
if (/^GHSA-/i.test(id)) return { name: "GitHub Advisory Database", url: `https://github.com/advisories/${encodeURIComponent(id)}` };
|
|
29
|
+
return { name: (source || "").includes("nvd") ? "NVD" : "OSV", url: `https://osv.dev/vulnerability/${encodeURIComponent(id)}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stable, UNIQUE bom-ref. Declared deps use their purl (unchanged). Embedded
|
|
33
|
+
// binaries get a location-qualified ref so two copies of the same coordinate
|
|
34
|
+
// (one declared, one shaded into a jar) don't collide on the same bom-ref.
|
|
35
|
+
function bomRefFor(dep, purl) {
|
|
36
|
+
if (dep.provenance === "embedded") return dep.coordKey; // already "embedded:<path>" — unique
|
|
37
|
+
return purl || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cdxSeverity(sev) {
|
|
41
|
+
const s = (sev || "unknown").toLowerCase();
|
|
42
|
+
return ["critical", "high", "medium", "low", "none", "info"].includes(s) ? s : "unknown";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cweNum(c) {
|
|
46
|
+
const m = String(c).match(/(\d+)/);
|
|
47
|
+
return m ? parseInt(m[1], 10) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function licenseEntry(ids) {
|
|
51
|
+
// CycloneDX: {license:{id}} for SPDX ids, {license:{name}} otherwise.
|
|
52
|
+
return (ids || []).map(x => /^[A-Za-z0-9.+-]+$/.test(x) && /[A-Z]/.test(x) ? { license: { id: x } } : { license: { name: x } });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a CycloneDX 1.6 object.
|
|
57
|
+
* opts: { projectInfo, toolVersion, timestamp, licenseResults }
|
|
58
|
+
*/
|
|
59
|
+
function buildCycloneDx(resolvedDeps, cveMatches, opts = {}) {
|
|
60
|
+
const { projectInfo = {}, toolVersion = "0", timestamp, licenseResults } = opts;
|
|
61
|
+
|
|
62
|
+
// coordKey → [spdx ids] for component licenses (optional).
|
|
63
|
+
const licByCoord = new Map();
|
|
64
|
+
for (const e of licenseResults?.assessed || []) {
|
|
65
|
+
const key = e.dep?.coordKey || `${e.dep?.namespace || ""}:${e.dep?.name}`;
|
|
66
|
+
const ids = (e.ids || []).concat(e.raw || []);
|
|
67
|
+
if (ids.length) licByCoord.set(key, ids);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const components = [];
|
|
71
|
+
for (const dep of resolvedDeps.values()) {
|
|
72
|
+
const purl = purlFor(dep);
|
|
73
|
+
const comp = { type: "library", name: dep.name || dep.artifactId, version: dep.version || undefined };
|
|
74
|
+
if (dep.namespace || dep.groupId) comp.group = dep.namespace || dep.groupId;
|
|
75
|
+
const ref = bomRefFor(dep, purl);
|
|
76
|
+
if (ref) comp["bom-ref"] = ref;
|
|
77
|
+
if (purl) comp.purl = purl;
|
|
78
|
+
const lic = licByCoord.get(dep.coordKey);
|
|
79
|
+
if (lic) comp.licenses = licenseEntry(lic);
|
|
80
|
+
// Mark embedded-binary components so the SBOM consumer can tell a vendored/fat-jar
|
|
81
|
+
// coordinate apart from a declared dependency, and where it physically lives.
|
|
82
|
+
if (dep.provenance === "embedded") {
|
|
83
|
+
comp.properties = [
|
|
84
|
+
{ name: "fad:provenance", value: "embedded" },
|
|
85
|
+
...((dep.manifestPaths || [])[0] ? [{ name: "fad:location", value: dep.manifestPaths[0] }] : []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
components.push(comp);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One vulnerability entry per CVE id, aggregating affected component refs.
|
|
92
|
+
const byCve = new Map();
|
|
93
|
+
for (const m of cveMatches || []) {
|
|
94
|
+
const id = m.cve?.id;
|
|
95
|
+
if (!id) continue;
|
|
96
|
+
const ref = bomRefFor(m.dep, purlFor(m.dep)); // match the component bom-ref (unique for embedded)
|
|
97
|
+
let v = byCve.get(id);
|
|
98
|
+
if (!v) {
|
|
99
|
+
const cve = m.cve;
|
|
100
|
+
v = {
|
|
101
|
+
"bom-ref": `vuln-${id}`,
|
|
102
|
+
id,
|
|
103
|
+
source: advisorySource(id, m.source),
|
|
104
|
+
ratings: cve.score != null ? [{
|
|
105
|
+
source: { name: "NVD" },
|
|
106
|
+
score: cve.score,
|
|
107
|
+
severity: cdxSeverity(cve.severity),
|
|
108
|
+
method: cvssMethod(cve.cvssVersion),
|
|
109
|
+
...(cve.cvssVector ? { vector: cve.cvssVector } : {}),
|
|
110
|
+
}] : [],
|
|
111
|
+
...(Array.isArray(cve.cwes) && cve.cwes.length ? { cwes: cve.cwes.map(cweNum).filter(n => n != null) } : {}),
|
|
112
|
+
...(cve.description ? { description: cve.description } : {}),
|
|
113
|
+
affects: [],
|
|
114
|
+
properties: buildVulnProps(cve),
|
|
115
|
+
};
|
|
116
|
+
byCve.set(id, v);
|
|
117
|
+
}
|
|
118
|
+
if (ref && !v.affects.some(a => a.ref === ref)) v.affects.push({ ref });
|
|
119
|
+
if (m.cpeFiltered) v.properties.push({ name: "fad:cpe-filtered", value: "true" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bom = {
|
|
123
|
+
bomFormat: "CycloneDX",
|
|
124
|
+
specVersion: "1.6",
|
|
125
|
+
version: 1,
|
|
126
|
+
metadata: {
|
|
127
|
+
...(timestamp ? { timestamp } : {}),
|
|
128
|
+
tools: { components: [{ type: "application", name: "fad-checker", version: String(toolVersion) }] },
|
|
129
|
+
component: { type: "application", name: projectInfo.name || "project" },
|
|
130
|
+
},
|
|
131
|
+
components,
|
|
132
|
+
vulnerabilities: [...byCve.values()],
|
|
133
|
+
};
|
|
134
|
+
return bom;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildVulnProps(cve) {
|
|
138
|
+
const props = [];
|
|
139
|
+
if (cve.epssScore != null) props.push({ name: "fad:epss", value: String(cve.epssScore) });
|
|
140
|
+
if (cve.epssPercentile != null) props.push({ name: "fad:epssPercentile", value: String(cve.epssPercentile) });
|
|
141
|
+
if (cve.kev) props.push({ name: "fad:kev", value: "true" });
|
|
142
|
+
if (cve.priority?.band) props.push({ name: "fad:priorityBand", value: cve.priority.band });
|
|
143
|
+
if (cve.priority?.score != null) props.push({ name: "fad:priorityScore", value: String(cve.priority.score) });
|
|
144
|
+
return props;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeCycloneDx(resolvedDeps, cveMatches, outputPath, opts = {}) {
|
|
148
|
+
const bom = buildCycloneDx(resolvedDeps, cveMatches, opts);
|
|
149
|
+
fs.writeFileSync(outputPath, JSON.stringify(bom, null, 2) + "\n");
|
|
150
|
+
return bom;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { buildCycloneDx, writeCycloneDx, cvssMethod, cdxSeverity };
|
package/lib/scan-completeness.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Output: an array of warning objects matching the same shape as
|
|
20
20
|
* lib/npm/collect.js warnings ({ type, manifestPath?, message, ... }).
|
|
21
|
+
*
|
|
22
|
+
* @author: N.BRAUN
|
|
23
|
+
* @email: pp9ping@gmail.com
|
|
21
24
|
*/
|
|
22
25
|
/**
|
|
23
26
|
* Scan the resolved deps map and return warnings.
|
package/lib/snyk.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Runs `snyk test --all-projects --json` on the cleaned POM directory and
|
|
5
5
|
* normalises the output to fad-checker's CVE match shape so the report can
|
|
6
6
|
* merge findings from both engines.
|
|
7
|
+
*
|
|
8
|
+
* @author: N.BRAUN
|
|
9
|
+
* @email: pp9ping@gmail.com
|
|
7
10
|
*/
|
|
8
11
|
const { execFile } = require("child_process");
|
|
9
12
|
const { promisify } = require("util");
|
package/lib/suppress.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/suppress.js — triage: suppress accepted-risk / false-positive findings so
|
|
3
|
+
* re-audits of the same codebase stay signal-rich.
|
|
4
|
+
*
|
|
5
|
+
* Two inputs:
|
|
6
|
+
* --ignore <file> : plain text rules, one per line:
|
|
7
|
+
* CVE-2021-44228 # suppress this CVE everywhere
|
|
8
|
+
* CVE-2021-44228 org.apache.* # …only for matching coord/purl (glob)
|
|
9
|
+
* * com.acme.internal:* # any CVE for these coords
|
|
10
|
+
* anything after '#' is a reason
|
|
11
|
+
* --vex <file> : a CSAF VEX document — CVEs whose product_status is
|
|
12
|
+
* known_not_affected / fixed are suppressed (round-trips fad's own
|
|
13
|
+
* --report-csaf output; products mapped back to coords via their purl).
|
|
14
|
+
*
|
|
15
|
+
* Pure parsing + matching; the caller reads the files and mutates matches.
|
|
16
|
+
*
|
|
17
|
+
* @author: N.BRAUN
|
|
18
|
+
* @email: pp9ping@gmail.com
|
|
19
|
+
*/
|
|
20
|
+
const { purlFor } = require("./purl");
|
|
21
|
+
|
|
22
|
+
function coordOf(dep) {
|
|
23
|
+
const ns = dep.namespace || dep.groupId || "";
|
|
24
|
+
const name = dep.name || dep.artifactId;
|
|
25
|
+
if (dep.ecosystem === "maven" && ns) return `${ns}:${name}`;
|
|
26
|
+
if (dep.ecosystem === "composer" && ns) return `${ns}/${name}`;
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Glob → anchored RegExp ('*' = any run). Everything else is literal.
|
|
31
|
+
function globToRe(glob) {
|
|
32
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
33
|
+
return new RegExp(`^${esc}$`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse an --ignore file body into rules [{ cve, coordRe, coord, reason }]. */
|
|
37
|
+
function parseIgnoreFile(text) {
|
|
38
|
+
const rules = [];
|
|
39
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
40
|
+
const hash = raw.indexOf("#");
|
|
41
|
+
const reason = hash >= 0 ? raw.slice(hash + 1).trim() : null;
|
|
42
|
+
const line = (hash >= 0 ? raw.slice(0, hash) : raw).trim();
|
|
43
|
+
if (!line) continue;
|
|
44
|
+
const parts = line.split(/\s+/);
|
|
45
|
+
const cve = parts[0];
|
|
46
|
+
const coord = parts[1] || null;
|
|
47
|
+
rules.push({ cve, coord, coordRe: coord ? globToRe(coord) : null, reason: reason || "ignored" });
|
|
48
|
+
}
|
|
49
|
+
return rules;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse a CSAF VEX document into suppression rules for not_affected/fixed CVEs. */
|
|
53
|
+
function parseVex(csaf) {
|
|
54
|
+
const rules = [];
|
|
55
|
+
if (!csaf || typeof csaf !== "object") return rules;
|
|
56
|
+
// product_id → purl, to map suppressed products back to coordinates.
|
|
57
|
+
const purlByPid = {};
|
|
58
|
+
for (const p of csaf.product_tree?.full_product_names || []) {
|
|
59
|
+
const purl = p.product_identification_helper?.purl;
|
|
60
|
+
if (p.product_id && purl) purlByPid[p.product_id] = purl;
|
|
61
|
+
}
|
|
62
|
+
for (const v of csaf.vulnerabilities || []) {
|
|
63
|
+
const cve = v.cve;
|
|
64
|
+
if (!cve) continue;
|
|
65
|
+
const cleared = [
|
|
66
|
+
...(v.product_status?.known_not_affected || []),
|
|
67
|
+
...(v.product_status?.fixed || []),
|
|
68
|
+
];
|
|
69
|
+
if (!cleared.length) {
|
|
70
|
+
// No product scope → suppress the CVE globally.
|
|
71
|
+
rules.push({ cve, coord: null, coordRe: null, reason: "VEX: not affected / fixed" });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const pid of cleared) {
|
|
75
|
+
const purl = purlByPid[pid];
|
|
76
|
+
// A product we can't map back to a coord must NOT become a coordRe:null
|
|
77
|
+
// rule — that would suppress this CVE for EVERY dependency (a security
|
|
78
|
+
// false-negative). Skip it: a product-scoped clearance only ever narrows.
|
|
79
|
+
if (!purl) continue;
|
|
80
|
+
rules.push({ cve, coord: purl, coordRe: globToRe(purl), reason: "VEX: not affected / fixed" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return rules;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ruleMatches(rule, m) {
|
|
87
|
+
if (rule.cve && rule.cve !== "*" && rule.cve !== m.cve?.id) return false;
|
|
88
|
+
if (!rule.coordRe) return true;
|
|
89
|
+
const coord = coordOf(m.dep);
|
|
90
|
+
const purl = purlFor(m.dep) || "";
|
|
91
|
+
return rule.coordRe.test(coord) || rule.coordRe.test(purl) || rule.coordRe.test(m.dep.name || "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build a matcher from a flat rule list. */
|
|
95
|
+
function buildSuppressor(rules) {
|
|
96
|
+
return (m) => {
|
|
97
|
+
for (const r of rules || []) if (ruleMatches(r, m)) return { suppressed: true, reason: r.reason };
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Mark matches in place; returns the number suppressed. */
|
|
103
|
+
function applySuppressions(matches, rules) {
|
|
104
|
+
const suppressor = buildSuppressor(rules);
|
|
105
|
+
let n = 0;
|
|
106
|
+
for (const m of matches || []) {
|
|
107
|
+
const hit = suppressor(m);
|
|
108
|
+
if (hit) { m.suppressed = true; m.suppressedReason = hit.reason; n++; }
|
|
109
|
+
}
|
|
110
|
+
return n;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { parseIgnoreFile, parseVex, buildSuppressor, applySuppressions, globToRe };
|
package/lib/transitive.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* - <relocation> handling (rare in modern artifacts)
|
|
19
19
|
* - non-central repositories (everything fetched from repo1.maven.org)
|
|
20
20
|
* - SNAPSHOT version resolution (we just return the literal version)
|
|
21
|
+
*
|
|
22
|
+
* @author: N.BRAUN
|
|
23
|
+
* @email: pp9ping@gmail.com
|
|
21
24
|
*/
|
|
22
25
|
const fs = require("fs");
|
|
23
26
|
const path = require("path");
|
package/lib/ui.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* The Progress indicator renders a "[n/N] <spinner> label — summary" checklist.
|
|
6
6
|
* TTY: one animated line per step, rewritten in place, finalized with ✓/⊘/✗.
|
|
7
7
|
* Non-TTY (pipes, CI, files): a single plain line per finished step, no escapes.
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
8
11
|
*/
|
|
9
12
|
const chalk = require("chalk");
|
|
10
13
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.0
|
|
4
|
-
"description": "Scan ALL Maven, npm, Yarn, Composer, Python
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Scan ALL Maven, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sca",
|
|
7
|
+
"software-composition-analysis",
|
|
8
|
+
"dependency-scanner",
|
|
9
|
+
"vulnerability-scanner",
|
|
10
|
+
"security",
|
|
11
|
+
"cve",
|
|
12
|
+
"osv",
|
|
13
|
+
"nvd",
|
|
14
|
+
"epss",
|
|
15
|
+
"kev",
|
|
16
|
+
"cyclonedx",
|
|
17
|
+
"sbom",
|
|
18
|
+
"csaf",
|
|
19
|
+
"vex",
|
|
20
|
+
"sarif",
|
|
21
|
+
"license-compliance",
|
|
22
|
+
"eol",
|
|
23
|
+
"end-of-life",
|
|
24
|
+
"outdated",
|
|
25
|
+
"deprecated",
|
|
26
|
+
"audit",
|
|
27
|
+
"devsecops",
|
|
28
|
+
"appsec",
|
|
29
|
+
"supply-chain",
|
|
30
|
+
"offline",
|
|
31
|
+
"air-gapped",
|
|
32
|
+
"passi",
|
|
33
|
+
"maven",
|
|
34
|
+
"npm",
|
|
35
|
+
"yarn",
|
|
36
|
+
"pnpm",
|
|
37
|
+
"composer",
|
|
38
|
+
"pypi",
|
|
39
|
+
"nuget",
|
|
40
|
+
"go",
|
|
41
|
+
"golang",
|
|
42
|
+
"ruby",
|
|
43
|
+
"rubygems",
|
|
44
|
+
"retirejs",
|
|
45
|
+
"polyglot",
|
|
46
|
+
"cli"
|
|
47
|
+
],
|
|
5
48
|
"license": "MIT",
|
|
6
49
|
"repository": "https://github.com/n8tz/fad-checker",
|
|
50
|
+
"homepage": "https://github.com/n8tz/fad-checker#readme",
|
|
51
|
+
"bugs": "https://github.com/n8tz/fad-checker/issues",
|
|
7
52
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
8
53
|
"maintainers": [
|
|
9
54
|
"pp9Ping <pp9Ping@gmail.com>"
|
|
@@ -25,6 +70,7 @@
|
|
|
25
70
|
"dependencies": {
|
|
26
71
|
"chalk": "^4.1.2",
|
|
27
72
|
"commander": "^14.0.1",
|
|
73
|
+
"fflate": "^0.8.3",
|
|
28
74
|
"js-yaml": "^4.1.1",
|
|
29
75
|
"p-limit": "^3.1.0",
|
|
30
76
|
"retire": "^5.4.2",
|