fad-checker 2.4.1 → 2.4.3

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 CHANGED
@@ -5,6 +5,69 @@ This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### Fixed
9
+ - **External `<parent>` POMs (spring-boot-starter-parent) now backfill their managed
10
+ versions.** A versionless dep whose version is inherited from an external `<parent>`
11
+ (e.g. `spring-boot-starter-actuator` under `spring-boot-starter-parent`, whose own
12
+ parent `spring-boot-dependencies` holds the version table) was left unresolved and
13
+ **dropped from the CVE/OSV/EOL/outdated scans** — the mainline backfill (`lib/maven-bom.js`)
14
+ only handled `<scope>import</scope>` BOMs, never the `<parent>` case, so this failed
15
+ even online (the `--transitive` overlay resolved the parent but couldn't backfill the
16
+ primary version). `collectExternalParents()` now feeds external parents through the same
17
+ `effectivePom` → `backfillVersions` path as import BOMs (import BOMs win on precedence),
18
+ stamped `versionSource={via:"parent",…}` → report "version managed by … (parent POM)".
19
+ It runs in the **mainline** flow (not just `--transitive`), so the warmed cache always
20
+ captures the parent POMs for **offline/air-gapped** reuse. **Child property overrides are
21
+ honored** (Maven semantics): a project that redefines `<log4j2.version>2.17.1</log4j2.version>`
22
+ to patch a CVE resolves the managed coord to `2.17.1`, not the framework default
23
+ (`collectPropertyOverrides()` → `effectivePom`'s new `propertyOverrides`; import-BOM-managed
24
+ coords are correctly left un-overridden). The "missing parent POM — potentially private"
25
+ warning now **partitions** parents fad resolves from Maven Central/cache (public) from the
26
+ truly-unresolvable ones (private), instead of flagging every external parent as suspect.
27
+ - **Anonymized descriptor closes the versionless-Spring-Boot round-trip in one exchange.**
28
+ The `fad-deps/1` descriptor now carries a `maven` hints block (`externalParents[]` +
29
+ `importBoms[]` coords + version `propertyOverrides{}`) so a no-source-tree online
30
+ `--import-anonymized` run can resolve the versionless deps' versions and warm their
31
+ `coord+version`-keyed CVE caches — previously that needed a second air-gapped exchange.
32
+ Only public coords + version strings travel (a private parent listed is a harmless online
33
+ no-op); the source tree never leaves the enclave.
34
+ - **CVE-index recall: real-world pre-2023 records were dropped (incl. Log4Shell).**
35
+ `isMavenRelevant()` only accepted CVE 5.x records carrying machine-readable Maven
36
+ metadata (`packageName`/`collectionURL`/`versionType:"maven"`) or an EXACT-match
37
+ known vendor — but CNAs publish legal-entity vendors ("Apache Software Foundation")
38
+ and display products ("Apache Log4j2"), so **CVE-2021-44228 was absent from the
39
+ index** and an offline scan of `log4j-core:2.14.0` reported no Log4Shell. The filter
40
+ now tokenises vendors, strips leading vendor words from products, and consults the
41
+ curated `data/cpe-coord-map.json` — which also **backfills `packageName`** on
42
+ product-only records so they match at tier-1. `versions[].changes[]` timelines
43
+ (how 44228 encodes its affected windows) are expanded into plain affected windows,
44
+ placeholder bounds (`lessThan:"log4j-core*"`, `"*"`, `"unspecified"`) no longer
45
+ poison comparisons (fail-closed preserved), and `fixVersion` picks the **highest**
46
+ version-like upper bound instead of the first (multi-branch advisories suggested a
47
+ downgrade). Rebuilt index: **6 589 → 15 236 CVE (+131 %)**. Regression-tested against
48
+ the real cvelistV5 record (`test/fixtures/cve-samples/cve-2021-44228-real.json`).
49
+ - **OSV offline: warmed cache older than the 12 h TTL was silently discarded.**
50
+ `--offline` now bypasses the OSV cache TTL (same rule as the NVD cache): on an
51
+ air-gapped box the warmed cache is the only source, and expiring it reported
52
+ "0 OSV vulns" for every ecosystem. Online behaviour is unchanged.
53
+ - **Go: `replace` directives are now applied** (module→module replaces rewrite the
54
+ scanned coordinate — a `replace` downgrade was invisible; directory replaces are
55
+ dropped with a chapter-0 `local-replace` warning), and **pre-1.17 modules merge the
56
+ `go.sum` graph** (their `go.mod` lists direct deps only — transitives were skipped).
57
+ - **PyPI: `pip-compile --generate-hashes` output parsed correctly.** The trailing
58
+ `\` of hash-pinned lines (and inline ` --hash=…` options) made every dep of such a
59
+ requirements file silently skipped. `uv.lock` no longer inventories the project's
60
+ own virtual/editable package. Same-name deps pinned to different versions across
61
+ files now ALL land in `versions[]` (every distinct version scanned, as Maven does).
62
+ - **NuGet: `Directory.Packages.props` is resolved by walking UP the tree** (MSBuild
63
+ semantics — nearest wins). Before, only the csproj's own directory was searched, so
64
+ a root-level CPM solution collected **zero** deps. Also: `VersionOverride` support,
65
+ exact-range pins `[1.2.3]` accepted as concrete, distinct resolved versions across
66
+ projects/TFMs all scanned, and **paged registration indexes** (Newtonsoft.Json-class
67
+ packages) have the needed pages fetched instead of returning empty findings.
68
+ - **Registry caches (go/pypi/nuget): offline runs no longer re-stamp cache freshness**
69
+ (a stale cache would then look fresh to the next online run and skip its refetch).
70
+
8
71
  ### Changed
9
72
  - **Report chapters reorganised into a two-level hierarchy.** Related chapters are now
10
73
  grouped under six **root chapters**, each whose header carries a breakdown count:
@@ -18,6 +81,20 @@ This project adheres to [Semantic Versioning](https://semver.org/).
18
81
  (roots + indented sub-chapters).
19
82
 
20
83
  ### Added
84
+ - **Certificate & key-material scanner (report chapter 2.4).** A new standalone scanner
85
+ (`lib/certs/`, on by default, `--no-certs` to disable) walks the source tree for
86
+ committed cryptographic material and surfaces it in a dedicated report chapter, the
87
+ JSON export (`certificates` array + `summary.certificates`/`certPrivateKeys`) and SARIF
88
+ (`FAD-*` rules). It detects **X.509 certificates** (PEM/DER, parsed with Node's built-in
89
+ `crypto.X509Certificate`) and flags **expired**, **expiring** (within `--cert-expiry-days`,
90
+ default 90), **weak key** (RSA<2048 / weak EC curve), **weak signature** (MD5/SHA1) and
91
+ **self-signed**; **private & public keys** — every key explicitly labelled **private**
92
+ (a committed secret → critical) or **public** (low) — across PEM (PKCS#1/8/SEC1),
93
+ **OpenSSH of every algorithm** (RSA/DSA/ECDSA/Ed25519 incl. FIDO `-sk`), PuTTY `.ppk`,
94
+ PGP and one-line SSH (`*.pub`, `authorized_keys`, `known_hosts`); and **keystores**
95
+ (JKS/JCEKS by magic byte, PKCS#12 by extension). Detection is by extension **and**
96
+ conventional SSH filename. **100% offline** — no network, no decryption — inventory-only
97
+ (does not affect the `--fail-on` gate).
21
98
  - **Scan-provenance manifest + Methodology chapter (audit reproducibility).** Every
22
99
  report now carries a provenance manifest — tool version, run mode (offline/online),
23
100
  the findings-affecting configuration, and the **freshness of every data source**
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 · 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.
11
+ `fad-checker` audits **Maven · Gradle · npm · Yarn · pnpm · Composer · PyPI · NuGet · Go · Ruby**, vendored JavaScript, committed native binaries and cryptographic material (certificates & private/public keys) 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://9pings.github.io/fad-checker/)**
14
14
 
@@ -20,6 +20,7 @@
20
20
  ## Features
21
21
 
22
22
  - **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).
23
+ - **Crypto material** — committed **certificates** (X.509, PEM/DER), **private &amp; public keys** (PEM / OpenSSH every algorithm / PuTTY / PGP / one-line SSH) and **keystores** (JKS/JCEKS/PKCS#12). Each key is labelled **private** (a committed secret → critical) or **public**; certs are checked for **expiry, weak key (RSA<2048), weak signature (MD5/SHA1) and self-signed** — all parsed offline with the built-in X.509 parser, no network. `--no-certs` to disable.
23
24
  - **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)
24
25
  - **CVE, merged & prioritised** — CVEProject + OSV.dev + NVD, CPE/version cross-checked to cut false positives, ranked **CISA KEV → EPSS → CVSS**.
25
26
  - **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).
@@ -62,7 +63,7 @@ The report is organised into **root chapters** (each grouping related sub-chapte
62
63
  | **0. Warnings** *(top)* | local heuristics | Missing lockfiles, unresolved Maven versions (BOM-managed), private libs not on Maven Central |
63
64
  | **Δ. Changes since baseline** *(top, with `--baseline`)* | diff vs prior JSON | New / fixed / unchanged findings per category + the list of **new production CVEs** — for repeat audits and `--fail-on-new` CI gating |
64
65
  | **1. CVE** *(X direct, Y indirect, Z dev)* | CVEProject + OSV.dev + NVD + CPE | **1.1 Production** — public CVE / GHSA in prod deps, per ecosystem, per manifest, **prioritised** by CISA KEV + EPSS + CVSS · **1.2 Vendored JS vulns** ([retire.js](https://retirejs.github.io/)) · **1.3 Dev** (`test`/`provided`, `dev`/`optional`/`peer`) · **1.4 Likely false positives** (CPE-filtered) |
65
- | **2. Unmanaged / unversioned components** | deps.dev + CIRCL (by checksum), retire.js | **2.1 Embedded binaries** — CVEs in libs shipped inside committed `.jar`/`.war`/`.ear` (fat-jars, shaded uber-jars) · **2.2 Native binaries** (`.dll`/`.exe`/`.so`/`.dylib`) identified by hash, flagged should-be-managed / name≠checksum / unknown / malicious · **2.3 Vendored JavaScript** inventory (jQuery, Bootstrap, …) vulnerable *or not* |
66
+ | **2. Unmanaged / unversioned components** | deps.dev + CIRCL (by checksum), retire.js, built-in X.509 | **2.1 Embedded binaries** — CVEs in libs shipped inside committed `.jar`/`.war`/`.ear` (fat-jars, shaded uber-jars) · **2.2 Native binaries** (`.dll`/`.exe`/`.so`/`.dylib`) identified by hash, flagged should-be-managed / name≠checksum / unknown / malicious · **2.3 Vendored JavaScript** inventory (jQuery, Bootstrap, …) vulnerable *or not* · **2.4 Certificates &amp; key material** — committed certs (expiry / weak key / weak signature / self-signed), **private vs public keys** (PEM/OpenSSH/PuTTY/PGP/SSH) and keystores, all parsed offline |
66
67
  | **3. Maintenance / lifecycle** *(X EOL, Y obsolete, Z outdated)* | endoflife.date · curated + registry flags · Maven Central / npm / Packagist / PyPI / NuGet | **3.1 End-of-Life** frameworks · **3.2 Obsolete / deprecated / abandoned / yanked** · **3.3 Outdated** (newer version available, with release dates) |
67
68
  | **4. 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 |
68
69
  | **5. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
@@ -6,7 +6,7 @@ _fad_check_complete() {
6
6
  COMPREPLY=()
7
7
  cur="${COMP_WORDS[COMP_CWORD]}"
8
8
  prev="${COMP_WORDS[COMP_CWORD-1]}"
9
- opts="--src --target --exclude --verbose --no-report --no-transitive --no-all-libs --no-osv --no-nvd --no-epss --no-kev --licenses --no-retire --retire-refresh --report-output --report-html --report-doc --report-sbom --report-csaf --report-json --report-sarif --fail-on --fail-on-new --baseline --no-checksums --ignore --vex --ignore-test --cve-refresh --cve-offline --snyk --transitive-depth --offline --ecosystem --no-maven --no-npm --no-yarn --no-nuget --no-composer --no-pypi --no-go --no-ruby --no-jars --no-js --set-nvd-key --show-config --completion --help --version -s -t -e -v"
9
+ opts="--src --target --exclude --verbose --no-report --no-transitive --no-all-libs --no-osv --no-nvd --no-epss --no-kev --licenses --no-retire --retire-refresh --report-output --report-html --report-doc --report-sbom --report-csaf --report-json --report-sarif --fail-on --fail-on-new --baseline --no-checksums --ignore --vex --ignore-test --cve-refresh --cve-offline --snyk --transitive-depth --offline --ecosystem --no-maven --no-npm --no-yarn --no-nuget --no-composer --no-pypi --no-go --no-ruby --no-jars --no-binaries --no-certs --cert-expiry-days --no-js --set-nvd-key --show-config --completion --help --version -s -t -e -v"
10
10
  case "$prev" in
11
11
  --src|-s|--target|-t|--report-output)
12
12
  COMPREPLY=( $(compgen -d -- "$cur") )
@@ -24,6 +24,9 @@ _fad_check() {
24
24
  '--report-sarif[write SARIF 2.1.0 log]::file:_files'
25
25
  '--no-report[write no output files (gate-only)]'
26
26
  '--no-jars[skip embedded .jar/.war/.ear scanning]'
27
+ '--no-binaries[skip committed native-binary scanning]'
28
+ '--no-certs[skip certificate / key-material scanning]'
29
+ '--cert-expiry-days[warn on certs expiring within N days (default 90)]:days:'
27
30
  '--no-go[skip the Go codec]'
28
31
  '--no-ruby[skip the Ruby codec]'
29
32
  '--fail-on[CI gate level]:level:(none low medium high critical kev)'
@@ -3,6 +3,7 @@
3
3
 
4
4
  "byVendorProduct": {
5
5
  "apache:log4j": ["log4j:log4j", "org.apache.logging.log4j:log4j-core", "org.apache.logging.log4j:log4j-api"],
6
+ "apache:log4j2": ["org.apache.logging.log4j:log4j-core"],
6
7
  "apache:log4j-core": ["org.apache.logging.log4j:log4j-core"],
7
8
  "apache:log4j-api": ["org.apache.logging.log4j:log4j-api"],
8
9
  "apache:struts": ["org.apache.struts:struts2-core"],
package/fad-checker.js CHANGED
@@ -301,6 +301,8 @@ program
301
301
  .option("--no-ruby", "skip the Ruby (Bundler) codec")
302
302
  .option("--no-binaries", "skip scanning committed native binaries (.dll/.exe/.so/.dylib)")
303
303
  .option("--no-jars", "skip scanning embedded .jar/.war/.ear binaries for Maven coordinates")
304
+ .option("--no-certs", "skip scanning committed certificates, private/public keys (PEM/SSH/PuTTY/PGP) and keystores")
305
+ .option("--cert-expiry-days <n>", "warn on certificates expiring within N days", "90")
304
306
  .option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
305
307
  .option("--repo <eco=url...>", "extra registry as <ecosystem>=<url> (e.g. npm=https://npm.acme/) tried before the public one. Repeatable. Supports https://user:pass@host/.")
306
308
  .option("--add-repo <eco>", "persist a registry: --add-repo <ecosystem> <name> <url> [--auth user:pass] [--token TOK]")
@@ -524,11 +526,24 @@ async function timedPhase(label, fn) {
524
526
  let imported;
525
527
  try { imported = deserializeDeps(descriptor); }
526
528
  catch (e) { console.error(chalk.red(`❌ invalid descriptor: ${e.message}`)); process.exit(1); }
527
- const { resolved, activeIds, runMaven, runNpm } = imported;
529
+ const { resolved, activeIds, runMaven, runNpm, externalParents = [], importBoms = [], propertyOverrides = {} } = imported;
528
530
  ui.section("Anonymized descriptor");
529
531
  ui.ok(`imported ${chalk.bold(resolved.size)} dep(s) across ${activeIds.join(", ") || "—"}`);
530
532
  if (options.offline) ui.warn("--offline: caches won't warm; only useful to re-render from an already-warm cache");
531
533
  if (!resolved.size) { ui.warn("descriptor has no dependencies — nothing to scan"); process.exit(0); }
534
+ // Replay the external-parent / import-BOM backfill from the descriptor's carried hints.
535
+ // Workflow B has no source tree here, so the mainline store-based backfill can't run —
536
+ // without this, versionless deps (spring-boot-starter-*) stay unresolved and their CVE
537
+ // caches never warm, forcing a SECOND air-gapped exchange. Online only (needs the POMs).
538
+ if (runMaven && !options.offline && (externalParents.length || importBoms.length)) {
539
+ const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
540
+ const base = { repos: mavenRepos, offline: options.offline, verbose, effCache: new Map() };
541
+ const mgmt = await resolveBomManagedVersions(importBoms, base);
542
+ const parentMgmt = await resolveBomManagedVersions(externalParents, { ...base, via: "parent", propertyOverrides });
543
+ for (const [k, v] of parentMgmt) if (!mgmt.has(k)) mgmt.set(k, v);
544
+ const filled = backfillVersions(resolved, mgmt);
545
+ if (filled) ui.ok(`backfilled ${chalk.bold(filled)} version(s) from ${externalParents.length} parent(s) + ${importBoms.length} BOM(s) in the descriptor`);
546
+ }
532
547
  // Warm retire signatures (online) so --export-cache carries them for offline JS scanning.
533
548
  if (runNpm && !options.offline && options.retire !== false) {
534
549
  const { warmRetireSignatures } = require("./lib/retire");
@@ -623,8 +638,14 @@ async function timedPhase(label, fn) {
623
638
  // --- Anonymized phase 1: export a descriptor and exit (no network, no report) ---
624
639
  if (options.exportAnonymized) {
625
640
  const { serializeDeps } = require("./lib/deps-descriptor");
641
+ const { collectExternalParents, collectImportBoms, collectPropertyOverrides } = require("./lib/maven-bom");
626
642
  const pkgVersion = require("./package.json").version;
627
- const descriptor = serializeDeps(resolved, { generator: `fad-checker ${pkgVersion}` });
643
+ // Carry the Maven resolution hints so a no-source-tree online warm run (Phase 2) can
644
+ // resolve the versionless deps' versions and warm their CVE caches in ONE exchange.
645
+ const externalParents = mavenCtx?.store ? collectExternalParents(mavenCtx.store) : [];
646
+ const importBoms = mavenCtx?.propsByPom ? collectImportBoms(mavenCtx.propsByPom) : [];
647
+ const propertyOverrides = mavenCtx?.store ? collectPropertyOverrides(mavenCtx.store) : {};
648
+ const descriptor = serializeDeps(resolved, { generator: `fad-checker ${pkgVersion}`, externalParents, importBoms, propertyOverrides });
628
649
  try { fs.writeFileSync(options.exportAnonymized, JSON.stringify(descriptor, null, 2) + "\n"); }
629
650
  catch (e) { console.error(chalk.red(`❌ could not write --export-anonymized file: ${e.message}`)); process.exit(1); }
630
651
  const ecoSummary = Object.entries(descriptor.summary.byEcosystem).map(([k, v]) => `${k}:${v}`).join(", ");
@@ -689,9 +710,29 @@ async function timedPhase(label, fn) {
689
710
  return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
690
711
  });
691
712
  if (missingParents.length) {
692
- ui.warn(`${missingParents.length} missing parent POM(s) Snyk will fail if these are private:`);
693
- for (const id of missingParents.slice(0, 10)) ui.info(chalk.yellow(id));
694
- if (missingParents.length > 10) ui.info(chalk.dim(`…and ${missingParents.length - 10} more`));
713
+ // A parent absent from the source tree is EXTERNAL, not necessarily private. fad
714
+ // resolves a PUBLIC one (spring-boot-starter-parent, ) from Maven Central / warmed
715
+ // cache and backfills its managed versions so only classify as "likely private"
716
+ // the ones it genuinely can't resolve. effectivePom is cache-first + offline-aware,
717
+ // and warms the cache the BOM/parent backfill reuses later in the report flow.
718
+ const { effectivePom } = require("./lib/transitive");
719
+ const resolvable = [], unresolved = [];
720
+ await Promise.all(missingParents.map(async id => {
721
+ const [g, a, v] = id.split(":");
722
+ let eff = null;
723
+ if (g && a && v) { try { eff = await effectivePom(g, a, v, { repos: mavenRepos, offline: options.offline }); } catch { eff = null; } }
724
+ (eff && eff.depMgmt && eff.depMgmt.length ? resolvable : unresolved).push(id);
725
+ }));
726
+ if (resolvable.length) {
727
+ ui.ok(`${resolvable.length} external parent POM(s) resolved from Maven Central${options.offline ? " (cache)" : ""} — managed versions backfilled`);
728
+ for (const id of resolvable.slice(0, 10)) ui.info(chalk.green(id));
729
+ if (resolvable.length > 10) ui.info(chalk.dim(`…and ${resolvable.length - 10} more`));
730
+ }
731
+ if (unresolved.length) {
732
+ ui.warn(`${unresolved.length} parent POM(s) not resolvable — likely private${options.offline ? " (or not in the warmed cache)" : ""}; Snyk will fail on these:`);
733
+ for (const id of unresolved.slice(0, 10)) ui.info(chalk.yellow(id));
734
+ if (unresolved.length > 10) ui.info(chalk.dim(`…and ${unresolved.length - 10} more`));
735
+ }
695
736
  } else {
696
737
  ui.ok("no missing Maven parent POMs");
697
738
  }
@@ -826,7 +867,14 @@ async function runReportFlow(resolved, ecoFlags = {}) {
826
867
  .map(b => ({ groupId: b.group, artifactId: b.name, version: b.version }))
827
868
  .filter(b => b.groupId && b.artifactId && b.version);
828
869
  const allBoms = importBoms.concat(gradleBoms);
829
- const willBom = allBoms.length > 0;
870
+ // External <parent> POMs (e.g. spring-boot-starter-parent) manage versionless declared
871
+ // deps via their own inherited depMgmt (spring-boot-dependencies). core.js only follows
872
+ // LOCAL parents, so feed the external ones through the SAME backfill as import BOMs. This
873
+ // runs in the MAINLINE flow (not just the --transitive overlay) so the warmed cache always
874
+ // captures the parent POMs for offline reuse.
875
+ const externalParents = (runMaven && mavenStore)
876
+ ? require("./lib/maven-bom").collectExternalParents(mavenStore) : [];
877
+ const willBom = allBoms.length > 0 || externalParents.length > 0;
830
878
  const willOsv = !!options.osv;
831
879
  // Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
832
880
  // OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
@@ -837,20 +885,35 @@ async function runReportFlow(resolved, ecoFlags = {}) {
837
885
  const willKev = !!options.kev;
838
886
  const willLicenses = !!options.licenses;
839
887
  const willRetire = !!options.retire;
888
+ // Committed crypto material (certs / keys / keystores) — local file scan, no network.
889
+ const willCerts = options.certs !== false && !!options.src;
890
+ const certExpiryDays = parseInt(options.certExpiryDays, 10) || 90;
840
891
  // Identify committed native binaries by checksum (deps.dev + CIRCL) when present.
841
892
  const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
842
893
  // License detection piggybacks on the registry passes (same fetched metadata),
843
894
  // so it adds no progress step of its own.
844
- const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willOsvDb, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
895
+ const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willOsvDb, willNvd, willEpss, willKev, willRetire, willCerts, willBinaryId].filter(Boolean).length;
845
896
  const progress = new ui.Progress(totalSteps);
846
897
 
847
898
  if (willBom) {
848
- const st = progress.start("BOM version resolution (Maven Central)");
899
+ const st = progress.start("BOM / parent version resolution (Maven Central)");
849
900
  try {
850
901
  const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
851
- const mgmt = await resolveBomManagedVersions(allBoms, { repos: mavenRepos, offline, verbose });
902
+ const effCache = new Map();
903
+ const base = { repos: mavenRepos, offline, verbose, effCache };
904
+ // Import BOMs first: a local <scope>import</scope> declaration wins over the
905
+ // versions inherited from an external parent (Maven precedence). Import BOMs
906
+ // resolve in their own context — the project's property overrides do NOT reach
907
+ // them (Maven), so they get no propertyOverrides.
908
+ const mgmt = await resolveBomManagedVersions(allBoms, base);
909
+ // External parents second: fill only coords the BOMs didn't already manage, and
910
+ // honor the project's own <properties> overrides (e.g. a patched <log4j2.version>)
911
+ // so the version reflects the classpath, not the framework default.
912
+ const propertyOverrides = require("./lib/maven-bom").collectPropertyOverrides(mavenStore);
913
+ const parentMgmt = await resolveBomManagedVersions(externalParents, { ...base, via: "parent", propertyOverrides });
914
+ for (const [k, v] of parentMgmt) if (!mgmt.has(k)) mgmt.set(k, v);
852
915
  const filled = backfillVersions(resolved, mgmt);
853
- st.done(`${filled} dep version(s) from ${allBoms.length} import BOM(s)`);
916
+ st.done(`${filled} dep version(s) from ${allBoms.length} BOM(s) + ${externalParents.length} parent(s)`);
854
917
  } catch (err) { st.fail(err.message); }
855
918
  }
856
919
 
@@ -1107,6 +1170,21 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1107
1170
  }
1108
1171
  }
1109
1172
 
1173
+ // 5b. Certificate / key-material scan — committed certs, private/public keys
1174
+ // (PEM, OpenSSH every algorithm, PuTTY, PGP, SSH one-liners) and keystores.
1175
+ // Pure local file walk: no network, so it runs the same online or offline.
1176
+ let certFindings = [];
1177
+ if (willCerts) {
1178
+ const st = progress.start("Certificates & keys");
1179
+ try {
1180
+ const { scanCertificates } = require("./lib/certs");
1181
+ certFindings = scanCertificates(options.src, { srcRoot: options.src, excludePath, defaultExcludes, expiryDays: certExpiryDays });
1182
+ const priv = certFindings.filter(c => c.kind === "private-key").length;
1183
+ const expired = certFindings.filter(c => c.issues.some(i => i.type === "cert-expired")).length;
1184
+ st.done(`${certFindings.length} item(s)${priv ? ` · ${priv} private key(s)` : ""}${expired ? ` · ${expired} expired` : ""}`);
1185
+ } catch (err) { st.fail(err.message); }
1186
+ }
1187
+
1110
1188
  // 6. Snyk (optional)
1111
1189
  let snykMatches = [];
1112
1190
  if (options.snyk) {
@@ -1235,6 +1313,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1235
1313
  }
1236
1314
  }
1237
1315
 
1316
+ if (certFindings.length) {
1317
+ const privN = certFindings.filter(c => c.kind === "private-key").length;
1318
+ const expiredN = certFindings.filter(c => c.issues.some(i => i.type === "cert-expired")).length;
1319
+ heading("Certificates & keys", certFindings.length, [privN ? chalk.red(`${privN} private key`) : null, expiredN ? chalk.yellow(`${expiredN} expired`) : null].filter(Boolean).join(" "));
1320
+ for (const c of certFindings.slice(0, 10)) {
1321
+ const vis = c.keyVisibility ? chalk.dim(`[${c.keyVisibility}]`) : "";
1322
+ const label = c.kind === "certificate" ? `${c.subject || "?"}` : `${c.algorithm || ""} ${c.kind}`.trim();
1323
+ console.log(" " + sev(c.severity.toUpperCase())((c.severity || "?").padEnd(8)) + " " + chalk.white(path.basename(String(c.path))) + " " + vis + " " + chalk.dim(label));
1324
+ }
1325
+ if (certFindings.length > 10) console.log(chalk.dim(` …and ${certFindings.length - 10} more (see report ch.2.4)`));
1326
+ }
1327
+
1238
1328
  heading("EOL frameworks", eolResults.length);
1239
1329
  for (const e of eolResults.slice(0, 8)) console.log(" " + chalk.yellow(e.product.padEnd(18)) + " " + chalk.dim(`${coordOf(e.dep)}:${e.dep.version}`) + " " + chalk.dim(e.eol === true ? "EOL" : String(e.eol)) + definedInOf(e.dep));
1240
1330
  if (eolResults.length > 8) console.log(chalk.dim(` …and ${eolResults.length - 8} more`));
@@ -1379,7 +1469,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1379
1469
  if (out.html || out.doc) {
1380
1470
  await ensureDir(out.html); await ensureDir(out.doc);
1381
1471
  const { htmlPath, docPath } = await writeReports({
1382
- cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1472
+ cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory, certFindings,
1383
1473
  eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs,
1384
1474
  resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests, diff,
1385
1475
  htmlPath: out.html, docPath: out.doc,
@@ -1410,7 +1500,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1410
1500
  try {
1411
1501
  const { writeFindings } = require("./lib/json-export");
1412
1502
  await ensureDir(out.json);
1413
- writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats, diff: jsonDiff }, out.json);
1503
+ writeFindings({ cveMatches, retireMatches, vendoredJsInventory, certFindings, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats, diff: jsonDiff }, out.json);
1414
1504
  wrote.push(["Findings JSON", out.json]);
1415
1505
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1416
1506
  }
@@ -1418,7 +1508,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1418
1508
  try {
1419
1509
  const { writeSarif } = require("./lib/sarif-export");
1420
1510
  await ensureDir(out.sarif);
1421
- writeSarif(cveMatches.filter(m => !m.suppressed), out.sarif, { projectInfo, toolVersion: pkg.version });
1511
+ writeSarif(cveMatches.filter(m => !m.suppressed), out.sarif, { projectInfo, toolVersion: pkg.version, certFindings });
1422
1512
  wrote.push(["SARIF", out.sarif]);
1423
1513
  } catch (err) { ui.warn(`SARIF export failed: ${err.message}`); }
1424
1514
  }