fad-checker 2.2.4 → 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 +7 -7
- package/fad-checker.js +59 -22
- 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 +57 -6
- 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/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
> **F**abulous **A**utonomous **D**ependency **C**hecker<br>
|
|
9
9
|
> AKA **F**uckin' **A**utonomous **D**ependency **C**hecker<br>
|
|
10
10
|
|
|
11
|
-
`fad-checker` audits **Maven · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
|
|
11
|
+
`fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript and committed native binaries in any source tree — multi-module, monorepo, polyglot — and produces a self-contained **HTML + Word report** (CVE prioritised by EPSS + CISA KEV, EOL, obsolete, outdated, licenses) plus **CycloneDX SBOM / CSAF VEX / SARIF / JSON** exports. **No build tools, no Docker, no network needed** — it reads lockfiles and manifests straight off disk.
|
|
12
12
|
|
|
13
13
|
🌐 **[Project site & docs →](https://n8tz.github.io/fad-checker/)**
|
|
14
14
|
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
|
-
- **
|
|
20
|
-
- **No build tools** — reads `pom.xml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
|
|
19
|
+
- **10 ecosystems in one pass** — Maven, **Gradle**, npm/Yarn/pnpm, Composer (PHP), PyPI, NuGet, Go, Ruby; plus **vendored JS** (retire.js), committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`, identified by checksum via deps.dev + CIRCL) and **embedded JARs** (fat-jars/war/ear, unzipped in-memory).
|
|
20
|
+
- **No build tools** — reads `pom.xml`, `build.gradle(.kts)`/`gradle.lockfile`/`libs.versions.toml`, `package-lock`/`yarn.lock`/`pnpm-lock`, `composer.lock`, `poetry`/`Pipfile`/`uv`/`pdm` locks, `packages.lock.json`/`*.csproj`, `go.mod`, `Gemfile.lock` directly. No `mvn`/`gradle`/`npm install`/`pip`/`dotnet restore`/`go build`/`bundle`, no `node_modules/`. → [how it stays build-free](docs/COMPARISON.md#how-its-autonomous-no-build-tools)
|
|
21
21
|
- **CVE, merged & prioritised** — CVEProject + OSV.dev + NVD, CPE/version cross-checked to cut false positives, ranked **CISA KEV → EPSS → CVSS**.
|
|
22
22
|
- **Per-module Maven version mediation** — recovers vulnerable transitive versions that a global `<dependencyManagement>` pin hides in another module (lifted Snyk-corroborated coverage **156 → 181** on a real 25-module reactor, finding CVEs a single Snyk scan missed).
|
|
23
|
-
- **Air-gapped
|
|
23
|
+
- **Air-gapped** — **zero network under `--offline`** (regression-tested), offline Maven transitive resolution, and `--osv-db` for cache-independent offline OSV recall. → [Air-gapped](#air-gapped-audits)
|
|
24
24
|
- **Supply-chain risk** — known-**malicious** advisories (`MAL-`, always block the CI gate) + suspected **typosquats** (`--typosquat`).
|
|
25
25
|
- **Lifecycle** — EOL (endoflife.date), obsolete/deprecated, outdated — across every ecosystem.
|
|
26
26
|
- **Licenses** *(opt-in `--licenses`)* — SPDX-normalised, copyleft/proprietary flagged.
|
|
@@ -63,13 +63,13 @@ A single self-contained binary (no Node), from-source install and shell completi
|
|
|
63
63
|
| **5. Obsolete libraries** | curated list (Maven) + registry maintainer flags | log4j 1.x, jackson-mapper-asl, joda-time, …; npm `deprecated`, Composer `abandoned`, PyPI `yanked`/inactive, NuGet `deprecation` |
|
|
64
64
|
| **6. Outdated libraries** | Maven Central + npm / Packagist / PyPI / NuGet registries | Available newer versions, with release dates |
|
|
65
65
|
| **7. Licenses** *(opt-in: `--licenses`)* | registry metadata + Maven POMs → SPDX policy | Each dep's license normalised to SPDX and classified; copyleft (GPL/AGPL/LGPL/MPL), proprietary and unknown flagged for review |
|
|
66
|
-
| **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
|
|
66
|
+
| **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
|
|
67
67
|
|
|
68
68
|
The HTML report opens in any browser, contains every detail (CVSS vectors, references, full descriptions, CPE configurations, via-paths for transitives) and ships a Word-compatible `.doc` twin. Every match carries a **composite priority** (KEV-exploited > EPSS likelihood > CVSS severity), and the run can additionally emit a **CycloneDX 1.6 SBOM** (`--report-sbom`, vulnerabilities inline) and a **CSAF 2.0 VEX** (`--report-csaf`) for downstream tooling.
|
|
69
69
|
|
|
70
70
|
<p align="center"><img src="docs/assets/report.png" alt="fad-checker HTML report — executive summary with severity tiles and a detailed CVE table with CWE, descriptions and fix versions" width="900"></p>
|
|
71
71
|
|
|
72
|
-
## Air-gapped
|
|
72
|
+
## Air-gapped audits
|
|
73
73
|
|
|
74
74
|
> **Zero-data-sent guarantee.** Under `--offline`, fad-checker makes **no network calls
|
|
75
75
|
> whatsoever** — it reads only the warmed `~/.fad-checker/` caches and never transmits a
|
|
@@ -80,7 +80,7 @@ The HTML report opens in any browser, contains every detail (CVSS vectors, refer
|
|
|
80
80
|
> mainstream OSS scanners, fad also resolves the **Maven transitive graph** offline — so on
|
|
81
81
|
> an air-gapped multi-module project it finds the transitive CVEs they can't.
|
|
82
82
|
|
|
83
|
-
When the audited system is **offline / confidential** (typical of a
|
|
83
|
+
When the audited system is **offline / confidential** (typical of a regulated or air-gapped audit) it
|
|
84
84
|
can't reach OSV / NVD / Maven Central / npm. Split the work across machines while keeping
|
|
85
85
|
**zero environment information** off the secure enclave: an anonymized descriptor carries
|
|
86
86
|
only **public package coordinates** — no filesystem paths, no registry URLs, no
|
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
|
|
|
@@ -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); },
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/gradle/catalog.js — Gradle version catalog (gradle/libs.versions.toml).
|
|
3
|
+
*
|
|
4
|
+
* Parses the TOML [versions]/[libraries] tables into a normalized shape and resolves
|
|
5
|
+
* the Kotlin/Groovy DSL type-safe accessors (`libs.foo.bar` → alias `foo-bar`) plus the
|
|
6
|
+
* `findVersion(ref)`/`findLibrary(alias)` forms used inside convention plugins.
|
|
7
|
+
*
|
|
8
|
+
* A Gradle accessor normalizes `-`, `_` and `.` to the same separator, so `groovy-core`,
|
|
9
|
+
* `groovy.core` and `groovy_core` are the same library. We normalize both the alias keys
|
|
10
|
+
* and the looked-up accessor with `[-_.] → "."` so the reverse lookup is unambiguous.
|
|
11
|
+
*
|
|
12
|
+
* @author: N.BRAUN
|
|
13
|
+
* @email: pp9ping@gmail.com
|
|
14
|
+
*/
|
|
15
|
+
const TOML = require("smol-toml");
|
|
16
|
+
|
|
17
|
+
function normKey(s) { return String(s || "").replace(/[-_.]/g, "."); }
|
|
18
|
+
|
|
19
|
+
// A library entry's version may be an inline string, a { ref = "alias" } pointer into
|
|
20
|
+
// [versions], or absent (BOM-managed). Return the concrete string or null.
|
|
21
|
+
function resolveLibVersion(versionField, versions) {
|
|
22
|
+
if (versionField == null) return null;
|
|
23
|
+
if (typeof versionField === "string") return versionField || null;
|
|
24
|
+
if (typeof versionField === "object") {
|
|
25
|
+
if (typeof versionField.ref === "string") return versions[versionField.ref] || null;
|
|
26
|
+
// { strictly = "x" } / { require = "x" } / { prefer = "x" } rich version constraints.
|
|
27
|
+
return versionField.strictly || versionField.require || versionField.prefer || null;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a libs.versions.toml text → { versions: {alias→str}, libraries: {alias→{group,name,version}},
|
|
34
|
+
* plugins: {alias→{id,version}}, _byAccessor: {normalizedAlias→libraryEntry} }.
|
|
35
|
+
*/
|
|
36
|
+
function parseVersionCatalog(text) {
|
|
37
|
+
let data = {};
|
|
38
|
+
try { data = TOML.parse(String(text || "")); } catch { data = {}; }
|
|
39
|
+
const versions = {};
|
|
40
|
+
for (const [k, v] of Object.entries(data.versions || {})) {
|
|
41
|
+
versions[k] = typeof v === "string" ? v : resolveLibVersion(v, {});
|
|
42
|
+
}
|
|
43
|
+
const libraries = {};
|
|
44
|
+
for (const [alias, raw] of Object.entries(data.libraries || {})) {
|
|
45
|
+
let group = null, name = null;
|
|
46
|
+
if (typeof raw === "string") {
|
|
47
|
+
// shorthand "group:name:version"
|
|
48
|
+
const parts = raw.split(":");
|
|
49
|
+
group = parts[0] || null; name = parts[1] || null;
|
|
50
|
+
libraries[alias] = { group, name, version: parts[2] || null };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (raw && typeof raw === "object") {
|
|
54
|
+
if (typeof raw.module === "string") {
|
|
55
|
+
const i = raw.module.indexOf(":");
|
|
56
|
+
group = i >= 0 ? raw.module.slice(0, i) : raw.module;
|
|
57
|
+
name = i >= 0 ? raw.module.slice(i + 1) : null;
|
|
58
|
+
} else {
|
|
59
|
+
group = raw.group || null;
|
|
60
|
+
name = raw.name || null;
|
|
61
|
+
}
|
|
62
|
+
libraries[alias] = { group, name, version: resolveLibVersion(raw.version, versions) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const plugins = {};
|
|
66
|
+
for (const [alias, raw] of Object.entries(data.plugins || {})) {
|
|
67
|
+
if (typeof raw === "string") { const [id, version] = raw.split(":"); plugins[alias] = { id: id || null, version: version || null }; continue; }
|
|
68
|
+
if (raw && typeof raw === "object") plugins[alias] = { id: raw.id || null, version: resolveLibVersion(raw.version, versions) };
|
|
69
|
+
}
|
|
70
|
+
const byAccessor = {};
|
|
71
|
+
for (const [alias, entry] of Object.entries(libraries)) byAccessor[normKey(alias)] = entry;
|
|
72
|
+
return { versions, libraries, plugins, _byAccessor: byAccessor };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve a DSL accessor (the part after `libs.`, e.g. "clamav.client") → library entry or null. */
|
|
76
|
+
function resolveLibraryAccessor(catalog, accessor) {
|
|
77
|
+
if (!catalog) return null;
|
|
78
|
+
return catalog._byAccessor[normKey(accessor)] || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a version alias (libs.findVersion("spring-boot")) → string or null. */
|
|
82
|
+
function findCatalogVersion(catalog, ref) {
|
|
83
|
+
if (!catalog || !ref) return null;
|
|
84
|
+
if (catalog.versions[ref] != null) return catalog.versions[ref];
|
|
85
|
+
// tolerate accessor-style refs
|
|
86
|
+
const want = normKey(ref);
|
|
87
|
+
for (const [k, v] of Object.entries(catalog.versions)) if (normKey(k) === want) return v;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { parseVersionCatalog, resolveLibraryAccessor, findCatalogVersion, normKey };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/gradle/parse.js — Gradle manifest parsers (lockfile-first, best-effort DSL).
|
|
3
|
+
*
|
|
4
|
+
* gradle.lockfile → authoritative `g:a:v=conf,conf` (resolved, transitives incl.)
|
|
5
|
+
* gradle.properties → `key=value` (used to resolve `$var` versions)
|
|
6
|
+
* build.gradle / .kts → best-effort regex over `dependencies { … }`:
|
|
7
|
+
* string notation, map notation, version-catalog accessors
|
|
8
|
+
* (`libs.foo.bar`), and `platform(...)` BOMs (surfaced
|
|
9
|
+
* separately for the import-BOM backfill, NOT as a dep).
|
|
10
|
+
*
|
|
11
|
+
* A Gradle dependency IS a Maven coordinate, so the codec emits ecosystem "maven" records;
|
|
12
|
+
* this module only turns the various Gradle surfaces into {group,name,version} tuples.
|
|
13
|
+
* Versions that can't be resolved statically (programmatic constructs, missing var) come
|
|
14
|
+
* back null — never assumed-vulnerable — and are listed in `unresolved` for a warning.
|
|
15
|
+
*
|
|
16
|
+
* @author: N.BRAUN
|
|
17
|
+
* @email: pp9ping@gmail.com
|
|
18
|
+
*/
|
|
19
|
+
const { resolveLibraryAccessor, findCatalogVersion } = require("./catalog");
|
|
20
|
+
|
|
21
|
+
const COORD = "[A-Za-z0-9_.\\-]+";
|
|
22
|
+
|
|
23
|
+
// Function/keyword call sites that look like a configuration but are NOT external deps.
|
|
24
|
+
const DENY = new Set([
|
|
25
|
+
"id", "kotlin", "version", "project", "exclude", "platform", "enforcedPlatform",
|
|
26
|
+
"files", "fileTree", "gradleApi", "localGroovy", "because", "create", "named",
|
|
27
|
+
"register", "maven", "url", "uri", "from", "into", "extendsFrom", "add", "plugin",
|
|
28
|
+
"apply", "alias", "set", "property", "the", "get", "named", "dependencies",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isTestConfig(c) { return /test/i.test(String(c || "")); }
|
|
32
|
+
|
|
33
|
+
// Guard against non-coordinate string args that happen to contain a colon — e.g.
|
|
34
|
+
// jvmArgs("-Xshare:off"), systemProperty("a:b"). A real Maven groupId is reverse-DNS:
|
|
35
|
+
// it always starts with a letter (never "-" or a digit); an artifactId starts alphanumeric.
|
|
36
|
+
function looksLikeCoord(group, name) {
|
|
37
|
+
return /^[A-Za-z][\w.\-]*$/.test(String(group || "")) && /^[A-Za-z0-9][\w.\-]*$/.test(String(name || ""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function depScope(config) {
|
|
41
|
+
const isDev = isTestConfig(config);
|
|
42
|
+
return { scope: isDev ? "test" : "compile", isDev };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read the version expression that begins at s[i] (right after `g:a:` in a coord string).
|
|
46
|
+
// Handles a `${ … }` template (balanced braces — so a nested findVersion("x") with its own
|
|
47
|
+
// quotes/parens is captured whole), a bare `$var`, or a plain literal up to the closing quote.
|
|
48
|
+
function readVersionExpr(s, i) {
|
|
49
|
+
if (s[i] === "$" && s[i + 1] === "{") {
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
52
|
+
if (s[j] === "{") depth++;
|
|
53
|
+
else if (s[j] === "}" && --depth === 0) return s.slice(i, j + 1);
|
|
54
|
+
}
|
|
55
|
+
return s.slice(i);
|
|
56
|
+
}
|
|
57
|
+
if (s[i] === "$") { const m = s.slice(i).match(/^\$[\w.]+/); return m ? m[0] : null; }
|
|
58
|
+
const m = s.slice(i).match(/^[^"'\s)]+/);
|
|
59
|
+
return m ? m[0] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve a (possibly `$var` / catalog-backed) version expression → concrete string or null. */
|
|
63
|
+
function resolveVer(raw, ctx) {
|
|
64
|
+
if (raw == null) return null;
|
|
65
|
+
const v = String(raw).trim();
|
|
66
|
+
if (!v) return null;
|
|
67
|
+
if (v.includes("$")) {
|
|
68
|
+
let m = v.match(/findVersion\(\s*["']([^"']+)["']\s*\)/);
|
|
69
|
+
if (m) return findCatalogVersion(ctx.catalog, m[1]) || null;
|
|
70
|
+
m = v.match(/libs\.versions\.([\w.]+?)(?:\.get\(\))?[\s}"')]*$/);
|
|
71
|
+
if (m) { const r = findCatalogVersion(ctx.catalog, m[1]); if (r) return r; }
|
|
72
|
+
m = v.match(/\$\{?([\w.]+)\}?/);
|
|
73
|
+
if (m) {
|
|
74
|
+
const k = m[1];
|
|
75
|
+
if (ctx.localVars && ctx.localVars[k] != null) return ctx.localVars[k];
|
|
76
|
+
if (ctx.properties && ctx.properties[k] != null) return ctx.properties[k];
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const m = v.match(/[\w][\w.\-]*/);
|
|
81
|
+
return m ? m[0] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse the inner content of a `platform( … )` call → {group,name,version} or null. */
|
|
85
|
+
function parsePlatformCoord(content, ctx) {
|
|
86
|
+
const c = String(content || "").trim();
|
|
87
|
+
const head = c.match(new RegExp(`["']?\\s*(${COORD}):(${COORD}):`));
|
|
88
|
+
if (head) {
|
|
89
|
+
const tail = c.slice(c.indexOf(head[0]) + head[0].length);
|
|
90
|
+
return { group: head[1], name: head[2], version: resolveVer(tail, ctx) || null };
|
|
91
|
+
}
|
|
92
|
+
const ga = c.match(new RegExp(`["']\\s*(${COORD}):(${COORD})\\s*["']`));
|
|
93
|
+
if (ga) return { group: ga[1], name: ga[2], version: null };
|
|
94
|
+
const lib = c.match(/libs\.([\w.]+)/);
|
|
95
|
+
if (lib && ctx.catalog) {
|
|
96
|
+
const e = resolveLibraryAccessor(ctx.catalog, lib[1]);
|
|
97
|
+
if (e && e.group && e.name) return { group: e.group, name: e.name, version: e.version || null };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Parse a gradle.lockfile → { deps: [{group,name,version,scope,isDev,configurations}] }. */
|
|
103
|
+
function parseGradleLockfile(text) {
|
|
104
|
+
const deps = [];
|
|
105
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
106
|
+
const line = raw.trim();
|
|
107
|
+
if (!line || line.startsWith("#")) continue;
|
|
108
|
+
const eq = line.indexOf("=");
|
|
109
|
+
if (eq < 0) continue;
|
|
110
|
+
const coord = line.slice(0, eq).trim();
|
|
111
|
+
if (coord === "empty") continue;
|
|
112
|
+
const configurations = line.slice(eq + 1).split(",").map(s => s.trim()).filter(Boolean);
|
|
113
|
+
const parts = coord.split(":");
|
|
114
|
+
if (parts.length < 3 || !parts[0] || !parts[1] || !parts[2]) continue;
|
|
115
|
+
const isDev = configurations.length > 0 && configurations.every(isTestConfig);
|
|
116
|
+
deps.push({ group: parts[0], name: parts[1], version: parts[2], scope: isDev ? "test" : "compile", isDev, configurations });
|
|
117
|
+
}
|
|
118
|
+
return { deps };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Parse a gradle.properties → { key: value }. */
|
|
122
|
+
function parseGradleProperties(text) {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const raw of String(text || "").split(/\r?\n/)) {
|
|
125
|
+
const line = raw.trim();
|
|
126
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
127
|
+
const eq = line.indexOf("=");
|
|
128
|
+
if (eq < 0) continue;
|
|
129
|
+
const k = line.slice(0, eq).trim();
|
|
130
|
+
if (k) out[k] = line.slice(eq + 1).trim();
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort parse of a build.gradle / build.gradle.kts.
|
|
137
|
+
* @param opts { catalog, properties, kotlin } — catalog from parseVersionCatalog, properties
|
|
138
|
+
* from parseGradleProperties; `kotlin` is informational (the regexes accept both DSLs).
|
|
139
|
+
* @returns { deps: [{group,name,version,configuration,scope,isDev}], platformBoms, unresolved }
|
|
140
|
+
*/
|
|
141
|
+
function parseBuildScript(text, opts = {}) {
|
|
142
|
+
const src = String(text || "");
|
|
143
|
+
const ctx = { catalog: opts.catalog || null, properties: opts.properties || {}, localVars: {} };
|
|
144
|
+
for (const m of src.matchAll(/\b(?:val|def)\s+(\w+)\s*=\s*["']([^"']*)["']/g)) ctx.localVars[m[1]] = m[2];
|
|
145
|
+
|
|
146
|
+
const deps = [];
|
|
147
|
+
const platformBoms = [];
|
|
148
|
+
const unresolved = [];
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const addDep = (group, name, versionRaw, config) => {
|
|
151
|
+
if (!group || !name || !looksLikeCoord(group, name)) return;
|
|
152
|
+
const key = `${group}:${name}`;
|
|
153
|
+
if (seen.has(key)) return;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
const version = resolveVer(versionRaw, ctx) || null;
|
|
156
|
+
if (versionRaw != null && /\$/.test(String(versionRaw)) && !version) unresolved.push({ group, name, raw: String(versionRaw), reason: "unresolved-variable" });
|
|
157
|
+
deps.push({ group, name, version, configuration: config, ...depScope(config) });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// 1. platform()/enforcedPlatform() — balanced-paren scan (handles nested ${...} templates
|
|
161
|
+
// with their own quotes/parens), then blank the span so the inner coord isn't re-read.
|
|
162
|
+
const platSpans = [];
|
|
163
|
+
const re = /(?:enforced)?[Pp]latform\s*\(/g;
|
|
164
|
+
let pm;
|
|
165
|
+
while ((pm = re.exec(src))) {
|
|
166
|
+
const open = src.indexOf("(", pm.index + pm[0].length - 1);
|
|
167
|
+
if (open < 0) continue;
|
|
168
|
+
let depth = 0, end = -1;
|
|
169
|
+
for (let i = open; i < src.length; i++) {
|
|
170
|
+
if (src[i] === "(") depth++;
|
|
171
|
+
else if (src[i] === ")") { if (--depth === 0) { end = i; break; } }
|
|
172
|
+
}
|
|
173
|
+
if (end <= open) continue;
|
|
174
|
+
const coord = parsePlatformCoord(src.slice(open + 1, end), ctx);
|
|
175
|
+
if (coord && coord.group && coord.name && !platformBoms.some(b => b.group === coord.group && b.name === coord.name)) platformBoms.push(coord);
|
|
176
|
+
platSpans.push([pm.index, end + 1]);
|
|
177
|
+
}
|
|
178
|
+
let work = src;
|
|
179
|
+
if (platSpans.length) {
|
|
180
|
+
let out = "", last = 0;
|
|
181
|
+
for (const [s, e] of platSpans) { out += src.slice(last, s) + " ".repeat(e - s); last = e; }
|
|
182
|
+
work = out + src.slice(last);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 2. map notation: conf group: 'x', name: 'y'[, version: 'z']
|
|
186
|
+
for (const m of work.matchAll(/\b(\w+)\s*\(?\s*group:\s*(["'])([^"']+)\2\s*,\s*name:\s*(["'])([^"']+)\4(?:\s*,\s*version:\s*(["'])([^"']+)\6)?/g)) {
|
|
187
|
+
if (DENY.has(m[1])) continue;
|
|
188
|
+
addDep(m[3], m[5], m[7] || null, m[1]);
|
|
189
|
+
}
|
|
190
|
+
// 3. string notation: conf("g:a[:v]") | conf 'g:a[:v]'. We match only the opening
|
|
191
|
+
// `conf("g:a` + optional `:` and then read the version expression manually, so a
|
|
192
|
+
// version like `${libs.findVersion("x").get()}` (nested quotes) isn't truncated.
|
|
193
|
+
const strRe = new RegExp(`\\b(\\w+)\\s*(?:\\(\\s*)?(["'])(${COORD}):(${COORD})(:?)`, "g");
|
|
194
|
+
let sm;
|
|
195
|
+
while ((sm = strRe.exec(work))) {
|
|
196
|
+
if (DENY.has(sm[1])) continue;
|
|
197
|
+
const versionRaw = sm[5] === ":" ? readVersionExpr(work, sm.index + sm[0].length) : null;
|
|
198
|
+
addDep(sm[3], sm[4], versionRaw, sm[1]);
|
|
199
|
+
}
|
|
200
|
+
// 4. version-catalog accessor: conf(libs.foo.bar)
|
|
201
|
+
for (const m of work.matchAll(/\b(\w+)\s*(?:\(\s*)?libs\.([\w.]+)/g)) {
|
|
202
|
+
if (DENY.has(m[1]) || m[1] === "libs") continue;
|
|
203
|
+
const accessor = m[2];
|
|
204
|
+
if (/^(versions|findVersion|bundles|plugins)\b/.test(accessor)) continue;
|
|
205
|
+
const lib = resolveLibraryAccessor(ctx.catalog, accessor);
|
|
206
|
+
if (lib && lib.group && lib.name) addDep(lib.group, lib.name, lib.version, m[1]);
|
|
207
|
+
}
|
|
208
|
+
return { deps, platformBoms, unresolved };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { parseGradleLockfile, parseGradleProperties, parseBuildScript, resolveVer, depScope, isTestConfig };
|