fad-checker 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +147 -25
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/eol-mapping.json +5 -5
- 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 +556 -245
- 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 +37 -17
- package/lib/codecs/npm/parse.js +117 -10
- package/lib/codecs/npm/registry.js +14 -5
- package/lib/codecs/npm.codec.js +4 -1
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +7 -2
- package/lib/codecs/pypi/parse.js +111 -14
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +30 -6
- 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 +38 -1
- 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 +27 -6
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +113 -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 +17 -6
- package/lib/osv.js +75 -23
- package/lib/outdated.js +10 -2
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +80 -13
- 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 +6 -3
- package/lib/ui.js +90 -0
- package/package.json +49 -2
- package/CLAUDE.md +0 -147
- package/docs/ARCHITECTURE.md +0 -182
- package/docs/USAGE.md +0 -187
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
- package/test/cli-ecosystem.test.js +0 -30
- package/test/codec-edge-cases.test.js +0 -131
- package/test/codec-integration.test.js +0 -119
- package/test/codecs.test.js +0 -92
- package/test/composer.test.js +0 -71
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -180
- package/test/dep-record.test.js +0 -31
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/csharp-config/packages.config +0 -4
- package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
- package/test/fixtures/csharp-csproj/app.csproj +0 -7
- package/test/fixtures/csharp-lock/packages.lock.json +0 -6
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/php-app/composer.json +0 -5
- package/test/fixtures/php-app/composer.lock +0 -10
- package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
- package/test/fixtures/polyglot/js/package-lock.json +0 -1
- package/test/fixtures/polyglot/js/package.json +0 -1
- package/test/fixtures/polyglot/mvn/pom.xml +0 -7
- package/test/fixtures/polyglot/php/composer.lock +0 -1
- package/test/fixtures/polyglot/py/poetry.lock +0 -3
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
- package/test/fixtures/python-poetry/poetry.lock +0 -7
- package/test/fixtures/python-reqs/requirements.txt +0 -5
- package/test/fixtures/python-uv/uv.lock +0 -3
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -150
- package/test/nuget.test.js +0 -66
- package/test/osv.test.js +0 -62
- package/test/outdated.test.js +0 -101
- package/test/pypi.test.js +0 -72
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/csaf-export.js — emit a CSAF 2.0 VEX (csaf_vex) document from the resolved
|
|
3
|
+
* deps + matches. buildCsaf is pure; writeCsaf writes the JSON to disk.
|
|
4
|
+
*
|
|
5
|
+
* Spec: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html
|
|
6
|
+
*
|
|
7
|
+
* @author: N.BRAUN
|
|
8
|
+
* @email: pp9ping@gmail.com
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const { purlFor } = require("./purl");
|
|
12
|
+
|
|
13
|
+
function humanName(dep) {
|
|
14
|
+
const ns = dep.namespace || dep.groupId || "";
|
|
15
|
+
const name = dep.name || dep.artifactId;
|
|
16
|
+
const base = dep.ecosystem === "composer" && ns ? `${ns}/${name}` : (ns && dep.ecosystem === "maven" ? `${ns}:${name}` : name);
|
|
17
|
+
return dep.version ? `${base}@${dep.version}` : base;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// NVD "CVSS:3.1" → CVSS v3 JSON `version` field, or null when not a v3 score.
|
|
21
|
+
// Tolerates the legacy "CVSS:V31"/"CVSS:V30" labels still sitting in pre-fix NVD
|
|
22
|
+
// caches (the producer now emits "CVSS:3.1"; this keeps offline caches working).
|
|
23
|
+
function cvssV3Version(cvssVersion) {
|
|
24
|
+
const v = String(cvssVersion || "");
|
|
25
|
+
if (v.includes("3.1") || /v31\b/i.test(v)) return "3.1";
|
|
26
|
+
if (v.includes("3.0") || v === "CVSS:3" || /v30\b/i.test(v)) return "3.0";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// CVSS v3 `baseSeverity` is a closed enum (NONE|LOW|MEDIUM|HIGH|CRITICAL). OSV/GHSA
|
|
31
|
+
// often yield "UNKNOWN", which fails CSAF schema validation — derive it from the
|
|
32
|
+
// base score's standard v3 bands when the label isn't a valid enum value.
|
|
33
|
+
function cvssV3Severity(severity, score) {
|
|
34
|
+
const s = String(severity || "").toUpperCase();
|
|
35
|
+
if (["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"].includes(s)) return s;
|
|
36
|
+
if (typeof score !== "number") return "NONE";
|
|
37
|
+
if (score === 0) return "NONE";
|
|
38
|
+
if (score < 4) return "LOW";
|
|
39
|
+
if (score < 7) return "MEDIUM";
|
|
40
|
+
if (score < 9) return "HIGH";
|
|
41
|
+
return "CRITICAL";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a CSAF 2.0 csaf_vex object.
|
|
46
|
+
* opts: { projectInfo, toolVersion, timestamp }
|
|
47
|
+
*/
|
|
48
|
+
function buildCsaf(resolvedDeps, cveMatches, opts = {}) {
|
|
49
|
+
const { projectInfo = {}, toolVersion = "0", timestamp } = opts;
|
|
50
|
+
const now = timestamp || projectInfo.generatedAt || "1970-01-01T00:00:00Z";
|
|
51
|
+
|
|
52
|
+
// Stable product ids + coordKey → product_id lookup.
|
|
53
|
+
const full_product_names = [];
|
|
54
|
+
const productIdByCoord = new Map();
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const dep of resolvedDeps.values()) {
|
|
57
|
+
const pid = `PROD-${n++}`;
|
|
58
|
+
const purl = purlFor(dep);
|
|
59
|
+
productIdByCoord.set(dep.coordKey, pid);
|
|
60
|
+
full_product_names.push({
|
|
61
|
+
product_id: pid,
|
|
62
|
+
name: humanName(dep),
|
|
63
|
+
...(purl ? { product_identification_helper: { purl } } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One vulnerability per CVE, listing every affected product id.
|
|
68
|
+
const byCve = new Map();
|
|
69
|
+
for (const m of cveMatches || []) {
|
|
70
|
+
const id = m.cve?.id;
|
|
71
|
+
if (!id) continue;
|
|
72
|
+
let pid = productIdByCoord.get(m.dep?.coordKey);
|
|
73
|
+
// A match whose dep isn't in resolvedDeps (e.g. a Snyk-only finding) has no
|
|
74
|
+
// product yet — register it on the fly so the CVE never ends up with an empty
|
|
75
|
+
// known_affected[] (which is schema-invalid: product_status lists need ≥1 item).
|
|
76
|
+
if (!pid && m.dep && m.dep.coordKey) {
|
|
77
|
+
pid = `PROD-${n++}`;
|
|
78
|
+
const purl = purlFor(m.dep);
|
|
79
|
+
productIdByCoord.set(m.dep.coordKey, pid);
|
|
80
|
+
full_product_names.push({
|
|
81
|
+
product_id: pid,
|
|
82
|
+
name: humanName(m.dep),
|
|
83
|
+
...(purl ? { product_identification_helper: { purl } } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
let v = byCve.get(id);
|
|
87
|
+
if (!v) {
|
|
88
|
+
const cve = m.cve;
|
|
89
|
+
const notes = [];
|
|
90
|
+
if (cve.description) notes.push({ category: "description", text: cve.description, title: "Description" });
|
|
91
|
+
const extras = [];
|
|
92
|
+
if (cve.epssPercentile != null) extras.push(`EPSS percentile ${Math.round(cve.epssPercentile * 100)}%`);
|
|
93
|
+
if (cve.kev) extras.push(`CISA KEV (known exploited)${cve.kevDueDate ? `, remediate by ${cve.kevDueDate}` : ""}`);
|
|
94
|
+
if (cve.priority?.band) extras.push(`fad-checker priority: ${cve.priority.band} (${cve.priority.score}/100)`);
|
|
95
|
+
if (extras.length) notes.push({ category: "other", text: extras.join("; "), title: "Prioritization" });
|
|
96
|
+
|
|
97
|
+
const v3ver = cvssV3Version(cve.cvssVersion);
|
|
98
|
+
const scores = (v3ver && cve.score != null) ? [{
|
|
99
|
+
cvss_v3: {
|
|
100
|
+
version: v3ver,
|
|
101
|
+
baseScore: cve.score,
|
|
102
|
+
baseSeverity: cvssV3Severity(cve.severity, cve.score),
|
|
103
|
+
// Only carry the vector when it actually IS a v3 vector — a v4 (or
|
|
104
|
+
// mismatched) vector string fails the v3 schema's vectorString pattern.
|
|
105
|
+
...((cve.cvssVector && /^CVSS:3\.[01]\//.test(cve.cvssVector)) ? { vectorString: cve.cvssVector } : {}),
|
|
106
|
+
},
|
|
107
|
+
products: [],
|
|
108
|
+
}] : [];
|
|
109
|
+
|
|
110
|
+
v = {
|
|
111
|
+
cve: id,
|
|
112
|
+
...(notes.length ? { notes } : {}),
|
|
113
|
+
product_status: { known_affected: [] },
|
|
114
|
+
...(scores.length ? { scores } : {}),
|
|
115
|
+
...(cve.kev ? { flags: [{ label: "exploited", product_ids: [] }] } : {}),
|
|
116
|
+
};
|
|
117
|
+
byCve.set(id, v);
|
|
118
|
+
}
|
|
119
|
+
if (pid && !v.product_status.known_affected.includes(pid)) {
|
|
120
|
+
v.product_status.known_affected.push(pid);
|
|
121
|
+
if (v.scores?.[0]) v.scores[0].products.push(pid);
|
|
122
|
+
if (v.flags?.[0]) v.flags[0].product_ids.push(pid);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
document: {
|
|
128
|
+
category: "csaf_vex",
|
|
129
|
+
csaf_version: "2.0",
|
|
130
|
+
title: `Vulnerability disclosure (VEX) — ${projectInfo.name || "project"}`,
|
|
131
|
+
publisher: {
|
|
132
|
+
category: "vendor",
|
|
133
|
+
name: "fad-checker",
|
|
134
|
+
namespace: "https://github.com/nathb2b/fad-checker",
|
|
135
|
+
},
|
|
136
|
+
tracking: {
|
|
137
|
+
id: `fad-checker-${(projectInfo.name || "project").replace(/[^A-Za-z0-9._-]/g, "-")}-${String(now).slice(0, 10)}`,
|
|
138
|
+
status: "final",
|
|
139
|
+
version: "1",
|
|
140
|
+
generator: { engine: { name: "fad-checker", version: String(toolVersion) } },
|
|
141
|
+
initial_release_date: now,
|
|
142
|
+
current_release_date: now,
|
|
143
|
+
revision_history: [{ number: "1", date: now, summary: "Initial automated VEX from fad-checker scan" }],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
product_tree: { full_product_names },
|
|
147
|
+
// Drop any vulnerability that ended up with zero affected products (e.g. a
|
|
148
|
+
// match with no usable dep) — an empty product_status is schema-invalid.
|
|
149
|
+
vulnerabilities: [...byCve.values()].filter(v => v.product_status.known_affected.length > 0),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeCsaf(resolvedDeps, cveMatches, outputPath, opts = {}) {
|
|
154
|
+
const doc = buildCsaf(resolvedDeps, cveMatches, opts);
|
|
155
|
+
fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n");
|
|
156
|
+
return doc;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { buildCsaf, writeCsaf };
|
package/lib/cve-download.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* The bundle is ~500 MB+. We shell out to curl + unzip rather than
|
|
6
6
|
* bundling a zip library. The extracted JSON is deleted after the index
|
|
7
7
|
* is built.
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
8
11
|
*/
|
|
9
12
|
const fs = require("fs");
|
|
10
13
|
const path = require("path");
|
package/lib/cve-match.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/cve-match.js — collect resolved deps from the parsed POM tree and
|
|
3
3
|
* match them against a CVE index built by lib/cve-download.js.
|
|
4
|
+
*
|
|
5
|
+
* @author: N.BRAUN
|
|
6
|
+
* @email: pp9ping@gmail.com
|
|
4
7
|
*/
|
|
5
8
|
const { coord } = require("./core");
|
|
6
9
|
const { compareMavenVersions, isVersionAffected } = require("./maven-version");
|
|
@@ -12,11 +15,21 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOW
|
|
|
12
15
|
function resolveDepVersion(rawVersion, allProps) {
|
|
13
16
|
if (!rawVersion) return null;
|
|
14
17
|
// Resolve ${prop} references using the merged property map for this POM.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Maven interpolates recursively (e.g. ${project.version} → ${revision} →
|
|
19
|
+
// "1.0"), so we re-substitute until the string stops changing. Bounded +
|
|
20
|
+
// stop-on-no-progress guards against property cycles. Unknown refs are left
|
|
21
|
+
// verbatim, so they still surface as "unresolved-versions" in chapter 0.
|
|
22
|
+
let v = String(rawVersion);
|
|
23
|
+
for (let i = 0; i < 10 && v.includes("${"); i++) {
|
|
24
|
+
const next = v.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
|
|
25
|
+
const val = allProps?.[k];
|
|
26
|
+
if (Array.isArray(val)) return val[0];
|
|
27
|
+
return val != null ? val : m;
|
|
28
|
+
});
|
|
29
|
+
if (next === v) break; // no progress → remaining refs are unresolvable
|
|
30
|
+
v = next;
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
/**
|
|
@@ -109,12 +122,17 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
109
122
|
// project pins a transitive's version via <dependencyManagement>.
|
|
110
123
|
const rootDepMgmt = new Map();
|
|
111
124
|
for (const dep of resolvedDeps.values()) {
|
|
125
|
+
// Embedded-binary coords don't participate in Maven Central resolution (a
|
|
126
|
+
// fat-jar already ships its deps, discovered by recursion) and must not act as
|
|
127
|
+
// a depMgmt override for the declared tree.
|
|
128
|
+
if (dep.provenance === "embedded") continue;
|
|
112
129
|
if (dep.version && !/\$\{/.test(dep.version)) {
|
|
113
130
|
rootDepMgmt.set(`${dep.groupId}:${dep.artifactId}`, { version: dep.version });
|
|
114
131
|
}
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
const directs = [...resolvedDeps.values()]
|
|
135
|
+
.filter(d => d.provenance !== "embedded")
|
|
118
136
|
.filter(d => d.version && !/\$\{/.test(d.version))
|
|
119
137
|
.filter(d => d.scope !== "test" || opts.includeTestDeps)
|
|
120
138
|
.filter(d => d.scope !== "parent");
|
|
@@ -228,7 +246,10 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
|
228
246
|
const seen = new Set();
|
|
229
247
|
const deduped = [];
|
|
230
248
|
for (const m of all) {
|
|
231
|
-
|
|
249
|
+
// Key by coordKey (not g:a): identical to g:a for declared Maven deps, but
|
|
250
|
+
// keeps embedded-binary findings (coordKey "embedded:<path>") distinct from a
|
|
251
|
+
// declared dep with the same g:a:v so the embedded chapter doesn't lose them.
|
|
252
|
+
const k = `${m.dep.coordKey}:${m.dep.version}|${m.cve.id}`;
|
|
232
253
|
if (seen.has(k)) continue;
|
|
233
254
|
seen.add(k);
|
|
234
255
|
deduped.push(m);
|
package/lib/cve-report.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Word opens HTML saved as .doc natively — we use the same HTML body
|
|
6
6
|
* wrapped in Word XML namespace meta tags.
|
|
7
|
+
*
|
|
8
|
+
* @author: N.BRAUN
|
|
9
|
+
* @email: pp9ping@gmail.com
|
|
7
10
|
*/
|
|
8
11
|
const fs = require("fs");
|
|
9
12
|
const path = require("path");
|
|
10
13
|
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
14
|
+
const { computePriority, sortByPriority } = require("./priority");
|
|
11
15
|
|
|
12
16
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
13
17
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
@@ -34,13 +38,14 @@ function esc(s) {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
function computeStats(cveMatches) {
|
|
37
|
-
const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0 };
|
|
41
|
+
const buckets = { critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0, transitive: 0, direct: 0, kev: 0 };
|
|
38
42
|
for (const m of cveMatches || []) {
|
|
39
43
|
const sev = (m.cve.severity || "UNKNOWN").toLowerCase();
|
|
40
44
|
if (buckets[sev] != null) buckets[sev]++;
|
|
41
45
|
else buckets.unknown++;
|
|
42
46
|
if (m.dep?.scope === "transitive") buckets.transitive++;
|
|
43
47
|
else buckets.direct++;
|
|
48
|
+
if (m.cve?.kev) buckets.kev++;
|
|
44
49
|
}
|
|
45
50
|
buckets.total = (cveMatches || []).length;
|
|
46
51
|
return buckets;
|
|
@@ -61,6 +66,26 @@ function badge(sev) {
|
|
|
61
66
|
return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${c.bg};color:${c.fg};font-weight:600;font-size:11px;letter-spacing:.5px">${esc(s)}</span>`;
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
const BAND_BADGE = {
|
|
70
|
+
exploited: { bg: "#7c0008", fg: "#fff", label: "EXPLOITED" },
|
|
71
|
+
critical: { bg: "#b91c1c", fg: "#fff", label: "CRITICAL" },
|
|
72
|
+
high: { bg: "#ea580c", fg: "#fff", label: "HIGH" },
|
|
73
|
+
medium: { bg: "#ca8a04", fg: "#fff", label: "MEDIUM" },
|
|
74
|
+
low: { bg: "#2563eb", fg: "#fff", label: "LOW" },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Priority cell: composite band badge (KEV-aware) + EPSS percentile + KEV chip.
|
|
78
|
+
function priorityCell(m) {
|
|
79
|
+
const p = m.cve?.priority || computePriority(m.cve || {});
|
|
80
|
+
const b = BAND_BADGE[p.band] || BAND_BADGE.low;
|
|
81
|
+
const bandBadge = `<span class="prio-band" style="display:inline-block;padding:2px 7px;border-radius:3px;background:${b.bg};color:${b.fg};font-weight:700;font-size:10px;letter-spacing:.4px">${esc(b.label)}</span>`;
|
|
82
|
+
const kev = p.kev ? `<div class="kev-chip" title="CISA Known Exploited Vulnerability${m.cve?.kevDueDate ? ` — remediate by ${esc(m.cve.kevDueDate)}` : ""}">🛑 KEV${m.cve?.kevRansomware ? " · ransomware" : ""}</div>` : "";
|
|
83
|
+
const epss = m.cve?.epssPercentile != null
|
|
84
|
+
? `<div class="epss-chip" title="EPSS score ${(m.cve.epssScore != null ? (m.cve.epssScore * 100).toFixed(1) : "?")}%">EPSS ${Math.round(m.cve.epssPercentile * 100)}%</div>`
|
|
85
|
+
: "";
|
|
86
|
+
return `${bandBadge}${kev}${epss}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
const CSS = `
|
|
65
90
|
* { box-sizing: border-box; }
|
|
66
91
|
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2937; margin: 24px; background: #f9fafb; }
|
|
@@ -95,6 +120,19 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
|
|
|
95
120
|
.exec-summary .exec-ver { color: #6b7280; }
|
|
96
121
|
.exec-summary .exec-fix { color: #065f46; font-weight: 600; }
|
|
97
122
|
.fp-intro { background: #f3f4f6; border-left: 3px solid #6b7280; padding: 10px 14px; margin: 8px 0 16px; font-size: 12px; color: #4b5563; line-height: 1.5; }
|
|
123
|
+
td.prio { white-space: normal; }
|
|
124
|
+
td.prio .prio-band { white-space: nowrap; }
|
|
125
|
+
td.prio .kev-chip { display: block; margin-top: 3px; font-size: 10px; font-weight: 700; color: #7c0008; white-space: nowrap; }
|
|
126
|
+
td.prio .epss-chip { display: block; margin-top: 2px; font-size: 10px; color: #6b7280; font-family: ui-monospace, monospace; white-space: nowrap; }
|
|
127
|
+
.summary .card.sev-kev { border-left: 4px solid #7c0008; background: #fff1f2; }
|
|
128
|
+
.summary .card.sev-kev .v { color: #7c0008; }
|
|
129
|
+
.lic-group { margin: 14px 0; }
|
|
130
|
+
.lic-group .lic-h { font-size: 14px; margin: 8px 0 4px; padding: 4px 8px; border-radius: 4px; }
|
|
131
|
+
.lic-group .lic-n { font-size: 11px; color: #6b7280; font-weight: 500; }
|
|
132
|
+
.lic-group.lic-strong .lic-h { background: #fee2e2; color: #7c0008; }
|
|
133
|
+
.lic-group.lic-weak .lic-h { background: #fef3c7; color: #92400e; }
|
|
134
|
+
.lic-group.lic-unknown .lic-h { background: #e5e7eb; color: #374151; }
|
|
135
|
+
.lic-group.lic-ok .lic-h { background: #dcfce7; color: #065f46; }
|
|
98
136
|
td.cwe { font-family: ui-monospace, monospace; font-size: 11px; color: #4b5563; }
|
|
99
137
|
td.cwe .cwe-link { color: #6366f1; text-decoration: none; }
|
|
100
138
|
td.cwe .cwe-link:hover { text-decoration: underline; }
|
|
@@ -479,6 +517,9 @@ function renderDetailPanel(m) {
|
|
|
479
517
|
const kv = [];
|
|
480
518
|
if (cve.cvssVector) kv.push(`<div><label>${esc(cve.cvssVersion || "CVSS")}</label><span class="cvss-vector">${esc(cve.cvssVector)}</span></div>`);
|
|
481
519
|
if (cve.score != null) kv.push(`<div><label>Base score</label><span>${esc(cve.score.toFixed ? cve.score.toFixed(1) : cve.score)} (${esc(cve.severity || "?")})</span></div>`);
|
|
520
|
+
if (cve.priority) kv.push(`<div><label>Priority</label><span>${esc((cve.priority.band || "").toUpperCase())} · ${esc(cve.priority.score)}/100</span></div>`);
|
|
521
|
+
if (cve.epssPercentile != null) kv.push(`<div><label>EPSS</label><span>${(cve.epssScore != null ? (cve.epssScore * 100).toFixed(1) + "% prob · " : "")}${Math.round(cve.epssPercentile * 100)}th pct</span></div>`);
|
|
522
|
+
if (cve.kev) kv.push(`<div><label>CISA KEV</label><span>Known exploited${cve.kevDateAdded ? ` · added ${esc(cve.kevDateAdded)}` : ""}${cve.kevDueDate ? ` · due ${esc(cve.kevDueDate)}` : ""}${cve.kevRansomware ? " · ransomware" : ""}</span></div>`);
|
|
482
523
|
if (cve.fixVersion) kv.push(`<div><label>Fix version</label><span>${esc(cve.fixVersion)}</span></div>`);
|
|
483
524
|
if (cve.published) kv.push(`<div><label>Published</label><span>${esc(cve.published.slice(0, 10))}</span></div>`);
|
|
484
525
|
if (cve.modified) kv.push(`<div><label>Modified</label><span>${esc(cve.modified.slice(0, 10))}</span></div>`);
|
|
@@ -585,7 +626,7 @@ function renderCveRow(m, opts = {}) {
|
|
|
585
626
|
const source = sourceParts.map(s => `<span class="source ${esc(s)}">${esc(s)}</span>`).join("");
|
|
586
627
|
const conf = m.confidence ? `<div class="confidence">${esc(m.confidence)}</div>` : "";
|
|
587
628
|
const actionCell = withAction ? `<td class="action">${renderActionCell(m)}</td>` : "";
|
|
588
|
-
const baseCols =
|
|
629
|
+
const baseCols = 9; // Priority, Sev, CVE, Dep, CWE, Published, Description, Fix, Source
|
|
589
630
|
const colspan = withAction ? baseCols + 1 : baseCols;
|
|
590
631
|
const pub = formatPubDate(m.cve.published);
|
|
591
632
|
const cwes = formatCwes(m.cve.cwes);
|
|
@@ -593,6 +634,7 @@ function renderCveRow(m, opts = {}) {
|
|
|
593
634
|
// tr.detail-row. JS toggle is at the bottom of the document; links inside
|
|
594
635
|
// cells stay clickable (the handler ignores anchor/button clicks).
|
|
595
636
|
return `<tr class="cve-row${m.cpeFiltered ? " cpe-fp" : ""}">
|
|
637
|
+
<td class="prio">${priorityCell(m)}</td>
|
|
596
638
|
<td>${badge(m.cve.severity)}${m.cve.score != null ? `<div class="confidence">${esc(m.cve.score.toFixed(1))}</div>` : ""}</td>
|
|
597
639
|
<td class="cve"><a href="${link}" target="_blank">${esc(m.cve.id)}</a>${conf}${m.cpeFiltered ? `<div class="cpe-tag">CPE-filtered</div>` : ""}</td>
|
|
598
640
|
${depCell(m.dep)}
|
|
@@ -629,10 +671,11 @@ function renderActionCell(m) {
|
|
|
629
671
|
function renderCveTable(cveMatches, opts = {}) {
|
|
630
672
|
if (!cveMatches?.length) return `<div class="empty">No CVEs matched.</div>`;
|
|
631
673
|
const { withAction } = opts;
|
|
632
|
-
|
|
674
|
+
// Highest composite priority (KEV → EPSS-weighted CVSS) first.
|
|
675
|
+
const rows = sortByPriority(cveMatches).map(m => renderCveRow(m, opts)).join("\n");
|
|
633
676
|
const actionHeader = withAction ? `<th>Recommended action</th>` : "";
|
|
634
677
|
return `<table>
|
|
635
|
-
<thead><tr><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
|
|
678
|
+
<thead><tr><th>Priority</th><th>Severity</th><th>CVE ID</th><th>Dependency</th><th>CWE</th><th>Published</th><th>Description</th><th>Fix Version</th><th>Source</th>${actionHeader}</tr></thead>
|
|
636
679
|
<tbody>${rows}</tbody>
|
|
637
680
|
</table>`;
|
|
638
681
|
}
|
|
@@ -966,6 +1009,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
|
|
|
966
1009
|
{ v: stats.high, l: "High", cls: "sev-high" },
|
|
967
1010
|
{ v: stats.medium, l: "Medium", cls: "sev-medium" },
|
|
968
1011
|
{ v: stats.low, l: "Low", cls: "sev-low" },
|
|
1012
|
+
...(stats.kev ? [{ v: stats.kev, l: "KEV exploited", cls: "sev-kev" }] : []),
|
|
969
1013
|
{ v: stats.total, l: "Total CVEs" },
|
|
970
1014
|
{ v: stats.direct, l: "in Direct" },
|
|
971
1015
|
{ v: stats.transitive, l: "in Transitive" },
|
|
@@ -973,6 +1017,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
|
|
|
973
1017
|
{ v: eol?.length || 0, l: "EOL" },
|
|
974
1018
|
{ v: obs?.length || 0, l: "Obsolete" },
|
|
975
1019
|
{ v: out?.length || 0, l: "Outdated" },
|
|
1020
|
+
...(extra.licenseFlagged ? [{ v: extra.licenseFlagged, l: "Licenses to review", cls: "sev-medium" }] : []),
|
|
976
1021
|
{ v: extra.warnings?.length || 0, l: "Scan alerts", cls: "sev-warn" },
|
|
977
1022
|
];
|
|
978
1023
|
return `<div class="summary">${cards.map(c => `<div class="card${c.cls ? " " + c.cls : ""}"><div class="v">${c.v}</div><div class="l">${esc(c.l)}</div></div>`).join("")}</div>`;
|
|
@@ -1278,11 +1323,49 @@ function renderWarningItems(items, itemLimit) {
|
|
|
1278
1323
|
return `<ul class="warn-items">${lis}${overflow}</ul>`;
|
|
1279
1324
|
}
|
|
1280
1325
|
|
|
1281
|
-
|
|
1326
|
+
const LICENSE_CAT_META = {
|
|
1327
|
+
"network-copyleft": { label: "Network copyleft (AGPL/SSPL)", cls: "lic-strong" },
|
|
1328
|
+
"strong-copyleft": { label: "Strong copyleft (GPL)", cls: "lic-strong" },
|
|
1329
|
+
"proprietary": { label: "Proprietary / commercial", cls: "lic-strong" },
|
|
1330
|
+
"weak-copyleft": { label: "Weak copyleft (LGPL/MPL/EPL)", cls: "lic-weak" },
|
|
1331
|
+
"unknown": { label: "Unknown / unrecognized", cls: "lic-unknown" },
|
|
1332
|
+
"permissive": { label: "Permissive (MIT/Apache/BSD)", cls: "lic-ok" },
|
|
1333
|
+
};
|
|
1334
|
+
const LICENSE_CAT_ORDER = ["network-copyleft", "strong-copyleft", "proprietary", "weak-copyleft", "unknown", "permissive"];
|
|
1335
|
+
|
|
1336
|
+
function renderLicenseChapter(licenseResults) {
|
|
1337
|
+
if (!licenseResults || !licenseResults.assessed?.length) {
|
|
1338
|
+
return `<div class="empty">No license data resolved (offline, or registries returned none).</div>`;
|
|
1339
|
+
}
|
|
1340
|
+
const { byCategory } = licenseResults;
|
|
1341
|
+
const blocks = LICENSE_CAT_ORDER.filter(cat => byCategory[cat]?.length).map(cat => {
|
|
1342
|
+
const meta = LICENSE_CAT_META[cat] || { label: cat, cls: "lic-unknown" };
|
|
1343
|
+
const rows = byCategory[cat].slice().sort((a, b) => depDisplayName(a.dep).localeCompare(depDisplayName(b.dep))).map(e => {
|
|
1344
|
+
const lics = e.ids.concat(e.raw);
|
|
1345
|
+
return `<tr>
|
|
1346
|
+
<td>${depCell(e.dep)}</td>
|
|
1347
|
+
<td>${esc(lics.join(", ") || "—")}</td>
|
|
1348
|
+
<td>${esc(e.source || "—")}</td>
|
|
1349
|
+
</tr>`;
|
|
1350
|
+
}).join("");
|
|
1351
|
+
return `<div class="lic-group ${meta.cls}">
|
|
1352
|
+
<h3 class="lic-h">${esc(meta.label)} <span class="lic-n">${byCategory[cat].length}</span></h3>
|
|
1353
|
+
<table><thead><tr><th>Dependency</th><th>License(s)</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table>
|
|
1354
|
+
</div>`;
|
|
1355
|
+
}).join("");
|
|
1356
|
+
const flaggedN = licenseResults.flagged?.length || 0;
|
|
1357
|
+
const intro = flaggedN
|
|
1358
|
+
? `<div class="fp-intro">${flaggedN} dependency(ies) carry a copyleft, proprietary, or unrecognized license that may impose redistribution obligations — review against your distribution model. Permissive licenses are listed for completeness.</div>`
|
|
1359
|
+
: `<div class="fp-intro">All resolved licenses are permissive. Listed for completeness.</div>`;
|
|
1360
|
+
return intro + blocks;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1282
1364
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1283
1365
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1284
|
-
cveMatches
|
|
1285
|
-
devCveMatches
|
|
1366
|
+
cveMatches = mergeMatches(cveMatches || []);
|
|
1367
|
+
devCveMatches = mergeMatches(devCveMatches || []);
|
|
1368
|
+
embeddedMatches = mergeMatches(embeddedMatches || []);
|
|
1286
1369
|
retireMatches = retireMatches || [];
|
|
1287
1370
|
attachRootUpdates(cveMatches, outdatedResults, resolvedDeps);
|
|
1288
1371
|
attachRootUpdates(devCveMatches, outdatedResults, resolvedDeps);
|
|
@@ -1294,9 +1377,12 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1294
1377
|
const prodMatchesFP = cveMatches.filter(m => m.cpeFiltered);
|
|
1295
1378
|
const devMatchesActive = devCveMatches.filter(m => !m.cpeFiltered);
|
|
1296
1379
|
const devMatchesFP = devCveMatches.filter(m => m.cpeFiltered);
|
|
1380
|
+
const embMatchesActive = embeddedMatches.filter(m => !m.cpeFiltered);
|
|
1381
|
+
const embMatchesFP = embeddedMatches.filter(m => m.cpeFiltered);
|
|
1297
1382
|
|
|
1298
1383
|
const prodStats = computeStats(prodMatchesActive);
|
|
1299
1384
|
const devStats = computeStats(devMatchesActive);
|
|
1385
|
+
const embStats = computeStats(embMatchesActive);
|
|
1300
1386
|
const retireStats = computeStats(retireMatches);
|
|
1301
1387
|
|
|
1302
1388
|
// Recos are built from prod matches only (dev/test issues aren't a release blocker).
|
|
@@ -1305,7 +1391,11 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1305
1391
|
const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
|
|
1306
1392
|
const vendoredContent = renderRetireTable(retireMatches);
|
|
1307
1393
|
const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
|
|
1308
|
-
const
|
|
1394
|
+
const licenseContent = renderLicenseChapter(licenseResults);
|
|
1395
|
+
const licenseTotal = licenseResults?.assessed?.length || 0;
|
|
1396
|
+
const licenseFlagged = licenseResults?.flagged?.length || 0;
|
|
1397
|
+
const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
|
|
1398
|
+
const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
|
|
1309
1399
|
|
|
1310
1400
|
const toolbar = `<div class="toolbar">
|
|
1311
1401
|
<button class="btn" type="button" data-action="expand-all">⊕ Expand all</button>
|
|
@@ -1334,7 +1424,10 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1334
1424
|
eolTotal: eolResults?.length || 0,
|
|
1335
1425
|
obsoleteTotal: obsoleteResults?.length || 0,
|
|
1336
1426
|
outdatedTotal: outdatedResults?.length || 0,
|
|
1337
|
-
|
|
1427
|
+
licenseTotal,
|
|
1428
|
+
licenseFlagged,
|
|
1429
|
+
embeddedTotal: embStats.total,
|
|
1430
|
+
fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
|
|
1338
1431
|
});
|
|
1339
1432
|
|
|
1340
1433
|
const body = `
|
|
@@ -1347,19 +1440,21 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1347
1440
|
</div>
|
|
1348
1441
|
</header>
|
|
1349
1442
|
${exec}
|
|
1350
|
-
${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length })}
|
|
1443
|
+
${summaryCards(prodStats, eolResults, obsoleteResults, outdatedResults, { warnings, vendored: retireMatches.length, licenseFlagged })}
|
|
1351
1444
|
${toc}
|
|
1352
1445
|
${toolbar}
|
|
1353
1446
|
|
|
1354
1447
|
${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
|
|
1355
1448
|
${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
|
|
1449
|
+
${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
|
|
1356
1450
|
${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
|
|
1357
1451
|
${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
|
|
1358
1452
|
${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
|
|
1359
1453
|
${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
|
|
1360
1454
|
${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
|
|
1361
|
-
${majorSection(`7.
|
|
1362
|
-
${
|
|
1455
|
+
${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
|
|
1456
|
+
${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
|
|
1457
|
+
${(prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length) ? majorSection(`9. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length})`, fpContent, { open: false, id: "ch9" }) : ""}
|
|
1363
1458
|
`;
|
|
1364
1459
|
return wrapTables ? wrapTablesWithCopyButtons(body) : body;
|
|
1365
1460
|
}
|
|
@@ -1396,20 +1491,41 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
|
|
|
1396
1491
|
return out;
|
|
1397
1492
|
}
|
|
1398
1493
|
|
|
1399
|
-
function renderToc({ hasWarnings, prodTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, fpTotal }) {
|
|
1494
|
+
function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1400
1495
|
const entries = [];
|
|
1401
1496
|
if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
|
|
1402
1497
|
entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
|
|
1498
|
+
if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
|
|
1403
1499
|
entries.push({ id: "ch2", label: `2. Vendored JS (${retireTotal})` });
|
|
1404
1500
|
entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
|
|
1405
1501
|
entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
|
|
1406
1502
|
entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
|
|
1407
1503
|
entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
|
|
1408
|
-
entries.push({ id: "ch7", label: `7.
|
|
1409
|
-
|
|
1504
|
+
entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
|
|
1505
|
+
entries.push({ id: "ch8", label: `8. Fix Recos` });
|
|
1506
|
+
if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
|
|
1410
1507
|
return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
|
|
1411
1508
|
}
|
|
1412
1509
|
|
|
1510
|
+
// Embedded-binary CVE chapter: group findings by their top-level archive (the
|
|
1511
|
+
// part of the manifestPath before the first "!/"), so a fat-jar lists all the
|
|
1512
|
+
// vulnerable libraries shaded inside it.
|
|
1513
|
+
function renderEmbeddedChapter(matches, srcRoot) {
|
|
1514
|
+
if (!matches.length) return `<div class="empty">No CVEs found in embedded binaries.</div>`;
|
|
1515
|
+
const intro = `<div class="fp-intro">Maven coordinates discovered inside committed <code>.jar</code>/<code>.war</code>/<code>.ear</code> binaries (vendored libs, fat-jars) — not declared in any <code>pom.xml</code>. Patch by rebuilding/replacing the containing archive.</div>`;
|
|
1516
|
+
const byArchive = new Map();
|
|
1517
|
+
for (const m of matches) {
|
|
1518
|
+
const mp = (m.dep?.manifestPaths || [])[0] || "(unknown archive)";
|
|
1519
|
+
const top = String(mp).split("!/")[0];
|
|
1520
|
+
if (!byArchive.has(top)) byArchive.set(top, []);
|
|
1521
|
+
byArchive.get(top).push(m);
|
|
1522
|
+
}
|
|
1523
|
+
const blocks = [...byArchive.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([archive, list]) =>
|
|
1524
|
+
minorSection(`📦 <code class="path">${esc(archive)}</code> (${list.length})`, renderCveTable(list), { open: list.length <= 30 })
|
|
1525
|
+
).join("\n");
|
|
1526
|
+
return intro + blocks;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1413
1529
|
function renderFalsePositives(matches) {
|
|
1414
1530
|
if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
|
|
1415
1531
|
const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
|
|
@@ -1721,10 +1837,10 @@ const WORD_SECTION_PR = `<!--[if gte mso 9]>
|
|
|
1721
1837
|
// so Word can lay out the columns deterministically instead of inflating wide
|
|
1722
1838
|
// columns based on natural content width.
|
|
1723
1839
|
const TABLE_COLGROUPS = {
|
|
1724
|
-
//
|
|
1725
|
-
cve: { match: /<thead><tr><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
|
|
1726
|
-
|
|
1727
|
-
|
|
1840
|
+
// 9-col CVE table (header starts with <th>Priority</th><th>Severity</th>…)
|
|
1841
|
+
cve: { match: /<thead><tr><th>Priority<\/th><th>Severity<\/th><th>CVE ID<\/th><th>Dependency<\/th><th>CWE<\/th><th>Published<\/th><th>Description<\/th><th>Fix Version<\/th><th>Source<\/th>(<th>Recommended action<\/th>)?<\/tr><\/thead>/,
|
|
1842
|
+
widths9: [9, 7, 9, 14, 9, 6, 22, 10, 14],
|
|
1843
|
+
widths10: [8, 6, 8, 12, 8, 6, 18, 9, 13, 12] },
|
|
1728
1844
|
// 5-col EOL table
|
|
1729
1845
|
eol: { match: /<thead><tr><th>Product<\/th><th>Dependency<\/th><th>EOL date<\/th><th>Latest<\/th><th>Notes<\/th><\/tr><\/thead>/,
|
|
1730
1846
|
widths: [15, 25, 12, 12, 36] },
|
|
@@ -1748,7 +1864,7 @@ function colgroupHtml(widths) {
|
|
|
1748
1864
|
function injectColgroups(html) {
|
|
1749
1865
|
// CVE 8-col / 9-col (action variant)
|
|
1750
1866
|
html = html.replace(new RegExp(`(<table[^>]*>)\\s*(${TABLE_COLGROUPS.cve.match.source})`, "g"), (m, tableTag, thead) => {
|
|
1751
|
-
const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.
|
|
1867
|
+
const widths = thead.includes("Recommended action") ? TABLE_COLGROUPS.cve.widths10 : TABLE_COLGROUPS.cve.widths9;
|
|
1752
1868
|
return `${tableTag}${colgroupHtml(widths)}${thead}`;
|
|
1753
1869
|
});
|
|
1754
1870
|
for (const key of ["eol", "obsolete", "outdated", "retire"]) {
|
|
@@ -1790,14 +1906,27 @@ ${WORD_SECTION_PR}
|
|
|
1790
1906
|
</html>`;
|
|
1791
1907
|
}
|
|
1792
1908
|
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1909
|
+
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
1910
|
+
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
1911
|
+
// Back-compat: `outputDir` still writes both under the default file names.
|
|
1912
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
1913
|
+
if (outputDir) {
|
|
1914
|
+
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
1915
|
+
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
1916
|
+
}
|
|
1917
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
1918
|
+
const written = { htmlPath: null, docPath: null };
|
|
1919
|
+
if (htmlPath) {
|
|
1920
|
+
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
|
1921
|
+
await fs.promises.writeFile(htmlPath, generateHtmlReport(payload));
|
|
1922
|
+
written.htmlPath = htmlPath;
|
|
1923
|
+
}
|
|
1924
|
+
if (docPath) {
|
|
1925
|
+
await fs.promises.mkdir(path.dirname(path.resolve(docPath)), { recursive: true });
|
|
1926
|
+
await fs.promises.writeFile(docPath, generateWordReport(payload));
|
|
1927
|
+
written.docPath = docPath;
|
|
1928
|
+
}
|
|
1929
|
+
return written;
|
|
1801
1930
|
}
|
|
1802
1931
|
|
|
1803
1932
|
module.exports = {
|
package/lib/dep-record.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
|
|
6
6
|
* sont conservés comme alias rétro-compat le temps de migrer tous les
|
|
7
7
|
* consommateurs vers `namespace`/`name`/`manifestPaths`.
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
// NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
|
|
@@ -32,9 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
function makeDepRecord(input) {
|
|
35
|
-
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
|
|
38
|
+
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
|
|
36
39
|
const concrete = version && !/\$\{/.test(version) ? version : null;
|
|
37
40
|
const manifestPaths = manifestPath ? [manifestPath] : [];
|
|
41
|
+
// Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear, not declared in a
|
|
42
|
+
// manifest) must not share the Map key of a declared dep with the same g:a — else
|
|
43
|
+
// they'd merge and the "embedded binaries" report chapter would lose them. Key
|
|
44
|
+
// them by their unique physical location instead. provenance stays on the record
|
|
45
|
+
// so the report/exports can label & group them; "both" is set later if a merge
|
|
46
|
+
// across provenances ever happens.
|
|
47
|
+
const coordKey = provenance === "embedded" && manifestPath
|
|
48
|
+
? `embedded:${manifestPath}`
|
|
49
|
+
: coordKeyFor(ecosystem, namespace, name);
|
|
38
50
|
return {
|
|
39
51
|
ecosystem,
|
|
40
52
|
ecosystemType: ecosystemType || ecosystem,
|
|
@@ -42,7 +54,8 @@ function makeDepRecord(input) {
|
|
|
42
54
|
name,
|
|
43
55
|
version: version || null,
|
|
44
56
|
versions: concrete ? [concrete] : [],
|
|
45
|
-
coordKey
|
|
57
|
+
coordKey,
|
|
58
|
+
provenance,
|
|
46
59
|
scope,
|
|
47
60
|
isDev: !!isDev,
|
|
48
61
|
manifestPaths,
|