fad-checker 2.2.3 → 2.3.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/README.md +37 -333
- package/fad-checker.js +59 -22
- package/lib/charts.js +293 -0
- package/lib/codecs/composer.codec.js +4 -1
- package/lib/codecs/go.codec.js +4 -1
- package/lib/codecs/gradle/catalog.js +91 -0
- package/lib/codecs/gradle/parse.js +211 -0
- package/lib/codecs/gradle.codec.js +155 -0
- package/lib/codecs/index.js +3 -2
- package/lib/codecs/maven.codec.js +1 -1
- package/lib/codecs/npm/collect.js +3 -0
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget.codec.js +8 -2
- package/lib/codecs/pypi.codec.js +37 -14
- package/lib/codecs/recipes.js +18 -1
- package/lib/codecs/ruby.codec.js +3 -1
- package/lib/cpe.js +39 -2
- package/lib/cve-match.js +20 -1
- package/lib/cve-report.js +300 -86
- package/lib/deps-descriptor.js +2 -2
- package/lib/osv-db.js +1 -1
- package/lib/retire.js +1 -1
- package/package.json +2 -3
- package/fad-checker-report/cve-report.doc +0 -10987
- package/fad-checker-report/cve-report.html +0 -11126
package/fad-checker.js
CHANGED
|
@@ -229,7 +229,7 @@ program
|
|
|
229
229
|
.option("--export-cache <file>", "tar.gz/zip the ~/.fad-checker/ caches to <file> (excludes config.json by default)")
|
|
230
230
|
.option("--import-cache <file>", "restore ~/.fad-checker/ from a previously exported archive (existing dir is moved to .bak unless --force)")
|
|
231
231
|
.option("--include-config", "with --export-cache: also bundle config.json (contains the NVD API key)")
|
|
232
|
-
.option("--export-anonymized <file>", "offline: write an anonymized dependency descriptor (public coordinates only, no paths/URLs) for
|
|
232
|
+
.option("--export-anonymized <file>", "offline: write an anonymized dependency descriptor (public coordinates only, no paths/URLs) for air-gapped audits, then exit")
|
|
233
233
|
.option("--import-anonymized <file>", "online: scan an anonymized descriptor (no --src) to warm the caches; pair with --export-cache for offline reporting")
|
|
234
234
|
.option("--force", "with --import-cache: replace ~/.fad-checker/ without backup")
|
|
235
235
|
.option("--report-output <dir>", "report output directory", "./fad-checker-report")
|
|
@@ -246,6 +246,7 @@ program
|
|
|
246
246
|
.option("--transitive-depth <n>", "max transitive depth", "6")
|
|
247
247
|
.option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi,go,ruby (default: auto = detected)", "auto")
|
|
248
248
|
.option("--no-maven", "skip the Maven codec")
|
|
249
|
+
.option("--no-gradle", "skip the Gradle codec")
|
|
249
250
|
.option("--no-npm", "skip the npm codec")
|
|
250
251
|
.option("--no-yarn", "skip the Yarn codec")
|
|
251
252
|
.option("--no-nuget", "skip the NuGet (C#/.NET) codec")
|
|
@@ -451,10 +452,10 @@ async function timedPhase(label, fn) {
|
|
|
451
452
|
].filter(Boolean))];
|
|
452
453
|
const defaultExcludes = options.defaultExcludes !== false;
|
|
453
454
|
const walkOpts = { excludePath, defaultExcludes };
|
|
454
|
-
//
|
|
455
|
+
// Anonymized export (phase 1, --export-anonymized) is a purely local operation — parse the tree,
|
|
455
456
|
// emit public coordinates, exit. Force offline so no source ever touches the network.
|
|
456
457
|
if (options.exportAnonymized) options.offline = true;
|
|
457
|
-
const runMode = options.exportAnonymized ? "offline (
|
|
458
|
+
const runMode = options.exportAnonymized ? "offline (anonymized export · phase 1)"
|
|
458
459
|
: options.importAnonymized ? "import descriptor"
|
|
459
460
|
: (options.offline ? "offline" : "online");
|
|
460
461
|
if (options.src) ui.kv("source", chalk.white(options.src));
|
|
@@ -467,7 +468,7 @@ async function timedPhase(label, fn) {
|
|
|
467
468
|
|
|
468
469
|
let wrotePom = 0;
|
|
469
470
|
|
|
470
|
-
// ---
|
|
471
|
+
// --- Anonymized phase 2: import a descriptor instead of collecting ---
|
|
471
472
|
// Scans the descriptor's public coordinates online to WARM the coordinate-keyed
|
|
472
473
|
// caches (OSV/NVD/CVE/registry/EOL) + retire signatures. Pair with --export-cache.
|
|
473
474
|
if (options.importAnonymized) {
|
|
@@ -488,6 +489,12 @@ async function timedPhase(label, fn) {
|
|
|
488
489
|
const { warmRetireSignatures } = require("./lib/retire");
|
|
489
490
|
await warmRetireSignatures({ verbose });
|
|
490
491
|
}
|
|
492
|
+
// --import-anonymized is a cache-WARMING step (pair with --export-cache), not a
|
|
493
|
+
// reporting one: the path-bearing report is produced later, offline, from the warmed
|
|
494
|
+
// cache against the real source tree. So suppress the default HTML+doc output here
|
|
495
|
+
// (still honor an explicit --report-<type> if a user really wants a path-free one).
|
|
496
|
+
const anyReportRequested = [options.reportHtml, options.reportDoc, options.reportSbom, options.reportCsaf, options.reportJson, options.reportSarif].some(v => v !== undefined);
|
|
497
|
+
if (!anyReportRequested) options.report = false;
|
|
491
498
|
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
|
|
492
499
|
return;
|
|
493
500
|
}
|
|
@@ -503,11 +510,12 @@ async function timedPhase(label, fn) {
|
|
|
503
510
|
// project), and detectCodecs' manifest-glob matcher misses versioned sonames
|
|
504
511
|
// (libz.so.1). Always make it a candidate in auto mode; --no-binaries removes it.
|
|
505
512
|
if (eco === "auto" && !detected.includes("binary")) detected.push("binary");
|
|
506
|
-
const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
|
|
513
|
+
const noCodecs = ["maven", "gradle", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"].filter(id => options[id] === false);
|
|
507
514
|
// `--no-binaries` maps to options.binaries (plural) but the codec id is `binary`.
|
|
508
515
|
if (options.binaries === false) noCodecs.push("binary");
|
|
509
516
|
const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
|
|
510
517
|
const runMaven = activeIds.includes("maven");
|
|
518
|
+
const runGradle = activeIds.includes("gradle");
|
|
511
519
|
const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
|
|
512
520
|
|
|
513
521
|
// --- Collect deps from every active codec into one Map (coordKeys never collide) ---
|
|
@@ -516,7 +524,13 @@ async function timedPhase(label, fn) {
|
|
|
516
524
|
ui.section("Collection");
|
|
517
525
|
const resolved = new Map();
|
|
518
526
|
let mavenCtx = null;
|
|
527
|
+
let gradleCtx = null;
|
|
519
528
|
const collectWarnings = [];
|
|
529
|
+
// Every descriptor file each codec actually parsed (tagged with its ecosystemType =
|
|
530
|
+
// codec id), so the report's "Scanned dependency descriptors" appendix is a COMPLETE
|
|
531
|
+
// inventory — including files parsed that contributed no scannable dep (ranges-only /
|
|
532
|
+
// no lockfile), which would otherwise be invisible.
|
|
533
|
+
const parsedManifests = [];
|
|
520
534
|
for (const id of activeIds) {
|
|
521
535
|
if (id === "yarn") continue; // the npm codec already collects yarn.lock
|
|
522
536
|
const codec = getCodec(id);
|
|
@@ -529,7 +543,9 @@ async function timedPhase(label, fn) {
|
|
|
529
543
|
}
|
|
530
544
|
for (const [k, v] of res.deps) resolved.set(k, v);
|
|
531
545
|
if (res.warnings?.length) collectWarnings.push(...res.warnings);
|
|
546
|
+
for (const p of (res.parsedManifests || [])) parsedManifests.push({ path: p, ecosystemType: id });
|
|
532
547
|
if (id === "maven") mavenCtx = res._maven;
|
|
548
|
+
if (id === "gradle") gradleCtx = res._gradle;
|
|
533
549
|
}
|
|
534
550
|
|
|
535
551
|
// --- Collection summary ---
|
|
@@ -539,12 +555,15 @@ async function timedPhase(label, fn) {
|
|
|
539
555
|
for (const d of resolved.values()) {
|
|
540
556
|
if (d.provenance === "embedded") { embeddedCount++; continue; } // counted separately below
|
|
541
557
|
if (d.provenance === "binary") { binaryCount++; continue; } // committed native libs, no manifest
|
|
542
|
-
|
|
558
|
+
// Key by ecosystemType so Gradle (ecosystem "maven") gets its own line/label.
|
|
559
|
+
const ecoKey = d.ecosystemType || d.ecosystem;
|
|
560
|
+
ecoCount[ecoKey] = (ecoCount[ecoKey] || 0) + 1;
|
|
543
561
|
}
|
|
544
562
|
if (runMaven) ui.ok(`${chalk.bold("Maven".padEnd(8))} ${mavenCtx ? mavenCtx.pomFiles.length + " module(s) · " : ""}${ecoCount.maven || 0} direct dep(s)`);
|
|
563
|
+
if (runGradle) ui.ok(`${chalk.bold("Gradle".padEnd(8))} ${ecoCount.gradle || 0} direct dep(s)`);
|
|
545
564
|
if (runNpm) ui.ok(`${chalk.bold("npm/yarn".padEnd(8))} ${ecoCount.npm || 0} dep(s)`);
|
|
546
565
|
for (const [id, n] of Object.entries(ecoCount)) {
|
|
547
|
-
if (id === "maven" || id === "npm") continue;
|
|
566
|
+
if (id === "maven" || id === "gradle" || id === "npm") continue;
|
|
548
567
|
ui.ok(`${chalk.bold(((getCodec(id)?.label) || id).padEnd(8))} ${n} dep(s)`);
|
|
549
568
|
}
|
|
550
569
|
if (embeddedCount) ui.ok(`${chalk.bold("Embedded".padEnd(8))} ${embeddedCount} coord(s) in .jar/.war/.ear`);
|
|
@@ -556,7 +575,7 @@ async function timedPhase(label, fn) {
|
|
|
556
575
|
if (collectWarnings.length > 5) ui.info(chalk.dim(`…and ${collectWarnings.length - 5} more`));
|
|
557
576
|
}
|
|
558
577
|
|
|
559
|
-
// ---
|
|
578
|
+
// --- Anonymized phase 1: export a descriptor and exit (no network, no report) ---
|
|
560
579
|
if (options.exportAnonymized) {
|
|
561
580
|
const { serializeDeps } = require("./lib/deps-descriptor");
|
|
562
581
|
const pkgVersion = require("./package.json").version;
|
|
@@ -697,7 +716,7 @@ async function timedPhase(label, fn) {
|
|
|
697
716
|
// The scan always runs — it feeds the terminal summary, the file outputs and the
|
|
698
717
|
// CI gate. Which files get written is decided by the --report-* family inside
|
|
699
718
|
// (HTML + .doc by default; --no-report writes nothing).
|
|
700
|
-
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
|
|
719
|
+
await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests });
|
|
701
720
|
if (!readOnly) {
|
|
702
721
|
ui.section("Next step");
|
|
703
722
|
ui.info(`run Snyk on the cleaned tree:`);
|
|
@@ -706,7 +725,7 @@ async function timedPhase(label, fn) {
|
|
|
706
725
|
})();
|
|
707
726
|
|
|
708
727
|
async function runReportFlow(resolved, ecoFlags = {}) {
|
|
709
|
-
const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
|
|
728
|
+
const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [] } = ecoFlags;
|
|
710
729
|
const registriesFor = eco => regMap[eco] || [];
|
|
711
730
|
const { expandWithTransitives } = require("./lib/cve-match");
|
|
712
731
|
const { writeReports, computeStats } = require("./lib/cve-report");
|
|
@@ -736,11 +755,14 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
736
755
|
}
|
|
737
756
|
|
|
738
757
|
// Decide which update steps will run (from flags) so the [n/N] counter is accurate.
|
|
739
|
-
|
|
758
|
+
// Gradle deps are Maven coordinates (ecosystem "maven"), so the Maven CVE-index scanner
|
|
759
|
+
// and the Maven-Central transitive/BOM/outdated/EOL passes cover them — they're EXCLUDED
|
|
760
|
+
// from the per-codec registry loop below to avoid double-processing.
|
|
761
|
+
const cveScanner = (runMaven || runGradle) ? (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve") : null;
|
|
740
762
|
const cveIndexExists = fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH);
|
|
741
|
-
const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
|
|
763
|
+
const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "gradle" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
|
|
742
764
|
const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
|
|
743
|
-
const willTransitive = !!(options.transitive && runMaven);
|
|
765
|
+
const willTransitive = !!(options.transitive && (runMaven || runGradle));
|
|
744
766
|
// Per-module version mediation overlay: recover transitive versions the global
|
|
745
767
|
// transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
|
|
746
768
|
// the global pass runs, and needs the parsed store + per-module props.
|
|
@@ -751,11 +773,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
751
773
|
// never the network — same as transitive resolution).
|
|
752
774
|
const importBoms = (runMaven && mavenPropsByPom)
|
|
753
775
|
? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
|
|
754
|
-
|
|
776
|
+
// Gradle `platform(...)` BOMs are the same as Maven `<scope>import</scope>` BOMs — feed
|
|
777
|
+
// them through the same managed-version backfill (e.g. spring-boot-dependencies → all the
|
|
778
|
+
// versionless spring-boot-starter-* declared in build.gradle get their version).
|
|
779
|
+
const gradleBoms = (gradlePlatformBoms || [])
|
|
780
|
+
.map(b => ({ groupId: b.group, artifactId: b.name, version: b.version }))
|
|
781
|
+
.filter(b => b.groupId && b.artifactId && b.version);
|
|
782
|
+
const allBoms = importBoms.concat(gradleBoms);
|
|
783
|
+
const willBom = allBoms.length > 0;
|
|
755
784
|
const willOsv = !!options.osv;
|
|
756
785
|
// Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
|
|
757
786
|
// OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
|
|
758
|
-
const willOsvDb = !!options.osvDb && runMaven;
|
|
787
|
+
const willOsvDb = !!options.osvDb && (runMaven || runGradle);
|
|
759
788
|
const willOutdated = !!options.allLibs;
|
|
760
789
|
const willNvd = !!options.nvd;
|
|
761
790
|
const willEpss = !!options.epss;
|
|
@@ -772,9 +801,10 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
772
801
|
if (willBom) {
|
|
773
802
|
const st = progress.start("BOM version resolution (Maven Central)");
|
|
774
803
|
try {
|
|
775
|
-
const {
|
|
776
|
-
const
|
|
777
|
-
|
|
804
|
+
const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
|
|
805
|
+
const mgmt = await resolveBomManagedVersions(allBoms, { repos: mavenRepos, offline, verbose });
|
|
806
|
+
const filled = backfillVersions(resolved, mgmt);
|
|
807
|
+
st.done(`${filled} dep version(s) from ${allBoms.length} import BOM(s)`);
|
|
778
808
|
} catch (err) { st.fail(err.message); }
|
|
779
809
|
}
|
|
780
810
|
|
|
@@ -811,7 +841,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
811
841
|
// Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
|
|
812
842
|
// still without a concrete version (external BOM truly unreachable, or offline)
|
|
813
843
|
// are flagged, not the ones we just resolved from spring-boot-dependencies & co.
|
|
814
|
-
if (runMaven) {
|
|
844
|
+
if (runMaven || runGradle) {
|
|
815
845
|
const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
|
|
816
846
|
scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
|
|
817
847
|
}
|
|
@@ -903,6 +933,11 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
903
933
|
const k = `${r.dep.groupId}:${r.dep.artifactId}`;
|
|
904
934
|
return !eolKeys.has(k) && !obsKeys.has(k);
|
|
905
935
|
});
|
|
936
|
+
// Outdated is a maintenance signal for deps you actually declare and can bump. An
|
|
937
|
+
// INDIRECT (transitive) dep's "latest" isn't directly actionable — you'd bump the parent
|
|
938
|
+
// — so drop transitives from the Outdated chapter (EOL/obsolete keep them: security).
|
|
939
|
+
// scope==="transitive" is the marker set by the Maven/Gradle resolver and the npm parser.
|
|
940
|
+
outdatedResults = outdatedResults.filter(r => r.dep.scope !== "transitive");
|
|
906
941
|
|
|
907
942
|
// 4b. OSV.dev — Maven-native CVE+GHSA feed (huge recall win over raw CVEProject)
|
|
908
943
|
if (willOsv) {
|
|
@@ -1192,7 +1227,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1192
1227
|
let licenseResults = null;
|
|
1193
1228
|
if (willLicenses) {
|
|
1194
1229
|
try {
|
|
1195
|
-
if (runMaven) {
|
|
1230
|
+
if (runMaven || runGradle) {
|
|
1196
1231
|
const { collectMavenLicenses } = require("./lib/maven-license");
|
|
1197
1232
|
licenseFindings = licenseFindings.concat(collectMavenLicenses(resolved));
|
|
1198
1233
|
}
|
|
@@ -1265,7 +1300,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1265
1300
|
const { htmlPath, docPath } = await writeReports({
|
|
1266
1301
|
cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
|
|
1267
1302
|
eolResults, obsoleteResults, outdatedResults, licenseResults,
|
|
1268
|
-
resolvedDeps: resolved, projectInfo, warnings: reportWarnings,
|
|
1303
|
+
resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
|
|
1269
1304
|
htmlPath: out.html, docPath: out.doc,
|
|
1270
1305
|
});
|
|
1271
1306
|
if (htmlPath) wrote.push(["HTML report", htmlPath]);
|
|
@@ -1311,7 +1346,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1311
1346
|
ui.section("Output");
|
|
1312
1347
|
for (const [label, p] of wrote) ui.ok(`${label} → ${chalk.white(p)}`);
|
|
1313
1348
|
} else if (options.report === false) {
|
|
1314
|
-
ui.info(chalk.dim(
|
|
1349
|
+
ui.info(chalk.dim(options.importAnonymized
|
|
1350
|
+
? "import-anonymized: caches warmed, no report written (pair with --export-cache, then report offline from the real source tree)"
|
|
1351
|
+
: "--no-report: no files written (scan + gate only)"));
|
|
1315
1352
|
}
|
|
1316
1353
|
console.log();
|
|
1317
1354
|
|
package/lib/charts.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/charts.js — compact, dependency-free SVG charts for the HTML report's
|
|
3
|
+
* "Overview" row (rendered right under the summary totals).
|
|
4
|
+
*
|
|
5
|
+
* Why hand-rolled inline SVG (not <canvas>, not a chart lib, not pure-CSS):
|
|
6
|
+
* - self-contained: the report ships zero external assets, and an SVG is just
|
|
7
|
+
* markup — no runtime, no CDN. It even renders in the static .doc (no JS).
|
|
8
|
+
* - copy-to-Word: each chart rasterises SVG→<canvas>→PNG entirely in the
|
|
9
|
+
* browser (see the report's chart-copy script) and a PNG pastes into Word
|
|
10
|
+
* perfectly. A <div> has no native "→ PNG" API; an SVG does.
|
|
11
|
+
*
|
|
12
|
+
* Every visual attribute is INLINE on the SVG elements (fill/font-size/…), never
|
|
13
|
+
* a CSS class — a class would not survive the canvas rasterisation (the document
|
|
14
|
+
* stylesheet is not applied to the serialised SVG).
|
|
15
|
+
*
|
|
16
|
+
* Charts: (1) CWE of DIRECT vulns — donut, sliced by CWE, coloured by that CWE's
|
|
17
|
+
* worst severity, legend carries the human CWE title; (2) vulnerable transitive
|
|
18
|
+
* sub-deps per (root) dependency — readable horizontal bars stacked by severity,
|
|
19
|
+
* with rootless transitives reported as a note rather than a bogus "unknown
|
|
20
|
+
* root" bar; (3) reported CVE/elements — donut; (4) fix-priority bands — donut.
|
|
21
|
+
*
|
|
22
|
+
* Pure: aggregators take match arrays + counts → plain data; renderers turn that
|
|
23
|
+
* into SVG strings. No I/O, no network.
|
|
24
|
+
*
|
|
25
|
+
* @author: N.BRAUN
|
|
26
|
+
* @email: pp9ping@gmail.com
|
|
27
|
+
*/
|
|
28
|
+
const { computePriority } = require("./priority");
|
|
29
|
+
|
|
30
|
+
const CWE_NAMES = (() => {
|
|
31
|
+
try { const raw = { ...require("../data/cwe-names.json") }; delete raw._comment; return raw; }
|
|
32
|
+
catch { return {}; }
|
|
33
|
+
})();
|
|
34
|
+
const cweName = id => CWE_NAMES[String(id || "").toUpperCase()] || "";
|
|
35
|
+
|
|
36
|
+
function esc(s) {
|
|
37
|
+
if (s == null) return "";
|
|
38
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Severity series (stacked bars) — colours mirror the report's severity badges.
|
|
42
|
+
const SEV_SERIES = [
|
|
43
|
+
{ key: "critical", label: "Critical", color: "#7c0008" },
|
|
44
|
+
{ key: "high", label: "High", color: "#c92a2a" },
|
|
45
|
+
{ key: "medium", label: "Medium", color: "#f08c00" },
|
|
46
|
+
{ key: "low", label: "Low", color: "#3b82f6" },
|
|
47
|
+
{ key: "unknown", label: "Unknown", color: "#9ca3af" },
|
|
48
|
+
];
|
|
49
|
+
const SEV_COLOR = Object.fromEntries(SEV_SERIES.map(s => [s.key, s.color]));
|
|
50
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 };
|
|
51
|
+
const sevKey = m => {
|
|
52
|
+
const s = (m && m.cve && m.cve.severity || "").toLowerCase();
|
|
53
|
+
return s in SEV_RANK ? s : "unknown";
|
|
54
|
+
};
|
|
55
|
+
const emptySeg = () => ({ critical: 0, high: 0, medium: 0, low: 0, unknown: 0 });
|
|
56
|
+
const worstSev = seg => SEV_SERIES.map(s => s.key).find(k => seg[k] > 0) || "unknown";
|
|
57
|
+
const coordKeyOf = dep => dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : (dep.groupId || "") + ":"}${dep.artifactId}`;
|
|
58
|
+
|
|
59
|
+
// Keep the top-N rows; fold the remainder into a single "+K more" aggregate row
|
|
60
|
+
// (a chart must never silently drop categories).
|
|
61
|
+
function capRows(rows, topN, { stacked = false } = {}) {
|
|
62
|
+
if (rows.length <= topN) return rows;
|
|
63
|
+
const kept = rows.slice(0, topN);
|
|
64
|
+
const rest = rows.slice(topN);
|
|
65
|
+
if (stacked) {
|
|
66
|
+
const seg = emptySeg();
|
|
67
|
+
let total = 0;
|
|
68
|
+
for (const r of rest) { for (const k of Object.keys(seg)) seg[k] += r.segments[k] || 0; total += r.total; }
|
|
69
|
+
kept.push({ key: "__more__", label: `+${rest.length} more`, segments: seg, total, more: true });
|
|
70
|
+
} else {
|
|
71
|
+
const value = rest.reduce((a, r) => a + r.value, 0);
|
|
72
|
+
kept.push({ key: "__more__", label: `+${rest.length} more`, value, color: "#9ca3af", more: true });
|
|
73
|
+
}
|
|
74
|
+
return kept;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- aggregators
|
|
78
|
+
|
|
79
|
+
/** Chart 1 — CWE distribution of DIRECT production vulns, with per-severity counts. */
|
|
80
|
+
function cweByCriticality(prodMatches, { topN = 7 } = {}) {
|
|
81
|
+
const map = new Map();
|
|
82
|
+
for (const m of prodMatches || []) {
|
|
83
|
+
if (m.dep && m.dep.scope === "transitive") continue; // direct (declared) only
|
|
84
|
+
const cwes = Array.isArray(m.cve && m.cve.cwes) ? m.cve.cwes : [];
|
|
85
|
+
if (!cwes.length) continue; // a "by CWE" chart only counts categorised findings
|
|
86
|
+
const sev = sevKey(m);
|
|
87
|
+
for (const c of cwes) {
|
|
88
|
+
const id = String(c || "").toUpperCase();
|
|
89
|
+
if (!id) continue;
|
|
90
|
+
if (!map.has(id)) map.set(id, emptySeg());
|
|
91
|
+
map.get(id)[sev]++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const rows = [...map.entries()].map(([id, segments]) => ({
|
|
95
|
+
key: id,
|
|
96
|
+
label: id,
|
|
97
|
+
name: cweName(id),
|
|
98
|
+
segments,
|
|
99
|
+
total: SEV_SERIES.reduce((a, s) => a + segments[s.key], 0),
|
|
100
|
+
})).sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
|
|
101
|
+
return capRows(rows, topN, { stacked: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Readable label for a root coordKey: the artifact / package name (the part a
|
|
105
|
+
// human recognises), e.g. "org.spring.boot:spring-boot-starter-web" → "spring-boot-starter-web".
|
|
106
|
+
function readableDepLabel(coordKey) {
|
|
107
|
+
const s = String(coordKey || "");
|
|
108
|
+
const seg = s.split("/").pop(); // npm "@scope/name" path tail
|
|
109
|
+
const tail = seg.includes(":") ? seg.slice(seg.indexOf(":") + 1) : seg;
|
|
110
|
+
return tail || s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Chart 2 — sub-dep CVEs grouped by their ROOT (direct) dep, by severity.
|
|
114
|
+
* Counts CVEs (a sub-dep with 3 CVEs contributes 3), each in its own bucket. */
|
|
115
|
+
function vulnSubdepsByDep(prodMatches, { topN = 7 } = {}) {
|
|
116
|
+
const roots = new Map(); // rootKey -> segments
|
|
117
|
+
for (const m of prodMatches || []) {
|
|
118
|
+
if (!m.dep || m.dep.scope !== "transitive") continue;
|
|
119
|
+
const root = m.dep.via && m.dep.via[0];
|
|
120
|
+
if (!root) continue; // rootless → unattributedSubdeps()
|
|
121
|
+
if (!roots.has(root)) roots.set(root, emptySeg());
|
|
122
|
+
roots.get(root)[sevKey(m)]++; // one count per CVE, in its own severity
|
|
123
|
+
}
|
|
124
|
+
const rows = [...roots.entries()].map(([root, segments]) => ({
|
|
125
|
+
key: root,
|
|
126
|
+
label: readableDepLabel(root),
|
|
127
|
+
segments,
|
|
128
|
+
total: SEV_SERIES.reduce((a, s) => a + segments[s.key], 0),
|
|
129
|
+
})).filter(r => r.total > 0).sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
|
|
130
|
+
return capRows(rows, topN, { stacked: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Sub-dep CVEs on transitive deps with no resolved root (e.g. npm). */
|
|
134
|
+
function unattributedSubdeps(prodMatches) {
|
|
135
|
+
let n = 0;
|
|
136
|
+
for (const m of prodMatches || []) {
|
|
137
|
+
if (!m.dep || m.dep.scope !== "transitive") continue;
|
|
138
|
+
if (m.dep.via && m.dep.via[0]) continue;
|
|
139
|
+
n++;
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Chart 3 — where the risk lives: direct vs transitive production vulns, with
|
|
145
|
+
* each side's per-severity breakdown carried in the legend (e.g. "2C 1H 1M"). */
|
|
146
|
+
function directVsTransitive(prodMatches) {
|
|
147
|
+
const g = { direct: emptySeg(), transitive: emptySeg() };
|
|
148
|
+
for (const m of prodMatches || []) {
|
|
149
|
+
g[(m.dep && m.dep.scope === "transitive") ? "transitive" : "direct"][sevKey(m)]++;
|
|
150
|
+
}
|
|
151
|
+
const tot = seg => SEV_SERIES.reduce((a, s) => a + seg[s.key], 0);
|
|
152
|
+
const summary = seg => SEV_SERIES.filter(s => seg[s.key] > 0).map(s => `${seg[s.key]}${s.label[0]}`).join(" ");
|
|
153
|
+
const out = [];
|
|
154
|
+
if (tot(g.direct)) out.push({ key: "direct", label: "Direct", name: summary(g.direct), value: tot(g.direct), color: "#4338ca" });
|
|
155
|
+
if (tot(g.transitive)) out.push({ key: "transitive", label: "Transitive", name: summary(g.transitive), value: tot(g.transitive), color: "#d97706" });
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const FIX_BANDS = [
|
|
160
|
+
{ key: "exploited", label: "Exploited", color: "#7c0008" },
|
|
161
|
+
{ key: "critical", label: "Critical", color: "#b91c1c" },
|
|
162
|
+
{ key: "high", label: "High", color: "#ea580c" },
|
|
163
|
+
{ key: "medium", label: "Medium", color: "#ca8a04" },
|
|
164
|
+
{ key: "low", label: "Low", color: "#2563eb" },
|
|
165
|
+
];
|
|
166
|
+
/** Chart 4 — fix-priority distribution (composite KEV/EPSS-weighted bands). */
|
|
167
|
+
function fixPriority(prodMatches) {
|
|
168
|
+
const counts = { exploited: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
169
|
+
for (const m of prodMatches || []) {
|
|
170
|
+
const p = (m.cve && m.cve.priority) || computePriority(m.cve || {});
|
|
171
|
+
if (counts[p.band] != null) counts[p.band]++;
|
|
172
|
+
}
|
|
173
|
+
return FIX_BANDS.map(b => ({ key: b.key, label: b.label, value: counts[b.key], color: b.color })).filter(r => r.value > 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ------------------------------------------------------------------ renderers
|
|
177
|
+
|
|
178
|
+
const FONT = "font-family='-apple-system,Segoe UI,Roboto,sans-serif'";
|
|
179
|
+
|
|
180
|
+
const legendStr = s => s.name ? `${s.label} · ${s.name} · ${s.value}` : `${s.label} · ${s.value}`;
|
|
181
|
+
const LEGEND_CHARW = 4.15; // ~px per char at the 7.5px legend font
|
|
182
|
+
|
|
183
|
+
// The viewBox width needed to fit the FULL legend text (no ellipsis). The caller
|
|
184
|
+
// takes the MAX across all four charts and feeds it to every donut, so they share
|
|
185
|
+
// one viewBox width → uniform donut size when each card is the same 25% slot.
|
|
186
|
+
function legendBoxWidth(slices, note) {
|
|
187
|
+
const items = (slices || []).filter(s => s.value > 0);
|
|
188
|
+
return Math.ceil(Math.max(0, ...items.map(s => 13 + legendStr(s).length * LEGEND_CHARW), note ? note.length * 4 : 0)) + 8;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A donut + a legend underneath. slices: [{label, name?, value, color}].
|
|
192
|
+
// The legend carries the human title (so the principal CWEs read in plain text).
|
|
193
|
+
function donutChart({ slices, width = 250, note }) {
|
|
194
|
+
const items = (slices || []).filter(s => s.value > 0);
|
|
195
|
+
const total = items.reduce((a, s) => a + s.value, 0);
|
|
196
|
+
const legendTxt = legendStr;
|
|
197
|
+
const R = 47, cy = R + 4, cx = width / 2;
|
|
198
|
+
const parts = [];
|
|
199
|
+
if (items.length === 1) {
|
|
200
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="${items[0].color}"/>`);
|
|
201
|
+
} else {
|
|
202
|
+
let a0 = -Math.PI / 2;
|
|
203
|
+
for (const s of items) {
|
|
204
|
+
const a1 = a0 + (s.value / total) * 2 * Math.PI;
|
|
205
|
+
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
|
|
206
|
+
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
|
|
207
|
+
const large = (a1 - a0) > Math.PI ? 1 : 0;
|
|
208
|
+
const tip = s.name ? `${s.label} — ${s.name}: ${s.value}` : `${s.label}: ${s.value}`;
|
|
209
|
+
parts.push(`<path d="M${cx.toFixed(1)} ${cy.toFixed(1)} L${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${large} 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z" fill="${s.color}" stroke="#ffffff" stroke-width="1"><title>${esc(tip)}</title></path>`);
|
|
210
|
+
a0 = a1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${(R * 0.58).toFixed(1)}" fill="#ffffff"/>`);
|
|
214
|
+
parts.push(`<text x="${cx}" y="${cy + 5}" text-anchor="middle" ${FONT} font-size="17" font-weight="700" fill="#374151">${total}</text>`);
|
|
215
|
+
let ly = R * 2 + 23; // +10px breathing room donut → legend
|
|
216
|
+
for (const s of items) { // full legend text — no ellipsis
|
|
217
|
+
parts.push(`<rect x="0" y="${ly - 7}" width="8" height="8" rx="1" fill="${s.color}"/>`);
|
|
218
|
+
parts.push(`<text x="11" y="${ly}" ${FONT} font-size="7.5" fill="#4b5563">${esc(legendTxt(s))}</text>`);
|
|
219
|
+
ly += 11;
|
|
220
|
+
}
|
|
221
|
+
if (note) { parts.push(`<text x="0" y="${ly}" ${FONT} font-size="7" font-style="italic" fill="#9ca3af">${esc(note)}</text>`); ly += 11; }
|
|
222
|
+
return { body: parts.join(""), width, height: ly + 2 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function chartCard({ id, title, body, width, height, interactive }) {
|
|
226
|
+
// The title lives INSIDE the svg (so it travels with the copied PNG); the body
|
|
227
|
+
// is shifted down to make room. The copy button is an HTML overlay (top-right).
|
|
228
|
+
const titleH = 18;
|
|
229
|
+
const h = Math.max(1, Math.round(height + titleH));
|
|
230
|
+
const titleSvg = `<text x="0" y="12" ${FONT} font-size="10.5" font-weight="700" fill="#4b5563">${esc(title)}</text>`;
|
|
231
|
+
const svg = `<svg class="chart-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" role="img" aria-label="${esc(title)}"><rect x="0" y="0" width="${width}" height="${h}" fill="#ffffff"/>${titleSvg}<g transform="translate(0,${titleH})">${body}</g></svg>`;
|
|
232
|
+
const copyBtn = interactive ? `<button class="btn-copy chart-copy" type="button" title="Copy this chart as a PNG — paste it into Word">📋</button>` : "";
|
|
233
|
+
return `<figure class="chart-card" id="${esc(id)}">${copyBtn}${svg}</figure>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function emptyCard(id, title, note) {
|
|
237
|
+
return `<figure class="chart-card" id="${esc(id)}"><figcaption class="chart-head"><span class="chart-title">${esc(title)}</span></figcaption><div class="chart-empty">${esc(note)}</div></figure>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build the four-chart "Overview" row. Returns "" when there is nothing to plot.
|
|
242
|
+
*/
|
|
243
|
+
function renderCharts(payload = {}, opts = {}) {
|
|
244
|
+
const prod = payload.prodMatches || [];
|
|
245
|
+
const cwe = cweByCriticality(prod);
|
|
246
|
+
const subs = vulnSubdepsByDep(prod);
|
|
247
|
+
const unattr = unattributedSubdeps(prod);
|
|
248
|
+
const scope = directVsTransitive(prod);
|
|
249
|
+
const prio = fixPriority(prod);
|
|
250
|
+
|
|
251
|
+
if (!(cwe.length || subs.length || scope.length || prio.length || unattr)) return "";
|
|
252
|
+
|
|
253
|
+
const interactive = opts.interactive !== false; // copy button only in the interactive HTML
|
|
254
|
+
|
|
255
|
+
const cweSlices = cwe.map(r => ({ label: r.key, name: r.name, value: r.total, color: SEV_COLOR[worstSev(r.segments)] }));
|
|
256
|
+
// Donut per direct dep: slice size = its sub-dep CVE count, colour = worst
|
|
257
|
+
// severity among them, legend = readable dep name + count.
|
|
258
|
+
const subSlices = subs.map(r => ({ label: r.label, value: r.total, color: SEV_COLOR[worstSev(r.segments)] }));
|
|
259
|
+
const note2 = unattr ? `+${unattr} transitive CVE(s) with no resolved root` : "";
|
|
260
|
+
|
|
261
|
+
// One shared viewBox width across all four donuts (the widest legend) → with
|
|
262
|
+
// equal-width (25%) flex cards they scale identically, so the donuts stay the
|
|
263
|
+
// SAME size regardless of how long any single chart's legend is.
|
|
264
|
+
const W = Math.max(250, legendBoxWidth(cweSlices), legendBoxWidth(subSlices, note2), legendBoxWidth(scope), legendBoxWidth(prio));
|
|
265
|
+
|
|
266
|
+
const card1 = cwe.length
|
|
267
|
+
? chartCard({ id: "chart-cwe", title: "CWE — direct vulns (by criticality)", interactive, ...donutChart({ slices: cweSlices, width: W }) })
|
|
268
|
+
: emptyCard("chart-cwe", "CWE — direct vulns (by criticality)", "No categorised direct CVE.");
|
|
269
|
+
|
|
270
|
+
const card2 = (subs.length || unattr)
|
|
271
|
+
? chartCard({ id: "chart-subdeps", title: "Sub-dep CVEs per dependency", interactive, ...donutChart({ slices: subSlices, width: W, note: note2 }) })
|
|
272
|
+
: emptyCard("chart-subdeps", "Sub-dep CVEs per dependency", "No vulnerable transitive deps.");
|
|
273
|
+
|
|
274
|
+
const card3 = scope.length
|
|
275
|
+
? chartCard({ id: "chart-scope", title: "Direct vs transitive (by severity)", interactive, ...donutChart({ slices: scope, width: W }) })
|
|
276
|
+
: emptyCard("chart-scope", "Direct vs transitive (by severity)", "No production CVE.");
|
|
277
|
+
|
|
278
|
+
const card4 = prio.length
|
|
279
|
+
? chartCard({ id: "chart-priority", title: "Fix priority", interactive, ...donutChart({ slices: prio, width: W }) })
|
|
280
|
+
: emptyCard("chart-priority", "Fix priority", "No production CVE.");
|
|
281
|
+
|
|
282
|
+
return `<section class="charts-row" aria-label="Overview charts">${card1}${card2}${card3}${card4}</section>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
cweByCriticality,
|
|
287
|
+
vulnSubdepsByDep,
|
|
288
|
+
unattributedSubdeps,
|
|
289
|
+
directVsTransitive,
|
|
290
|
+
fixPriority,
|
|
291
|
+
renderCharts,
|
|
292
|
+
SEV_SERIES,
|
|
293
|
+
};
|
|
@@ -50,8 +50,10 @@ module.exports = {
|
|
|
50
50
|
const { ignoreTest, deps2Exclude } = opts;
|
|
51
51
|
const out = new Map();
|
|
52
52
|
const warnings = [];
|
|
53
|
+
const parsedManifests = [];
|
|
53
54
|
for (const g of findComposerManifests(dir, dirFilter(dir, opts))) {
|
|
54
55
|
if (g.composerLock) {
|
|
56
|
+
parsedManifests.push(g.composerLock);
|
|
55
57
|
let parsed;
|
|
56
58
|
try { parsed = parseComposerLock(g.composerLock); }
|
|
57
59
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerLock, message: `composer.lock parse failed: ${e.message}` }); continue; }
|
|
@@ -64,6 +66,7 @@ module.exports = {
|
|
|
64
66
|
}
|
|
65
67
|
} else if (g.composerJson) {
|
|
66
68
|
// No lockfile → best-effort: pinned exact versions only + warning.
|
|
69
|
+
parsedManifests.push(g.composerJson);
|
|
67
70
|
let parsed;
|
|
68
71
|
try { parsed = parseComposerJson(g.composerJson); }
|
|
69
72
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerJson, message: `composer.json parse failed: ${e.message}` }); continue; }
|
|
@@ -83,7 +86,7 @@ module.exports = {
|
|
|
83
86
|
warnings.push({ type: "no-lockfile", manifestPath: g.composerJson, message: `composer.json without composer.lock — best-effort: ${pinned} pinned, ${ranges} range(s) skipped (run "composer install")` });
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
return { deps: out, warnings };
|
|
89
|
+
return { deps: out, warnings, parsedManifests };
|
|
87
90
|
},
|
|
88
91
|
|
|
89
92
|
coordKey(d) { return coordKeyFor("composer", d.namespace || "", d.name); },
|
package/lib/codecs/go.codec.js
CHANGED
|
@@ -44,6 +44,7 @@ module.exports = {
|
|
|
44
44
|
const { deps2Exclude } = opts;
|
|
45
45
|
const out = new Map();
|
|
46
46
|
const warnings = [];
|
|
47
|
+
const parsedManifests = [];
|
|
47
48
|
const add = (d, manifestPath) => {
|
|
48
49
|
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
49
50
|
out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
|
|
@@ -51,6 +52,7 @@ module.exports = {
|
|
|
51
52
|
for (const g of findGoDirs(dir, dirFilter(dir, opts))) {
|
|
52
53
|
if (g.names.has("go.mod")) {
|
|
53
54
|
const fp = path.join(g.dir, "go.mod");
|
|
55
|
+
parsedManifests.push(fp);
|
|
54
56
|
try {
|
|
55
57
|
const r = G.parseGoModFile(fp);
|
|
56
58
|
if (r.deps.length) { for (const d of r.deps) add(d, fp); continue; }
|
|
@@ -59,11 +61,12 @@ module.exports = {
|
|
|
59
61
|
}
|
|
60
62
|
if (g.names.has("go.sum")) {
|
|
61
63
|
const fp = path.join(g.dir, "go.sum");
|
|
64
|
+
parsedManifests.push(fp);
|
|
62
65
|
try { const r = G.parseGoSumFile(fp); for (const d of r.deps) add(d, fp); }
|
|
63
66
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.sum parse failed: ${e.message}` }); }
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
|
-
return { deps: out, warnings };
|
|
69
|
+
return { deps: out, warnings, parsedManifests };
|
|
67
70
|
},
|
|
68
71
|
|
|
69
72
|
coordKey(d) { return coordKeyFor("go", "", d.name); },
|