fad-checker 2.3.2 → 2.4.1

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,7 +5,44 @@ This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### Changed
9
+ - **Report chapters reorganised into a two-level hierarchy.** Related chapters are now
10
+ grouped under six **root chapters**, each whose header carries a breakdown count:
11
+ **1. CVE** (`X direct, Y indirect, Z dev` — sub: Production, Vendored JS vulns, Dev,
12
+ Likely false positives) · **2. Unmanaged / unversioned components** (`X embedded,
13
+ Y native, Z vendored JS` — sub: embedded JAR/WAR/EAR, native binaries, vendored JS) ·
14
+ **3. Maintenance / lifecycle** (`X EOL, Y obsolete, Z outdated`) · **4. Licenses** ·
15
+ **5. Fix Recommendations** · **6. Scan context & limitations** (sub: scanned
16
+ descriptors, ignored dirs, methodology). **0. Warnings** and **Δ. Changes since
17
+ baseline** stay pinned at the top. The table of contents is now hierarchical
18
+ (roots + indented sub-chapters).
19
+
8
20
  ### Added
21
+ - **Scan-provenance manifest + Methodology chapter (audit reproducibility).** Every
22
+ report now carries a provenance manifest — tool version, run mode (offline/online),
23
+ the findings-affecting configuration, and the **freshness of every data source**
24
+ (CVE index, OSV, NVD, KEV, EPSS, endoflife, registry caches) read from
25
+ `~/.fad-checker/`. Surfaced in the JSON export's `provenance` block and in the
26
+ report's new **chapter 12 — "Methodology, data sources & limitations"**, which also
27
+ states explicitly **what fad-checker does *not* assess** (reachability, runtime
28
+ config, secrets/IaC, first-party code, malware beyond the OSV/CIRCL signal, legal
29
+ license advice). New `lib/provenance.js`.
30
+ - **Differential audits (`--baseline` / `fad diff`).** Diff a scan against a prior
31
+ findings JSON: the report gains a **"Δ Changes since baseline"** chapter, the JSON
32
+ export gains a `diff` block (summary + new/fixed CVEs), and CI can gate on **new**
33
+ findings with `--fail-on-new` (exit 1 on any new production CVE). Standalone
34
+ `fad-checker diff <baseline.json> <current.json> [--report-json <out>] [--fail-on-new]`
35
+ for ad-hoc comparison. Finding identity = CVE id + ecosystem + coord + version. New
36
+ `lib/diff.js`.
37
+ - **Report integrity manifest.** A standard `SHA256SUMS` is written beside the report
38
+ artifacts (verifiable with `sha256sum -c`); `--no-checksums` disables it. New
39
+ `lib/report-integrity.js`.
40
+ - **Private registries for NuGet and Composer** (previously the only registry-backed
41
+ ecosystems without private-feed support). NuGet custom feeds speak the v3
42
+ registration API (a service-index `…/index.json` is auto-resolved to its
43
+ `RegistrationsBaseUrl`); Composer custom feeds are queried via the v2
44
+ `<base>/p2/<vendor>/<pkg>.json` metadata API. Same `--add-repo nuget|composer …`
45
+ CRUD + auth as the other ecosystems.
9
46
  - **Custom registries for npm, PyPI, Ruby and Go** (previously Maven-only). Point
10
47
  fad-checker at private Verdaccio/Artifactory/GitHub Packages (npm), devpi (PyPI),
11
48
  Gemfury/Geminabox (Ruby) or a private GOPROXY/Athens (Go). They are tried in
@@ -49,6 +86,17 @@ This project adheres to [Semantic Versioning](https://semver.org/).
49
86
  repos with `--add-repo maven <name> <url>`.
50
87
 
51
88
  ### Fixed
89
+ - **A failing `--snyk` run is no longer silently reported as "0 findings".** Snyk
90
+ exits 2 on a command error (e.g. not authenticated) and 3 when it detects no
91
+ supported project — but in `--json` mode it still writes a JSON document to
92
+ **stdout**, shaped `{ ok:false, error:"…" }`. `runSnykTest`'s catch block treated
93
+ *any* stdout on a non-zero exit as "vulns found (exit 1)", so that error JSON was
94
+ parsed to zero vulnerabilities and surfaced as a green `Snyk: 0 findings merged`,
95
+ hiding the failure. It now distinguishes real results (a `vulnerabilities` array or
96
+ `ok:true`) from error stubs and **throws the snyk error message** (deduped, joined),
97
+ which the orchestrator shows as a `Snyk run failed: …` warning. A snyk crash with no
98
+ stdout now surfaces `stderr` instead of `execFile`'s generic "Command failed", and a
99
+ timeout is reported as such. New pure helper `snykOutputError()` (unit-tested).
52
100
  - **retire.js now skips the same dirs as the rest of the scan.** The vendored-JS
53
101
  scan walks the tree itself and was handed a bare `--ignore node_modules,…` list,
54
102
  which retire `path.resolve()`s against its **own working directory** — so a
package/README.md CHANGED
@@ -27,7 +27,8 @@
27
27
  - **Supply-chain risk** — known-**malicious** advisories (`MAL-`, always block the CI gate) + suspected **typosquats** (`--typosquat`).
28
28
  - **Lifecycle** — EOL (endoflife.date), obsolete/deprecated, outdated — across every ecosystem.
29
29
  - **Licenses** *(opt-in `--licenses`)* — SPDX-normalised, copyleft/proprietary flagged.
30
- - **Outputs & CI** — HTML + Word `.doc`, CycloneDX 1.6 SBOM, CSAF 2.0 VEX, SARIF 2.1.0, JSON; gate with `--fail-on`, triage with `--ignore`/`--vex`.
30
+ - **Audit-grade & reproducible** — every report carries a **provenance manifest** (data-source freshness + run config) and a **Methodology, data sources & limitations** chapter; artifacts ship a **`SHA256SUMS`** integrity manifest (`sha256sum -c`); **differential audits** diff against a prior run (`--baseline`, or `fad diff a.json b.json`) and CI can gate on *new* findings (`--fail-on-new`).
31
+ - **Outputs & CI** — HTML + Word `.doc`, CycloneDX 1.6 SBOM, CSAF 2.0 VEX, SARIF 2.1.0, JSON; gate with `--fail-on` / `--fail-on-new`, triage with `--ignore`/`--vex`. Private registries for Maven, npm, PyPI, Ruby, Go, **NuGet** and **Composer**.
31
32
 
32
33
  📖 **[Usage & all flags](docs/USAGE.md)** · **[Architecture](docs/ARCHITECTURE.md)** · **[Comparison vs other tools](docs/COMPARISON.md)** · **[Data sources](docs/DATA-SOURCES.md)**
33
34
 
@@ -46,27 +47,27 @@ fad-checker -s ./proj -t ../clean -e "^com\.acme\." --snyk # cleaned POM tre
46
47
  fad-checker -s ./proj --offline # fully offline (zero network)
47
48
  fad-checker -s ./proj --osv-db --typosquat # offline-complete OSV + typosquat
48
49
  fad-checker -s ./proj --licenses --fail-on high # license chapter + CI gate
50
+ fad-checker -s ./proj --report-json --baseline last.json --fail-on-new # differential audit: fail CI on NEW findings
51
+ fad-checker diff last.json this.json # standalone diff of two findings JSONs
49
52
  ```
50
53
 
51
54
  A single self-contained binary (no Node), from-source install and shell completion are in → [docs/USAGE.md](docs/USAGE.md).
52
55
 
53
56
  ## What it finds
54
57
 
58
+ The report is organised into **root chapters** (each grouping related sub-chapters):
59
+
55
60
  | Chapter | Source | What it catches |
56
61
  | --- | --- | --- |
57
- | **0. Warnings** | local heuristics | Missing lockfiles, unresolved Maven versions (BOM-managed), private libs not on Maven Central |
58
- | **1. CVE (production)** | CVEProject + OSV.dev + NVD + CPE | Public CVE / GHSA in production deps, per ecosystem, per manifest file each row **prioritised** by CISA KEV + EPSS + CVSS |
59
- | **1B. Embedded binaries** | same, on coords read from archives | CVEs in libraries **shipped inside committed `.jar`/`.war`/`.ear`** (vendored libs, Spring-Boot fat-jars, shaded uber-jars) not declared in any `pom.xml`. Grouped by containing archive |
60
- | **1C. Unmanaged / vendored binaries** | deps.dev + CIRCL (by checksum) | Committed **native binaries** (`.dll`/`.exe`/`.so`/`.dylib`) no package manager governs — identified by hash, flagged **should-be-managed** (exists in a registry), **name≠checksum** (filename disagrees with the hash), **unknown** (no source knows it) or **malicious** (free CIRCL signal) |
61
- | **1D. Unmanaged / vendored JavaScript** | [retire.js](https://retirejs.github.io/) (`--verbose`) | **Inventory of every standalone JS lib** committed into the tree (jQuery, Bootstrap, PDF.js, …) that no package manager governs vulnerable *or not*. A cyber-hygiene constat: unknown provenance/integrity/patch story. `--no-vendored-js-inventory` to skip |
62
- | **2. Vendored JS (vulnerable)** | [retire.js](https://retirejs.github.io/) | The subset of the above with known CVEs/advisories old jQuery/Bootstrap/Angular/PDF.js copies with no lockfile |
63
- | **3. CVE in dev deps** | same | Same as chapter 1, but for `test`/`provided` (Maven) and `dev`/`optional`/`peer` (npm) |
64
- | **Supply-chain risk** | OSV `MAL-…` + name heuristic | **Known-malicious** packages (always block the CI gate, any `--fail-on` level) and **suspected typosquats** (`--typosquat`: an npm/PyPI name one edit from a popular package `lodahs`↔`lodash`) |
65
- | **4. EOL frameworks** | endoflife.date | Spring Boot 2.5, Hibernate 4.x, EOL JDKs, AngularJS, Laravel/Symfony, Django, .NET, etc. |
66
- | **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` |
67
- | **6. Outdated libraries** | Maven Central + npm / Packagist / PyPI / NuGet registries | Available newer versions, with release dates |
68
- | **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 |
69
- | **8. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
62
+ | **0. Warnings** *(top)* | local heuristics | Missing lockfiles, unresolved Maven versions (BOM-managed), private libs not on Maven Central |
63
+ | **Δ. 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
+ | **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
+ | **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
+ | **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
+ | **5. Fix Recommendations** | computed | Per-ecosystem pin recipes: Maven `<dependencyManagement>`, Gradle `constraints { }`, npm `overrides`, yarn `resolutions`, `composer require`, `pip install`, `dotnet add package` |
69
+ | **6. Scan context & limitations** | provenance manifest + walk | **6.1 Scanned descriptors** (every manifest parsed) · **6.2 Ignored directories** (pruned paths + rule) · **6.3 Methodology, data sources & limitations** (data-source freshness, run config, explicit statement of **what fad-checker does *not* assess**) |
70
+ | **Supply-chain risk** *(cross-cutting)* | OSV `MAL-…` + name heuristic | **Known-malicious** packages (always block the CI gate, any `--fail-on` level) and **suspected typosquats** (`--typosquat`: an npm/PyPI name one edit from a popular package — `lodahs`↔`lodash`) |
70
71
 
71
72
  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.
72
73
 
@@ -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 --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-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") )
@@ -27,6 +27,9 @@ _fad_check() {
27
27
  '--no-go[skip the Go codec]'
28
28
  '--no-ruby[skip the Ruby codec]'
29
29
  '--fail-on[CI gate level]:level:(none low medium high critical kev)'
30
+ '--fail-on-new[fail CI on new production CVEs vs --baseline]'
31
+ '--baseline[diff this scan against a prior findings JSON]::file:_files'
32
+ '--no-checksums[do not write a SHA256SUMS integrity manifest]'
30
33
  '--ignore[suppress findings file]:file:_files'
31
34
  '--vex[ingest CSAF VEX]:file:_files'
32
35
  '--ignore-test[skip test-scoped deps]'
package/fad-checker.js CHANGED
@@ -180,6 +180,48 @@ if (process.argv.includes("--export-cache") || process.argv.includes("--import-c
180
180
  return;
181
181
  }
182
182
 
183
+ // -------- `fad diff <baseline.json> <current.json>` subcommand (pre-parse) --------
184
+ // Standalone differential audit between two findings JSON exports. Mirrors the other
185
+ // pre-parse intercepts so it never collides with the main option set (which requires -s).
186
+ if (process.argv[2] === "diff") {
187
+ const { diffFindings, summarizeDiff, newProductionCveCount } = require("./lib/diff");
188
+ const rest = process.argv.slice(3);
189
+ const positionals = rest.filter(a => !a.startsWith("-"));
190
+ const [basePath, curPath] = positionals;
191
+ const failOnNew = rest.includes("--fail-on-new");
192
+ const jIdx = rest.indexOf("--report-json");
193
+ const jsonOut = jIdx > -1 && rest[jIdx + 1] && !rest[jIdx + 1].startsWith("-") ? rest[jIdx + 1] : null;
194
+ if (!basePath || !curPath) {
195
+ console.error(chalk.red("❌ usage: fad-checker diff <baseline.json> <current.json> [--report-json <out>] [--fail-on-new]"));
196
+ process.exit(2);
197
+ }
198
+ let baseDoc, curDoc;
199
+ try { baseDoc = JSON.parse(fs.readFileSync(basePath, "utf8")); }
200
+ catch (e) { console.error(chalk.red(`❌ cannot read baseline ${basePath}: ${e.message}`)); process.exit(2); }
201
+ try { curDoc = JSON.parse(fs.readFileSync(curPath, "utf8")); }
202
+ catch (e) { console.error(chalk.red(`❌ cannot read current ${curPath}: ${e.message}`)); process.exit(2); }
203
+ const d = diffFindings(baseDoc, curDoc);
204
+ const s = summarizeDiff(d);
205
+ const newProd = newProductionCveCount(d);
206
+ console.log(chalk.bold(`\nDifferential audit ${chalk.dim(basePath)} → ${chalk.dim(curPath)}\n`));
207
+ const line = (label, c) => console.log(` ${label.padEnd(10)} ${chalk.red(`+${c.added} new`)} ${chalk.green(`-${c.removed} fixed`)} ${chalk.dim(`${c.unchanged} unchanged`)}`);
208
+ line("CVE", s.cve); line("EOL", s.eol); line("Obsolete", s.obsolete); line("Outdated", s.outdated); line("Licenses", s.licenses);
209
+ console.log();
210
+ const sev = Object.entries(s.cve.addedBySeverity).filter(([, n]) => n).map(([k, n]) => `${n} ${k.toLowerCase()}`).join(", ") || "none";
211
+ console.log(` ${chalk.bold("New production CVE findings:")} ${newProd ? chalk.red.bold(newProd) : chalk.green("0")} ${chalk.dim(`(${sev})`)}`);
212
+ const newCves = (d.cve.added || []).filter(f => !f.suppressed && !f.cpeFiltered);
213
+ for (const f of newCves.slice(0, 25)) console.log(` ${chalk.red("•")} ${f.id} ${chalk.dim((f.severity || "") + " · " + ((f.dep && f.dep.coord) || "") + "@" + ((f.dep && f.dep.version) || ""))}`);
214
+ if (newCves.length > 25) console.log(chalk.dim(` …and ${newCves.length - 25} more`));
215
+ if (jsonOut) {
216
+ const out = { tool: "fad-checker", kind: "fad-diff/1", baseline: basePath, current: curPath, summary: s, diff: d };
217
+ try { fs.mkdirSync(path.dirname(path.resolve(jsonOut)), { recursive: true }); } catch { /* ignore */ }
218
+ fs.writeFileSync(jsonOut, JSON.stringify(out, null, 2) + "\n");
219
+ console.log(chalk.green(`\n✅ diff written → ${jsonOut}`));
220
+ }
221
+ console.log();
222
+ process.exit(failOnNew && newProd > 0 ? 1 : 0);
223
+ }
224
+
183
225
  const USAGE = `
184
226
  (1) fad-checker -s ./proj # read-only: full report (CVE + EOL + obsolete + outdated + transitive)
185
227
  (2) fad-checker -s ./proj -e "^(org.private|client)" # same, with regex exclusion of private deps
@@ -220,6 +262,9 @@ program
220
262
  .option("--report-json [file]", "write a flat machine-readable findings JSON (default: <report-output>/findings.json)")
221
263
  .option("--report-sarif [file]", "write a SARIF 2.1.0 log for GitHub/GitLab code scanning (default: <report-output>/fad.sarif)")
222
264
  .option("--fail-on <level>", "exit non-zero if a production finding meets <level>: low|medium|high|critical|kev|none", "none")
265
+ .option("--baseline <file>", "differential audit: diff this scan against a prior findings JSON (from --report-json) — shows new / fixed findings in the report + JSON export")
266
+ .option("--fail-on-new", "exit non-zero if the scan introduces any NEW production CVE finding vs --baseline (combine with or instead of --fail-on)")
267
+ .option("--no-checksums", "don't write a SHA256SUMS integrity manifest alongside the report files")
223
268
  .option("--ignore <file>", "suppress findings listed in <file> (CVE ids / coords / globs, one per line)")
224
269
  .option("--vex <file>", "ingest a CSAF VEX: suppress CVEs marked not_affected/fixed")
225
270
  .option("--licenses", "run license detection + copyleft policy check (off by default)")
@@ -1254,6 +1299,13 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1254
1299
  toolVersion: pkg.version,
1255
1300
  cveDataDate,
1256
1301
  };
1302
+ // Scan-provenance manifest: data-source freshness + run configuration, for a
1303
+ // reproducible/defensible audit. Surfaced in the report's Methodology chapter and
1304
+ // the JSON export's `provenance` block.
1305
+ try {
1306
+ const { buildScanProvenance } = require("./lib/provenance");
1307
+ projectInfo.provenance = buildScanProvenance({ toolVersion: pkg.version, generatedAt: projectInfo.generatedAt, options });
1308
+ } catch (err) { if (verbose) ui.warn(`provenance manifest skipped: ${err.message}`); }
1257
1309
 
1258
1310
  // --- Output target resolution -------------------------------------------------
1259
1311
  // One --report-<type> flag per output, each taking an OPTIONAL path: a string is
@@ -1307,13 +1359,29 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1307
1359
  }] : []),
1308
1360
  ];
1309
1361
 
1362
+ // Differential audit vs --baseline: build the current findings doc once, diff it
1363
+ // against the prior export → a `diff` block for the report + JSON export, and a
1364
+ // `--fail-on-new` gate signal. Best-effort: a missing/unreadable baseline warns.
1365
+ let diff = null, jsonDiff = null;
1366
+ if (options.baseline) {
1367
+ try {
1368
+ const baseDoc = JSON.parse(fs.readFileSync(options.baseline, "utf8"));
1369
+ const { buildFindings } = require("./lib/json-export");
1370
+ const curDoc = buildFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats });
1371
+ const { diffFindings, summarizeDiff } = require("./lib/diff");
1372
+ const dd = diffFindings(baseDoc, curDoc);
1373
+ diff = { ...dd, summary: summarizeDiff(dd) };
1374
+ jsonDiff = { summary: diff.summary, cve: { added: dd.cve.added, removed: dd.cve.removed } };
1375
+ } catch (err) { ui.warn(`baseline diff skipped (${options.baseline}): ${err.message}`); }
1376
+ }
1377
+
1310
1378
  const wrote = [];
1311
1379
  if (out.html || out.doc) {
1312
1380
  await ensureDir(out.html); await ensureDir(out.doc);
1313
1381
  const { htmlPath, docPath } = await writeReports({
1314
1382
  cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1315
1383
  eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs,
1316
- resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
1384
+ resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests, diff,
1317
1385
  htmlPath: out.html, docPath: out.doc,
1318
1386
  });
1319
1387
  if (htmlPath) wrote.push(["HTML report", htmlPath]);
@@ -1342,7 +1410,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1342
1410
  try {
1343
1411
  const { writeFindings } = require("./lib/json-export");
1344
1412
  await ensureDir(out.json);
1345
- writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
1413
+ writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats, diff: jsonDiff }, out.json);
1346
1414
  wrote.push(["Findings JSON", out.json]);
1347
1415
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1348
1416
  }
@@ -1355,6 +1423,20 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1355
1423
  } catch (err) { ui.warn(`SARIF export failed: ${err.message}`); }
1356
1424
  }
1357
1425
 
1426
+ // Integrity manifest: a standard SHA256SUMS over every artifact written this run,
1427
+ // for a tamper-evident deliverable (verify with `sha256sum -c SHA256SUMS`). On by
1428
+ // default; --no-checksums disables. Written beside the first artifact.
1429
+ if (wrote.length && options.checksums !== false) {
1430
+ try {
1431
+ const { writeChecksums } = require("./lib/report-integrity");
1432
+ const files = wrote.map(([, p]) => p);
1433
+ const manifestDir = path.dirname(path.resolve(files[0]));
1434
+ const manifestPath = path.join(manifestDir, "SHA256SUMS");
1435
+ writeChecksums(files, manifestPath, { baseDir: manifestDir });
1436
+ wrote.push(["Integrity (SHA-256)", manifestPath]);
1437
+ } catch (err) { ui.warn(`checksum manifest skipped: ${err.message}`); }
1438
+ }
1439
+
1358
1440
  if (wrote.length) {
1359
1441
  ui.section("Output");
1360
1442
  for (const [label, p] of wrote) ui.ok(`${label} → ${chalk.white(p)}`);
@@ -1365,21 +1447,36 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1365
1447
  }
1366
1448
  console.log();
1367
1449
 
1450
+ // Differential-audit summary (vs --baseline).
1451
+ if (diff && diff.summary) {
1452
+ const s = diff.summary;
1453
+ ui.section("Baseline diff");
1454
+ const sev = Object.entries(s.cve.addedBySeverity).filter(([, n]) => n).map(([k, n]) => `${n} ${k.toLowerCase()}`).join(", ") || "none";
1455
+ console.log(` CVE ${chalk.red(`+${s.cve.added} new`)} ${chalk.green(`-${s.cve.removed} fixed`)} ${chalk.dim(`${s.cve.unchanged} unchanged`)}`);
1456
+ console.log(` ${chalk.bold("New production CVE:")} ${s.cve.addedProduction ? chalk.red.bold(s.cve.addedProduction) : chalk.green("0")} ${chalk.dim(`(${sev})`)}`);
1457
+ console.log();
1458
+ }
1459
+
1368
1460
  // CI gating — set a non-zero exit code (after all reports/exports are written)
1369
- // when a production finding meets the --fail-on threshold.
1370
- if (options.failOn && options.failOn !== "none") {
1461
+ // when a production finding meets the --fail-on threshold, OR (--fail-on-new) when
1462
+ // the scan introduces any new production CVE finding vs the --baseline.
1463
+ const newProd = (options.failOnNew && diff && diff.summary) ? diff.summary.cve.addedProduction : 0;
1464
+ if ((options.failOn && options.failOn !== "none") || options.failOnNew) {
1371
1465
  const { evaluateGate } = require("./lib/gate");
1372
1466
  // Embedded-binary findings are real production risk → gate on them too.
1373
- const gate = evaluateGate([...prodActive, ...embeddedActive], options.failOn);
1467
+ const gate = (options.failOn && options.failOn !== "none")
1468
+ ? evaluateGate([...prodActive, ...embeddedActive], options.failOn)
1469
+ : { failed: false, reason: "" };
1374
1470
  // A known-malicious package always blocks, regardless of the --fail-on level.
1375
1471
  const maliciousActive = [...prodActive, ...embeddedActive].filter(m => m.malicious);
1376
- if (gate.failed || maliciousActive.length) {
1472
+ if (gate.failed || maliciousActive.length || newProd > 0) {
1377
1473
  ui.section("Gate");
1378
1474
  if (maliciousActive.length) console.log(chalk.red.bold(`✗ ${maliciousActive.length} known-malicious package(s) detected — always blocks`));
1379
1475
  if (gate.failed) console.log(chalk.red(`✗ --fail-on ${options.failOn}: ${gate.reason}`));
1476
+ if (newProd > 0) console.log(chalk.red(`✗ --fail-on-new: ${newProd} new production CVE finding(s) vs baseline`));
1380
1477
  process.exitCode = 1;
1381
1478
  } else if (verbose) {
1382
- ui.info(chalk.dim(`--fail-on ${options.failOn}: no blocking finding`));
1479
+ ui.info(chalk.dim(`gate: no blocking finding`));
1383
1480
  }
1384
1481
  }
1385
1482
  }
@@ -14,6 +14,7 @@ const fs = require("fs");
14
14
  const path = require("path");
15
15
  const os = require("os");
16
16
  let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
17
+ const { authHeaderFor, normalise } = require("../../registries");
17
18
 
18
19
  const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
19
20
  const CACHE_PATH = path.join(CACHE_DIR, "packagist-cache.json");
@@ -48,19 +49,50 @@ function packagistToFindings(pkg, { version }) {
48
49
  return out;
49
50
  }
50
51
 
51
- async function fetchPackage(name, { offline }) {
52
+ // Convert a Composer v2 metadata document (`/p2/<name>.json`) into the packagist-like
53
+ // `package` shape packagistToFindings() consumes: { versions: { "<v>": {license} },
54
+ // abandoned }. The v2 format is { packages: { "<name>": [ {version, license, …}, … ] } }.
55
+ // Private feeds (Artifactory, Private Packagist) sometimes tag an abandoned package on
56
+ // its version objects; surface that if present.
57
+ function composerV2ToPackageObject(json, name) {
58
+ const arr = (json && json.packages && (json.packages[name] || json.packages[name.toLowerCase()])) || [];
59
+ const versions = {};
60
+ let abandoned = null;
61
+ for (const v of arr) {
62
+ if (!v || !v.version) continue;
63
+ versions[v.version] = { license: v.license || [] };
64
+ if (abandoned == null && v.abandoned != null && v.abandoned !== false) {
65
+ abandoned = (typeof v.abandoned === "string") ? v.abandoned : true;
66
+ }
67
+ }
68
+ return { versions, abandoned };
69
+ }
70
+
71
+ // Custom registries first (Composer v2 metadata: <base>/p2/<name>.json), then the
72
+ // public Packagist rich API (packages/<name>.json). Per-registry Basic/Bearer auth.
73
+ async function fetchPackage(name, { offline, registries = [], fetcher = globalThis.fetch } = {}) {
52
74
  if (offline) return null;
75
+ let lastErr = null;
76
+ for (const base of registries || []) {
77
+ const headers = { "User-Agent": "fad-checker-packagist", Accept: "application/json" };
78
+ const ah = authHeaderFor(base); if (ah) headers.Authorization = ah;
79
+ try {
80
+ const res = await fetcher(`${normalise(base.url)}p2/${name}.json`, { headers });
81
+ if (res.ok) return composerV2ToPackageObject(await res.json(), name);
82
+ lastErr = `HTTP ${res.status}`;
83
+ } catch (e) { lastErr = e.message; }
84
+ }
53
85
  try {
54
- const res = await fetch(`${API}/${name}.json`, { headers: { "User-Agent": "fad-checker-packagist" } });
86
+ const res = await fetcher(`${API}/${name}.json`, { headers: { "User-Agent": "fad-checker-packagist", Accept: "application/json" } });
55
87
  if (!res.ok) return { error: `HTTP ${res.status}` };
56
88
  const json = await res.json();
57
89
  return json.package || { error: "no package field" };
58
- } catch (e) { return { error: e.message }; }
90
+ } catch (e) { return { error: lastErr || e.message }; }
59
91
  }
60
92
 
61
93
  // Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
62
94
  async function checkComposerRegistryDeps(deps, opts = {}) {
63
- const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
95
+ const { verbose, offline, allLibs = true, concurrency = 8, registries = [], fetcher } = opts;
64
96
  const targets = [...deps.values()].filter(d => d.ecosystem === "composer" && d.version);
65
97
  const result = { deprecated: [], outdated: [], licensed: [] };
66
98
  if (!targets.length) return result;
@@ -73,7 +105,7 @@ async function checkComposerRegistryDeps(deps, opts = {}) {
73
105
  const key = `${name}@${t.version}`;
74
106
  let ex = cache.entries[key];
75
107
  if (!ex) {
76
- const pkg = await fetchPackage(name, { offline });
108
+ const pkg = await fetchPackage(name, { offline, registries, ...(fetcher ? { fetcher } : {}) });
77
109
  if (pkg && !pkg.error) {
78
110
  const f = packagistToFindings(pkg, { version: t.version });
79
111
  ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null, license: f.license || null };
@@ -97,4 +129,4 @@ async function checkComposerRegistryDeps(deps, opts = {}) {
97
129
  return result;
98
130
  }
99
131
 
100
- module.exports = { packagistToFindings, checkComposerRegistryDeps };
132
+ module.exports = { packagistToFindings, checkComposerRegistryDeps, fetchPackage, composerV2ToPackageObject };
@@ -14,11 +14,11 @@ const fs = require("fs");
14
14
  const path = require("path");
15
15
  const os = require("os");
16
16
  let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
17
+ const { withPublic, authHeaderFor, normalise } = require("../../registries");
17
18
 
18
19
  const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
19
20
  const CACHE_PATH = path.join(CACHE_DIR, "nuget-cache.json");
20
21
  const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
21
- const REG = "https://api.nuget.org/v3/registration5-gz-semver2";
22
22
 
23
23
  function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
24
24
  function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
@@ -45,18 +45,57 @@ function nugetRegistrationToFindings(reg, { version }) {
45
45
  return out;
46
46
  }
47
47
 
48
- async function fetchRegistration(name, { offline }) {
49
- if (offline) return null;
48
+ // A NuGet v3 service index (…/index.json) lists resources; the registration base is
49
+ // the resource whose @type starts with "RegistrationsBaseUrl" (prefer a gz/semver2
50
+ // variant). A configured base that is NOT a service index is treated as a
51
+ // registration base directly. Resolved bases are memoised for the process lifetime.
52
+ const _regBaseMemo = new Map();
53
+ async function resolveRegistrationBase(base, fetcher, headers) {
54
+ const raw = String(base.url || "");
55
+ if (!/index\.json\/?$/i.test(raw)) return normalise(raw);
56
+ const url = raw.replace(/\/$/, "");
57
+ if (_regBaseMemo.has(url)) return _regBaseMemo.get(url);
58
+ let resolved = null;
50
59
  try {
51
- const res = await fetch(`${REG}/${name.toLowerCase()}/index.json`, { headers: { "User-Agent": "fad-checker-nuget" } });
52
- if (!res.ok) return { error: `HTTP ${res.status}` };
53
- return await res.json();
54
- } catch (e) { return { error: e.message }; }
60
+ const res = await fetcher(url, { headers });
61
+ if (res.ok) {
62
+ const idx = await res.json();
63
+ const resources = idx.resources || [];
64
+ const pick = resources.find(r => /^RegistrationsBaseUrl\/.*(gz|semver2)/i.test(r["@type"]))
65
+ || resources.find(r => String(r["@type"] || "").startsWith("RegistrationsBaseUrl"));
66
+ if (pick && pick["@id"]) resolved = normalise(pick["@id"]);
67
+ }
68
+ } catch { /* fall through → null */ }
69
+ _regBaseMemo.set(url, resolved);
70
+ return resolved;
71
+ }
72
+
73
+ // Custom registries first (NuGet v3 registration API: <regBase>/<lowerid>/index.json),
74
+ // then api.nuget.org. A service-index base is resolved to its RegistrationsBaseUrl.
75
+ async function fetchRegistration(name, { offline, registries = [], fetcher = globalThis.fetch } = {}) {
76
+ if (offline) return null;
77
+ const id = name.toLowerCase();
78
+ const bases = withPublic("nuget", registries);
79
+ let lastErr = null;
80
+ for (const base of bases) {
81
+ const headers = { "User-Agent": "fad-checker-nuget", Accept: "application/json" };
82
+ const ah = authHeaderFor(base); if (ah) headers.Authorization = ah;
83
+ let regBase;
84
+ try { regBase = await resolveRegistrationBase(base, fetcher, headers); }
85
+ catch (e) { lastErr = e.message; continue; }
86
+ if (!regBase) { lastErr = "no registration base"; continue; }
87
+ try {
88
+ const res = await fetcher(`${regBase}${id}/index.json`, { headers });
89
+ if (res.ok) return await res.json();
90
+ lastErr = `HTTP ${res.status}`;
91
+ } catch (e) { lastErr = e.message; }
92
+ }
93
+ return { error: lastErr || "no data" };
55
94
  }
56
95
 
57
96
  // Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
58
97
  async function checkNugetRegistryDeps(deps, opts = {}) {
59
- const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
98
+ const { verbose, offline, allLibs = true, concurrency = 8, registries = [], fetcher } = opts;
60
99
  const targets = [...deps.values()].filter(d => d.ecosystem === "nuget" && d.version);
61
100
  const result = { deprecated: [], outdated: [], licensed: [] };
62
101
  if (!targets.length) return result;
@@ -68,7 +107,7 @@ async function checkNugetRegistryDeps(deps, opts = {}) {
68
107
  const key = `${t.name.toLowerCase()}@${t.version}`;
69
108
  let ex = cache.entries[key];
70
109
  if (!ex) {
71
- const reg = await fetchRegistration(t.name, { offline });
110
+ const reg = await fetchRegistration(t.name, { offline, registries, ...(fetcher ? { fetcher } : {}) });
72
111
  if (reg && !reg.error) {
73
112
  const f = nugetRegistrationToFindings(reg, { version: t.version });
74
113
  ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null, license: f.license || null };
@@ -92,4 +131,4 @@ async function checkNugetRegistryDeps(deps, opts = {}) {
92
131
  return result;
93
132
  }
94
133
 
95
- module.exports = { nugetRegistrationToFindings, checkNugetRegistryDeps };
134
+ module.exports = { nugetRegistrationToFindings, checkNugetRegistryDeps, fetchRegistration, resolveRegistrationBase };
package/lib/cve-report.js CHANGED
@@ -130,6 +130,9 @@ h2 { margin: 32px 0 12px; font-size: 20px; border-bottom: 2px solid #e5e7eb; pad
130
130
  .toc { position: sticky; top: 0; z-index: 50; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; margin: 16px 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.04); }
131
131
  .toc a { color: #4338ca; text-decoration: none; padding: 2px 6px; border-radius: 3px; }
132
132
  .toc a:hover { background: #eef2ff; }
133
+ .toc a.toc-root { font-weight: 700; }
134
+ .toc a.toc-sub { font-size: 11px; color: #6b7280; padding-left: 4px; }
135
+ .toc a.toc-sub::before { content: "› "; color: #c7cdd6; }
133
136
  .exec-summary .exec-top { margin-top: 8px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
134
137
  .exec-summary .exec-top-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #6b7280; margin-bottom: 6px; }
135
138
  .exec-summary .exec-top-list { list-style: none; padding: 0; margin: 0; font-size: 12px; }
@@ -400,11 +403,20 @@ function renderDefinedIn(dep, srcRoot) {
400
403
  const paths = (dep.manifestPaths && dep.manifestPaths.length) ? dep.manifestPaths
401
404
  : (dep.pomPaths && dep.pomPaths.length) ? dep.pomPaths
402
405
  : [];
403
- if (!paths.length) return "";
404
- const rel = paths.map(p => makeRelative(p, srcRoot));
405
- const shown = rel.slice(0, 3).map(p => `<code>${esc(p)}</code>`).join(", ");
406
- const more = rel.length > 3 ? ` <span class="defined-more">+${rel.length - 3} more</span>` : "";
407
- return `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
406
+ let definedLine = "";
407
+ if (paths.length) {
408
+ const rel = paths.map(p => makeRelative(p, srcRoot));
409
+ const shown = rel.slice(0, 3).map(p => `<code>${esc(p)}</code>`).join(", ");
410
+ const more = rel.length > 3 ? ` <span class="defined-more">+${rel.length - 3} more</span>` : "";
411
+ definedLine = `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
412
+ }
413
+ // A versionless declared dep (typical Spring Boot / Gradle) gets its version from an import
414
+ // BOM, not the manifest it's declared in. Disclose that so 6.0.3 doesn't read as fabricated.
415
+ const vs = dep.versionSource;
416
+ const versionLine = (vs && vs.via === "bom" && vs.bom)
417
+ ? `<div class="defined-in"><span class="defined-label">version managed by:</span> <code>${esc(vs.bom)}</code> (BOM)</div>`
418
+ : "";
419
+ return definedLine + versionLine;
408
420
  }
409
421
 
410
422
  function makeRelative(absPath, srcRoot) {
@@ -1170,18 +1182,23 @@ function attachRootUpdates(cveMatches, outdatedResults, resolvedDeps) {
1170
1182
  }
1171
1183
  }
1172
1184
 
1185
+ // Every chapter / sub-chapter renders EXPANDED by default (the user wants the whole
1186
+ // report open on load). The `open` opt is accepted for call-site compatibility but
1187
+ // intentionally ignored — collapse is still available via the toolbar / clicking a
1188
+ // header. (Per-CVE detail rows are NOT <details>, so they stay collapsed-by-default.)
1173
1189
  function majorSection(title, contentHtml, opts = {}) {
1174
- const { open = true, id } = opts;
1190
+ const { id } = opts;
1175
1191
  const idAttr = id ? ` id="${esc(id)}"` : "";
1176
- return `<details class="report-section"${open ? " open" : ""}${idAttr}>
1192
+ return `<details class="report-section" open${idAttr}>
1177
1193
  <summary><h2>${title}</h2></summary>
1178
1194
  <div>${contentHtml}</div>
1179
1195
  </details>`;
1180
1196
  }
1181
1197
 
1182
1198
  function minorSection(title, contentHtml, opts = {}) {
1183
- const { open = true } = opts;
1184
- return `<details class="report-subsection"${open ? " open" : ""}>
1199
+ const { id } = opts;
1200
+ const idAttr = id ? ` id="${esc(id)}"` : "";
1201
+ return `<details class="report-subsection" open${idAttr}>
1185
1202
  <summary><h3>${title}</h3></summary>
1186
1203
  <div>${contentHtml}</div>
1187
1204
  </details>`;
@@ -1485,7 +1502,7 @@ function renderLicenseChapter(licenseResults) {
1485
1502
  return intro + blocks;
1486
1503
  }
1487
1504
 
1488
- function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
1505
+ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], diff = null, wrapTables = true }) {
1489
1506
  setRenderCtx({ srcRoot: projectInfo?.src || null });
1490
1507
  // Dedupe rows so a single (dep, cve) shows once with all via paths merged.
1491
1508
  cveMatches = mergeMatches(cveMatches || []);
@@ -1513,9 +1530,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1513
1530
  // Recos are built from prod matches only (dev/test issues aren't a release blocker).
1514
1531
  const recos = buildFixRecommendations(prodMatchesActive, outdatedResults, resolvedDeps);
1515
1532
 
1516
- const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
1533
+ const cveContent = renderCveBySectionByEco(prodMatchesActive, "1.1", projectInfo?.src);
1517
1534
  const vendoredContent = renderRetireTable(retireMatches);
1518
- const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
1535
+ const devCveContent = renderCveBySectionByEco(devMatchesActive, "1.3", projectInfo?.src);
1519
1536
  const licenseContent = renderLicenseChapter(licenseResults);
1520
1537
  const licenseTotal = licenseResults?.assessed?.length || 0;
1521
1538
  const licenseFlagged = licenseResults?.flagged?.length || 0;
@@ -1548,27 +1565,86 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1548
1565
  interactive: wrapTables, // copy button + scripts only in the HTML output
1549
1566
  });
1550
1567
 
1551
- // (C) Table of contents rendered as a sticky nav. Only enumerates the
1552
- // chapters that actually have content (warnings, dev CVE, retire, FP appendix
1553
- // might be empty).
1568
+ // ---- Chapters are grouped under "root" chapters (a two-level hierarchy) ----
1554
1569
  const scanned = buildScannedManifests(resolvedDeps, projectInfo?.src, parsedManifests);
1555
- const toc = renderToc({
1556
- hasWarnings: !!(warnings?.length),
1557
- prodTotal: prodStats.total,
1558
- scannedTotal: scanned.length,
1559
- retireTotal: retireMatches.length,
1560
- devTotal: devStats.total,
1561
- eolTotal: eolResults?.length || 0,
1562
- obsoleteTotal: obsoleteResults?.length || 0,
1563
- outdatedTotal: outdatedResults?.length || 0,
1564
- licenseTotal,
1565
- licenseFlagged,
1566
- embeddedTotal: embeddedInventory.length,
1567
- unmanagedTotal: unmanagedInventory.length,
1568
- vendoredJsTotal: vendoredJsInv.length,
1569
- fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
1570
- excludedTotal: excludedDirs?.length || 0,
1571
- });
1570
+ const fpTotal = prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length;
1571
+ const eolTotal = eolResults?.length || 0;
1572
+ const obsTotal = obsoleteResults?.length || 0;
1573
+ const outTotal = outdatedResults?.length || 0;
1574
+ const embTotal = embeddedInventory.length;
1575
+ const unmTotal = unmanagedInventory.length;
1576
+ const vjsTotal = vendoredJsInv.length;
1577
+ const excTotal = excludedDirs?.length || 0;
1578
+ // CVE root header breakdown: direct vs indirect (transitive) production vulns + the dev count.
1579
+ const prodDirect = prodMatchesActive.filter(m => m.dep?.scope !== "transitive").length;
1580
+ const prodIndirect = prodMatchesActive.filter(m => m.dep?.scope === "transitive").length;
1581
+
1582
+ // Root 1 — CVE (production + vendored-JS vulns + dev + likely false positives).
1583
+ const cveRoot = majorSection(
1584
+ `1. CVE (${prodDirect} direct, ${prodIndirect} indirect, ${devStats.total} dev)`,
1585
+ minorSection(`1.1 Production (${prodStats.total})`, cveContent, { id: "ch1", open: true }) +
1586
+ minorSection(`1.2 Vendored JS vulns — retire.js (${retireMatches.length})`, vendoredContent, { id: "ch2", open: retireMatches.length > 0 && retireMatches.length <= 50 }) +
1587
+ minorSection(`1.3 Dev dependencies (${devStats.total})`, devCveContent, { id: "ch3", open: devStats.total > 0 && devStats.total <= 50 }) +
1588
+ (fpTotal ? minorSection(`1.4 Likely false positives — CPE-filtered (${fpTotal})`, fpContent, { id: "ch9", open: false }) : ""),
1589
+ { id: "chcve" });
1590
+
1591
+ // Root 2 — Unmanaged / unversioned components (binaries + vendored JS, no package manager).
1592
+ const binRoot = majorSection(
1593
+ `2. Unmanaged / unversioned components (${embTotal} embedded, ${unmTotal} native, ${vjsTotal} vendored JS)`,
1594
+ (embTotal ? minorSection(`2.1 Embedded binaries — JAR/WAR/EAR (${embTotal})`, embeddedContent, { id: "ch1e", open: embTotal <= 50 }) : "") +
1595
+ (unmTotal ? minorSection(`2.2 Unmanaged / vendored native binaries (${unmTotal})`, unmanagedContent, { id: "ch1c", open: unmTotal <= 50 }) : "") +
1596
+ (vjsTotal ? minorSection(`2.3 Unmanaged / vendored JavaScript (${vjsTotal})`, vendoredJsContent, { id: "ch1d", open: vjsTotal <= 50 }) : "") ||
1597
+ `<div class="empty">No unmanaged binaries or vendored JavaScript found.</div>`,
1598
+ { id: "chbin", open: (embTotal + unmTotal + vjsTotal) > 0 });
1599
+
1600
+ // Root 3 — Maintenance / lifecycle (EOL + obsolete + outdated).
1601
+ const maintRoot = majorSection(
1602
+ `3. Maintenance / lifecycle (${eolTotal} EOL, ${obsTotal} obsolete, ${outTotal} outdated)`,
1603
+ minorSection(`3.1 End-of-Life frameworks (${eolTotal})`, renderEolTable(eolResults), { id: "ch4", open: eolTotal > 0 }) +
1604
+ minorSection(`3.2 Obsolete / deprecated (${obsTotal})`, renderObsoleteTable(obsoleteResults), { id: "ch5", open: obsTotal > 0 }) +
1605
+ minorSection(`3.3 Outdated (${outTotal})`, renderOutdatedTable(outdatedResults), { id: "ch6", open: outTotal > 0 && outTotal <= 50 }),
1606
+ { id: "chmaint" });
1607
+
1608
+ // Root 4 — Licenses (standalone). Root 5 — Fix Recommendations (standalone).
1609
+ const licRoot = majorSection(`4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" });
1610
+ const fixRoot = majorSection(`5. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" });
1611
+
1612
+ // Root 6 — Scan context & limitations (scanned descriptors + ignored dirs + methodology).
1613
+ const scanRoot = majorSection(
1614
+ `6. Scan context & limitations`,
1615
+ minorSection(`6.1 Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { id: "chsrc", open: false }) +
1616
+ (excTotal ? minorSection(`6.2 Ignored directories (${excTotal})`, renderExcludedDirs(excludedDirs), { id: "chexcl", open: false }) : "") +
1617
+ minorSection(`6.3 Methodology, data sources & limitations`, renderMethodologyChapter(projectInfo), { id: "chmethod", open: false }),
1618
+ { id: "chscan", open: false });
1619
+
1620
+ // (C) Table of contents — sticky nav, hierarchical (roots + indented sub-chapters).
1621
+ const toc = renderToc([
1622
+ ...(warnings?.length ? [{ id: "ch0", label: "0. Warnings" }] : []),
1623
+ ...(diff && diff.summary ? [{ id: "chdiff", label: "Δ Baseline diff" }] : []),
1624
+ { id: "chcve", label: `1. CVE (${prodStats.total})`, sub: [
1625
+ { id: "ch1", label: `Prod (${prodStats.total})` },
1626
+ { id: "ch2", label: `Vendored JS (${retireMatches.length})` },
1627
+ { id: "ch3", label: `Dev (${devStats.total})` },
1628
+ ...(fpTotal ? [{ id: "ch9", label: `Likely FP (${fpTotal})` }] : []),
1629
+ ] },
1630
+ { id: "chbin", label: `2. Unmanaged components (${embTotal + unmTotal + vjsTotal})`, sub: [
1631
+ ...(embTotal ? [{ id: "ch1e", label: `Embedded (${embTotal})` }] : []),
1632
+ ...(unmTotal ? [{ id: "ch1c", label: `Native (${unmTotal})` }] : []),
1633
+ ...(vjsTotal ? [{ id: "ch1d", label: `Vendored JS (${vjsTotal})` }] : []),
1634
+ ] },
1635
+ { id: "chmaint", label: `3. Maintenance (${eolTotal + obsTotal + outTotal})`, sub: [
1636
+ { id: "ch4", label: `EOL (${eolTotal})` },
1637
+ { id: "ch5", label: `Obsolete (${obsTotal})` },
1638
+ { id: "ch6", label: `Outdated (${outTotal})` },
1639
+ ] },
1640
+ { id: "ch7", label: `4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` },
1641
+ { id: "ch8", label: `5. Fix Recos` },
1642
+ { id: "chscan", label: `6. Scan context`, sub: [
1643
+ { id: "chsrc", label: `Descriptors (${scanned.length})` },
1644
+ ...(excTotal ? [{ id: "chexcl", label: `Ignored dirs (${excTotal})` }] : []),
1645
+ { id: "chmethod", label: `Methodology` },
1646
+ ] },
1647
+ ]);
1572
1648
 
1573
1649
  // Overview charts row, rendered right under the compact totals.
1574
1650
  const chartsRow = renderCharts({ prodMatches: prodMatchesActive }, { interactive: wrapTables });
@@ -1589,20 +1665,13 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
1589
1665
  ${toolbar}
1590
1666
 
1591
1667
  ${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
1592
- ${majorSection(`1. CVE Vulnerabilities production (${prodStats.total})`, cveContent, { id: "ch1" })}
1593
- ${embeddedInventory.length ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embeddedInventory.length})`, embeddedContent, { open: embeddedInventory.length <= 50, id: "ch1e" }) : ""}
1594
- ${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
1595
- ${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
1596
- ${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
1597
- ${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
1598
- ${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
1599
- ${majorSection(`5. Obsolete / Deprecated Libraries (${obsoleteResults?.length || 0})`, renderObsoleteTable(obsoleteResults), { id: "ch5" })}
1600
- ${majorSection(`6. Outdated Libraries (${outdatedResults?.length || 0})`, renderOutdatedTable(outdatedResults), { open: (outdatedResults?.length || 0) <= 50, id: "ch6" })}
1601
- ${majorSection(`7. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" })}
1602
- ${majorSection(`8. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" })}
1603
- ${(prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length) ? majorSection(`9. Appendix: Likely false positives (CPE-filtered) (${prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length})`, fpContent, { open: false, id: "ch9" }) : ""}
1604
- ${majorSection(`10. Appendix: Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { open: false, id: "chsrc" })}
1605
- ${(excludedDirs?.length || 0) ? majorSection(`11. Appendix: Ignored directories (${excludedDirs.length})`, renderExcludedDirs(excludedDirs), { open: false, id: "chexcl" }) : ""}
1668
+ ${(diff && diff.summary) ? majorSection(`Δ Changes since baseline (+${diff.summary.cve.addedProduction} new prod CVE)`, renderDiffChapter(diff), { open: true, id: "chdiff" }) : ""}
1669
+ ${cveRoot}
1670
+ ${binRoot}
1671
+ ${maintRoot}
1672
+ ${licRoot}
1673
+ ${fixRoot}
1674
+ ${scanRoot}
1606
1675
  `;
1607
1676
  return wrapTables ? wrapTablesWithCopyButtons(body) : body;
1608
1677
  }
@@ -1666,24 +1735,15 @@ function pickTopEol(eolResults, n) {
1666
1735
  }).slice(0, n);
1667
1736
  }
1668
1737
 
1669
- function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal, scannedTotal, excludedTotal }) {
1670
- const entries = [];
1671
- if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
1672
- entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
1673
- if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
1674
- if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
1675
- if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
1676
- entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
1677
- entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
1678
- entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
1679
- entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
1680
- entries.push({ id: "ch6", label: `6. Outdated (${outdatedTotal})` });
1681
- entries.push({ id: "ch7", label: `7. Licenses (${licenseTotal || 0}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` });
1682
- entries.push({ id: "ch8", label: `8. Fix Recos` });
1683
- if (fpTotal) entries.push({ id: "ch9", label: `9. Likely FP (${fpTotal})` });
1684
- entries.push({ id: "chsrc", label: `10. Scanned descriptors (${scannedTotal || 0})` });
1685
- if (excludedTotal) entries.push({ id: "chexcl", label: `11. Ignored dirs (${excludedTotal})` });
1686
- return `<nav class="toc">${entries.map(e => `<a href="#${e.id}">${esc(e.label)}</a>`).join("")}</nav>`;
1738
+ // Hierarchical TOC. `entries` is an array of { id, label, sub?: [{id,label}] };
1739
+ // roots render as bold chips, their sub-chapters as smaller muted chips after them.
1740
+ function renderToc(entries) {
1741
+ const renderOne = e => {
1742
+ const root = `<a class="toc-root" href="#${esc(e.id)}">${esc(e.label)}</a>`;
1743
+ const subs = (e.sub || []).map(s => `<a class="toc-sub" href="#${esc(s.id)}">${esc(s.label)}</a>`).join("");
1744
+ return root + subs;
1745
+ };
1746
+ return `<nav class="toc">${(entries || []).map(renderOne).join("")}</nav>`;
1687
1747
  }
1688
1748
 
1689
1749
  // Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
@@ -1776,6 +1836,62 @@ function renderExcludedDirs(excludedDirs) {
1776
1836
  return intro + `<table><thead><tr><th>Directory (relative to --src)</th><th>Rule</th><th>Matched</th></tr></thead><tbody>${rows}</tbody></table>`;
1777
1837
  }
1778
1838
 
1839
+ // What fad-checker does NOT assess — stated explicitly so the audit's scope and
1840
+ // liability are unambiguous. Keep this list honest and in sync with the README.
1841
+ const LIMITATIONS = [
1842
+ "<strong>Reachability / exploitability in your app.</strong> Findings flag a vulnerable version on the dependency graph; they do <em>not</em> prove the vulnerable code path is actually called at runtime. Triage with your application context.",
1843
+ "<strong>Runtime configuration & mitigations.</strong> WAFs, feature flags, network isolation, disabled features, and other compensating controls are not modelled.",
1844
+ "<strong>Secrets, IaC & container base images.</strong> No secret scanning, infrastructure-as-code, or OS/base-image layer analysis — fad-checker scans declared & vendored application dependencies only.",
1845
+ "<strong>Business-logic & first-party code flaws.</strong> No SAST of your own source; only third-party components are assessed.",
1846
+ "<strong>Malware beyond the available signal.</strong> Supply-chain risk uses OSV <code>MAL-</code> advisories and the free CIRCL <code>KnownMalicious</code> flag; there is no antivirus/behavioural lane.",
1847
+ "<strong>License legal advice.</strong> License classification is informational SPDX categorisation, not a legal determination.",
1848
+ "<strong>Private / internal coordinates.</strong> Components absent from public registries can't be CVE-matched here; audit them against your internal feed.",
1849
+ "<strong>Data-source recency.</strong> Results reflect the cache state in the data-source table above; an <code>--offline</code> run uses exactly those snapshots.",
1850
+ ];
1851
+
1852
+ function renderMethodologyChapter(projectInfo) {
1853
+ const prov = projectInfo && projectInfo.provenance;
1854
+ const STATUS_PILL = { cached: ["cached", "#15803d"], disabled: ["disabled", "#6b7280"], missing: ["not warmed", "#b45309"] };
1855
+ let sourcesTable = `<div class="empty">No provenance manifest available for this run.</div>`;
1856
+ let cfgHtml = "";
1857
+ if (prov) {
1858
+ const rows = (prov.dataSources || []).map(s => {
1859
+ const [txt, bg] = STATUS_PILL[s.status] || [s.status, "#6b7280"];
1860
+ return `<tr><td>${esc(s.label)}</td><td>${pill(txt, bg)}</td><td>${esc(s.asOf || "—")}</td><td><span style="color:#6b7280">${esc(s.detail || "")}</span></td></tr>`;
1861
+ }).join("\n");
1862
+ sourcesTable = `<table><thead><tr><th>Data source</th><th>State</th><th>As of</th><th>Detail</th></tr></thead><tbody>${rows}</tbody></table>`;
1863
+ const c = prov.configuration || {};
1864
+ const rt = prov.runtime || {};
1865
+ const kv = (k, v) => `<span class="meta-kv"><strong>${esc(k)}:</strong> ${esc(String(v))}</span>`;
1866
+ cfgHtml = `<div class="fp-intro" style="margin-top:14px">
1867
+ ${kv("mode", prov.mode)} ${kv("ecosystems", c.ecosystems)} ${kv("transitive", c.transitive ? `on (depth ${c.transitiveDepth || "?"})` : "off")}
1868
+ ${kv("OSV", c.osv)} ${kv("NVD", c.nvd)} ${kv("EPSS", c.epss)} ${kv("KEV", c.kev)} ${kv("licenses", c.licenses)} ${kv("typosquat", c.typosquat)}
1869
+ ${kv("fail-on", c.failOn)}${c.ignoreFile ? " " + kv("ignore", c.ignoreFile) : ""}${c.vexFile ? " " + kv("vex", c.vexFile) : ""}
1870
+ ${kv("runtime", `${rt.node || "?"} · ${rt.platform || "?"}/${rt.arch || "?"}`)}
1871
+ </div>`;
1872
+ }
1873
+ const intro = `<div class="fp-intro">How this report was produced and what it does <strong>not</strong> cover — stated for audit transparency and reproducibility. The data-source table shows the freshness of every feed used; the configuration line records the run's findings-affecting options. An <code>--offline</code> re-run against the same cache (ship it with <code>--export-cache</code>) reproduces these findings.</div>`;
1874
+ const limitations = `<div class="defined-in" style="margin-top:14px"><span class="defined-label">Limitations — fad-checker does not assess:</span></div><ul class="methodology-limits">${LIMITATIONS.map(l => `<li>${l}</li>`).join("")}</ul>`;
1875
+ return intro + sourcesTable + cfgHtml + limitations;
1876
+ }
1877
+
1878
+ // Differential-audit chapter (vs --baseline): what changed since the prior findings JSON.
1879
+ function renderDiffChapter(diff) {
1880
+ if (!diff || !diff.summary) return "";
1881
+ const s = diff.summary;
1882
+ const sevTxt = Object.entries(s.cve.addedBySeverity || {}).filter(([, n]) => n).map(([k, n]) => `${n} ${k.toLowerCase()}`).join(", ") || "none";
1883
+ const intro = `<div class="fp-intro">Change since the baseline (<code>--baseline</code>). <strong>${s.cve.addedProduction}</strong> new production CVE finding(s) (${esc(sevTxt)}); ${s.cve.removed} resolved, ${s.cve.unchanged} unchanged.</div>`;
1884
+ const cat = (label, c) => `<tr><td>${esc(label)}</td><td style="color:#b91c1c">+${c.added}</td><td style="color:#15803d">−${c.removed}</td><td><span style="color:#6b7280">${c.unchanged}</span></td></tr>`;
1885
+ const table = `<table><thead><tr><th>Category</th><th>New</th><th>Resolved</th><th>Unchanged</th></tr></thead><tbody>
1886
+ ${cat("CVE", s.cve)}${cat("EOL", s.eol)}${cat("Obsolete", s.obsolete)}${cat("Outdated", s.outdated)}${cat("Licenses", s.licenses)}
1887
+ </tbody></table>`;
1888
+ const newCves = (diff.cve?.added || []).filter(f => !f.suppressed && !f.cpeFiltered);
1889
+ const list = newCves.length
1890
+ ? `<div class="defined-in" style="margin-top:10px"><span class="defined-label">New CVE findings:</span></div><ul>${newCves.slice(0, 50).map(f => `<li><code>${esc(f.id)}</code> · ${esc(f.severity || "")} · <code>${esc((f.dep && f.dep.coord) || "")}@${esc((f.dep && f.dep.version) || "")}</code></li>`).join("")}${newCves.length > 50 ? `<li>…and ${newCves.length - 50} more</li>` : ""}</ul>`
1891
+ : "";
1892
+ return intro + table + list;
1893
+ }
1894
+
1779
1895
  function renderFalsePositives(matches) {
1780
1896
  if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
1781
1897
  const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
@@ -2237,12 +2353,12 @@ ${WORD_SECTION_PR}
2237
2353
  // Render the HTML and/or Word report. Callers pass explicit target paths;
2238
2354
  // a null/omitted path skips that format (so `--report-html` alone writes only HTML).
2239
2355
  // Back-compat: `outputDir` still writes both under the default file names.
2240
- async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests }) {
2356
+ async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests, diff }) {
2241
2357
  if (outputDir) {
2242
2358
  if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
2243
2359
  if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
2244
2360
  }
2245
- const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests };
2361
+ const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests, diff };
2246
2362
  const written = { htmlPath: null, docPath: null };
2247
2363
  if (htmlPath) {
2248
2364
  await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
package/lib/diff.js ADDED
Binary file
@@ -30,6 +30,9 @@ function depBrief(dep) {
30
30
  provenance: dep.provenance || "manifest",
31
31
  purl: purlFor(dep),
32
32
  manifestPaths: dep.manifestPaths || dep.pomPaths || [],
33
+ // When the version was backfilled from an import/platform BOM, record which BOM
34
+ // supplied it — { via: "bom", bom: "g:a:v" } — so the finding is traceable.
35
+ versionSource: dep.versionSource || null,
33
36
  };
34
37
  }
35
38
 
@@ -68,7 +71,7 @@ function buildFindings(payload = {}) {
68
71
  const {
69
72
  cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
70
73
  outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
71
- typosquats = [], excludedDirs = [],
74
+ typosquats = [], excludedDirs = [], diff = null,
72
75
  } = payload;
73
76
 
74
77
  const { buildInventory } = require("./unmanaged");
@@ -91,6 +94,9 @@ function buildFindings(payload = {}) {
91
94
  tool: { name: "fad-checker", version: String(toolVersion) },
92
95
  generatedAt: projectInfo.generatedAt || null,
93
96
  project: { name: projectInfo.name || null, src: projectInfo.src || null },
97
+ // Scan-provenance manifest (data-source freshness + run configuration) for a
98
+ // reproducible/defensible audit. Null if the caller didn't supply one.
99
+ provenance: projectInfo.provenance || payload.provenance || null,
94
100
  summary: {
95
101
  dependencies: resolvedDeps?.size ?? null,
96
102
  cve: { ...sevCounts, kev, total: cveMatches.filter(m => !m.cpeFiltered && !m.suppressed).length },
@@ -118,6 +124,9 @@ function buildFindings(payload = {}) {
118
124
  vendoredJs: vendoredJsInventory,
119
125
  typosquat: typosquats,
120
126
  excludedDirs,
127
+ // Differential audit vs a --baseline document (summary + per-category deltas).
128
+ // Null when no baseline was provided.
129
+ diff: diff || null,
121
130
  };
122
131
  }
123
132
 
package/lib/maven-bom.js CHANGED
@@ -48,6 +48,12 @@ function collectImportBoms(propsByPom) {
48
48
  /**
49
49
  * Resolve each import BOM to its managed-version table and merge them into one map.
50
50
  * First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
51
+ *
52
+ * Each value is `{ version, bom }` where `bom` is the `groupId:artifactId:version` of the
53
+ * TOP-LEVEL platform/import BOM that supplied the version (e.g. spring-boot-dependencies),
54
+ * not the nested BOM it may import (spring-batch-bom) — that's the coordinate the user wrote
55
+ * in `platform(...)`/`<scope>import</scope>` and recognizes. Carried so backfillVersions can
56
+ * record per-dep provenance ("version managed by <bom>").
51
57
  */
52
58
  async function resolveBomManagedVersions(boms, opts = {}) {
53
59
  const effectivePom = opts.effectivePom || transitive.effectivePom;
@@ -57,11 +63,12 @@ async function resolveBomManagedVersions(boms, opts = {}) {
57
63
  try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
58
64
  catch { eff = null; }
59
65
  if (!eff || !eff.depMgmt) continue;
66
+ const bomCoord = `${bom.groupId}:${bom.artifactId}:${bom.version}`;
60
67
  for (const d of eff.depMgmt) {
61
68
  if (!d.groupId || !d.artifactId) continue;
62
69
  const k = `${d.groupId}:${d.artifactId}`;
63
70
  if (map.has(k)) continue;
64
- if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
71
+ if (d.version && !/\$\{/.test(String(d.version))) map.set(k, { version: String(d.version), bom: bomCoord });
65
72
  }
66
73
  }
67
74
  return map;
@@ -69,7 +76,10 @@ async function resolveBomManagedVersions(boms, opts = {}) {
69
76
 
70
77
  /**
71
78
  * Fill the version of every Maven dep that has no concrete version (null or `${…}`)
72
- * from the BOM-managed map. Mutates the resolvedDeps Map entries in place.
79
+ * from the BOM-managed map. Mutates the resolvedDeps Map entries in place. Each filled
80
+ * dep is stamped with `versionSource = { via: "bom", bom: "<coord>" }` so the report can
81
+ * disclose that the version came from a BOM (e.g. "version managed by spring-boot-dependencies")
82
+ * rather than the manifest it's declared in — the usual versionless Spring Boot / Gradle case.
73
83
  * @returns number of deps filled
74
84
  */
75
85
  function backfillVersions(resolvedDeps, mgmtMap) {
@@ -78,11 +88,13 @@ function backfillVersions(resolvedDeps, mgmtMap) {
78
88
  if (dep.ecosystem !== "maven") continue;
79
89
  if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
80
90
  if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
81
- const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
82
- if (!v) continue;
91
+ const entry = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
92
+ if (!entry) continue;
93
+ const v = entry.version;
83
94
  dep.version = v;
84
95
  if (!Array.isArray(dep.versions)) dep.versions = [];
85
96
  if (!dep.versions.includes(v)) dep.versions.push(v);
97
+ dep.versionSource = { via: "bom", bom: entry.bom };
86
98
  filled++;
87
99
  }
88
100
  return filled;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * lib/provenance.js — scan-provenance / reproducibility manifest.
3
+ *
4
+ * A professional audit must be defensible and reproducible: a finding has to be
5
+ * explainable from the exact inputs that produced it, and a second auditor should be
6
+ * able to reproduce it. This module builds a manifest of WHAT fad-checker ran with —
7
+ * tool version, runtime, run mode (offline/online), the run configuration (only the
8
+ * flags that change WHAT is found), and the freshness of every data source (read from
9
+ * its own cache under ~/.fad-checker/).
10
+ *
11
+ * The manifest reports the CACHE STATE of each source, not "online vs offline this
12
+ * run" — the cache state is the reproducible truth and is what an --offline re-run
13
+ * reads from. Pure given a cacheDir (injected for tests).
14
+ *
15
+ * @author: N.BRAUN
16
+ * @email: pp9ping@gmail.com
17
+ */
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const os = require("os");
21
+
22
+ const DEFAULT_CACHE_DIR = path.join(os.homedir(), ".fad-checker");
23
+
24
+ function readJson(file) {
25
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
26
+ }
27
+
28
+ function iso(ms) {
29
+ if (!ms || !Number.isFinite(Number(ms))) return null;
30
+ try { return new Date(Number(ms)).toISOString(); } catch { return null; }
31
+ }
32
+
33
+ // Each data source declares how to read its freshness from its cache file/dir.
34
+ // kind:
35
+ // "cve-meta" cve-data/meta.json → { builtAt, cveCount }
36
+ // "meta" <file>.json → { meta: { fetchedAt }, entries }
37
+ // "kev" kev-cache.json → { _fetchedAt, body: { catalogVersion, dateReleased } }
38
+ // "dir" <dir>/ → file count + newest mtime
39
+ const SOURCE_DEFS = [
40
+ { id: "cve", label: "CVEProject (Maven index)", rel: "cve-data/meta.json", kind: "cve-meta" },
41
+ { id: "osv", label: "OSV.dev", rel: "osv-cache", kind: "dir", flag: "osv" },
42
+ { id: "osvDb", label: "OSV local DB (Maven)", rel: "osv-db/maven-index.json", kind: "mtime" },
43
+ { id: "nvd", label: "NIST NVD", rel: "nvd-cache", kind: "dir", flag: "nvd" },
44
+ { id: "epss", label: "EPSS (FIRST.org)", rel: "epss-cache.json", kind: "meta", flag: "epss" },
45
+ { id: "kev", label: "CISA KEV", rel: "kev-cache.json", kind: "kev", flag: "kev" },
46
+ { id: "eol", label: "endoflife.date", rel: "eol-cache.json", kind: "meta" },
47
+ { id: "mavenCentral", label: "Maven Central (latest)", rel: "version-cache.json", kind: "meta" },
48
+ { id: "npm", label: "npm registry", rel: "npm-registry-cache.json", kind: "meta" },
49
+ { id: "nuget", label: "NuGet registry", rel: "nuget-cache.json", kind: "meta" },
50
+ { id: "packagist", label: "Packagist", rel: "packagist-cache.json", kind: "meta" },
51
+ { id: "go", label: "Go module proxy", rel: "go-proxy-cache.json", kind: "meta" },
52
+ { id: "rubygems", label: "RubyGems", rel: "rubygems-cache.json", kind: "meta" },
53
+ ];
54
+
55
+ // Read one source's freshness → { status, asOf, detail }.
56
+ function readSource(def, cacheDir) {
57
+ const target = path.join(cacheDir, def.rel);
58
+ if (def.kind === "dir") {
59
+ let files; try { files = fs.readdirSync(target).filter(f => f.endsWith(".json")); } catch { return { status: "missing", asOf: null, detail: null }; }
60
+ if (!files.length) return { status: "missing", asOf: null, detail: null };
61
+ let newest = 0;
62
+ for (const f of files) { try { const m = fs.statSync(path.join(target, f)).mtimeMs; if (m > newest) newest = m; } catch { /* skip */ } }
63
+ return { status: "cached", asOf: iso(newest), detail: `${files.length} cached` };
64
+ }
65
+ if (def.kind === "mtime") {
66
+ let st; try { st = fs.statSync(target); } catch { return { status: "missing", asOf: null, detail: null }; }
67
+ return { status: "cached", asOf: iso(st.mtimeMs), detail: null };
68
+ }
69
+ const data = readJson(target);
70
+ if (!data) return { status: "missing", asOf: null, detail: null };
71
+ if (def.kind === "cve-meta") {
72
+ return { status: "cached", asOf: data.builtAt || null, detail: data.cveCount != null ? `${data.cveCount} CVEs` : null };
73
+ }
74
+ if (def.kind === "kev") {
75
+ const body = data.body || {};
76
+ const ver = body.catalogVersion || body.dateReleased || null;
77
+ return { status: "cached", asOf: iso(data._fetchedAt), detail: ver ? `catalog ${ver}` : null };
78
+ }
79
+ // "meta": { meta: { fetchedAt }, entries }
80
+ const n = data.entries && typeof data.entries === "object" ? Object.keys(data.entries).length : null;
81
+ return { status: "cached", asOf: iso(data.meta && data.meta.fetchedAt), detail: n != null ? `${n} entries` : null };
82
+ }
83
+
84
+ /**
85
+ * Inspect the cache directory → an array of data-source descriptors:
86
+ * { id, label, status: "disabled"|"missing"|"cached", asOf, detail }
87
+ * A source whose feature flag is turned off this run is "disabled".
88
+ */
89
+ function collectDataSources({ cacheDir = DEFAULT_CACHE_DIR, options = {} } = {}) {
90
+ return SOURCE_DEFS.map(def => {
91
+ if (def.flag && options[def.flag] === false) {
92
+ return { id: def.id, label: def.label, status: "disabled", asOf: null, detail: null };
93
+ }
94
+ const r = readSource(def, cacheDir);
95
+ return { id: def.id, label: def.label, ...r };
96
+ });
97
+ }
98
+
99
+ // Only the options that change WHAT is found belong in the reproducibility record.
100
+ function runConfiguration(options = {}) {
101
+ return {
102
+ ecosystems: options.ecosystem || "auto",
103
+ transitive: options.transitive !== false,
104
+ transitiveDepth: options.transitiveDepth != null ? String(options.transitiveDepth) : null,
105
+ osv: options.osv !== false,
106
+ nvd: options.nvd !== false,
107
+ epss: options.epss !== false,
108
+ kev: options.kev !== false,
109
+ allLibs: options.allLibs !== false,
110
+ licenses: !!options.licenses,
111
+ typosquat: !!options.typosquat,
112
+ failOn: options.failOn || "none",
113
+ ignoreFile: options.ignore || null,
114
+ vexFile: options.vex || null,
115
+ excludePath: options.excludePath || [],
116
+ defaultExcludes: options.defaultExcludes !== false,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Assemble the full scan-provenance manifest. Pure given cacheDir + runtime.
122
+ */
123
+ function buildScanProvenance({ toolVersion, generatedAt, options = {}, cacheDir = DEFAULT_CACHE_DIR, runtime } = {}) {
124
+ const rt = runtime || { node: process.version, platform: process.platform, arch: process.arch };
125
+ return {
126
+ tool: { name: "fad-checker", version: String(toolVersion || "0") },
127
+ generatedAt: generatedAt || null,
128
+ mode: options.offline ? "offline" : "online",
129
+ runtime: { node: rt.node, platform: rt.platform, arch: rt.arch },
130
+ configuration: runConfiguration(options),
131
+ dataSources: collectDataSources({ cacheDir, options }),
132
+ };
133
+ }
134
+
135
+ module.exports = { buildScanProvenance, collectDataSources, runConfiguration, DEFAULT_CACHE_DIR };
package/lib/registries.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * @author: N.BRAUN
13
13
  * @email: pp9ping@gmail.com
14
14
  */
15
- const SUPPORTED = ["maven", "npm", "pypi", "ruby", "go"];
15
+ const SUPPORTED = ["maven", "npm", "pypi", "ruby", "go", "nuget", "composer"];
16
16
 
17
17
  const PUBLIC_BASES = {
18
18
  maven: "https://repo1.maven.org/maven2/",
@@ -20,6 +20,11 @@ const PUBLIC_BASES = {
20
20
  pypi: "https://pypi.org/pypi/",
21
21
  ruby: "https://rubygems.org/api/v1/gems/",
22
22
  go: "https://proxy.golang.org/",
23
+ // NuGet: the public v3 registration base (each fetch appends "<lowerid>/index.json").
24
+ nuget: "https://api.nuget.org/v3/registration5-gz-semver2/",
25
+ // Composer: the Packagist root (the public path is "packages/<name>.json"; custom
26
+ // feeds are queried via the v2 "p2/<name>.json" metadata API — see composer/registry.js).
27
+ composer: "https://packagist.org/",
23
28
  };
24
29
 
25
30
  function normalise(url) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * lib/report-integrity.js — tamper-evidence for the report deliverable.
3
+ *
4
+ * An audit handed to a client should be verifiable as unaltered. This writes a
5
+ * standard `sha256sum`-format manifest (`<hex>␠␠<relative-path>`) over every artifact
6
+ * produced by a run, verifiable offline with `sha256sum -c SHA256SUMS`. Checksums
7
+ * (not signatures) keep the CLI zero-config; signing the manifest with the auditor's
8
+ * own key is then a trivial external step.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const crypto = require("crypto");
16
+
17
+ /** SHA-256 of a file's bytes, as lowercase hex. */
18
+ function sha256OfFile(filePath) {
19
+ const buf = fs.readFileSync(filePath);
20
+ return crypto.createHash("sha256").update(buf).digest("hex");
21
+ }
22
+
23
+ function relName(file, baseDir) {
24
+ if (!baseDir) return path.basename(file);
25
+ const rel = path.relative(baseDir, file);
26
+ return rel && !rel.startsWith("..") ? rel : path.basename(file);
27
+ }
28
+
29
+ /**
30
+ * Build a sha256sum-format manifest over `files` (existing ones only), sorted by
31
+ * relative name for a deterministic, diffable output.
32
+ */
33
+ function buildChecksumManifest(files, { baseDir } = {}) {
34
+ const rows = [];
35
+ for (const f of files || []) {
36
+ if (!f) continue;
37
+ let hex;
38
+ try { hex = sha256OfFile(f); } catch { continue; } // missing/unreadable → skip
39
+ rows.push({ name: relName(f, baseDir), hex });
40
+ }
41
+ rows.sort((a, b) => a.name.localeCompare(b.name));
42
+ return rows.map(r => `${r.hex} ${r.name}`).join("\n") + (rows.length ? "\n" : "");
43
+ }
44
+
45
+ /** Write the manifest to `manifestPath`; returns the manifest text. */
46
+ function writeChecksums(files, manifestPath, { baseDir } = {}) {
47
+ const text = buildChecksumManifest(files, { baseDir: baseDir || path.dirname(manifestPath) });
48
+ fs.writeFileSync(manifestPath, text);
49
+ return text;
50
+ }
51
+
52
+ module.exports = { sha256OfFile, buildChecksumManifest, writeChecksums };
package/lib/snyk.js CHANGED
@@ -28,13 +28,46 @@ async function runSnykTest(outputDir, opts = {}) {
28
28
  });
29
29
  return parseSnykStdout(stdout);
30
30
  } catch (err) {
31
- // snyk exits 1 when vulns are found — stdout still contains the JSON
32
- if (err.stdout) return parseSnykStdout(err.stdout);
33
31
  if (err.code === "ENOENT") throw new Error("snyk CLI not found on PATH — install snyk separately");
34
- throw err;
32
+ if (err.killed) throw new Error(`snyk timed out after ${Math.round(timeoutMs / 1000)}s (raise opts.timeoutMs)`);
33
+ // snyk exits 1 when vulnerabilities are found — stdout holds the real findings
34
+ // JSON, so that's NOT a failure. BUT exit 2 (command error, e.g. auth) and
35
+ // exit 3 (no supported projects) ALSO write JSON to stdout, shaped
36
+ // { ok:false, error:"…" }. Returning that as-is makes parseSnykResults read it
37
+ // as "0 vulnerabilities" → a silent success that hides a real snyk failure.
38
+ if (err.stdout) {
39
+ const parsed = parseSnykStdout(err.stdout);
40
+ const snykErr = snykOutputError(parsed);
41
+ if (snykErr) throw new Error(`snyk failed (exit ${err.code}): ${snykErr}`);
42
+ return parsed;
43
+ }
44
+ // No JSON on stdout at all → surface stderr (the human-readable reason) rather
45
+ // than execFile's generic "Command failed: snyk test…".
46
+ const detail = (err.stderr && String(err.stderr).trim()) || err.message;
47
+ throw new Error(`snyk failed (exit ${err.code}): ${detail}`);
35
48
  }
36
49
  }
37
50
 
51
+ /**
52
+ * Inspect parsed `snyk --json` output and return a human-readable error string when
53
+ * snyk actually FAILED — i.e. every project is an error stub ({ ok:false, error }) and
54
+ * none carries usable results — or `null` when the output holds real findings.
55
+ *
56
+ * Snyk emits this error-shaped JSON on stdout for exit 2 (command error, e.g. auth) and
57
+ * exit 3 (no supported projects). Without this check those are read as "0 vulnerabilities",
58
+ * silently hiding the failure behind a green "Snyk: 0 findings merged".
59
+ */
60
+ function snykOutputError(parsed) {
61
+ const projects = Array.isArray(parsed) ? parsed : (parsed != null ? [parsed] : []);
62
+ if (!projects.length) return null;
63
+ // A usable project either lists vulnerabilities (exit 1) or reports ok:true (clean).
64
+ const usable = projects.some(p => p && (Array.isArray(p.vulnerabilities) || p.ok === true));
65
+ if (usable) return null;
66
+ const errored = projects.filter(p => p && p.ok === false && p.error);
67
+ if (!errored.length) return null;
68
+ return [...new Set(errored.map(p => String(p.error).trim()).filter(Boolean))].join("; ");
69
+ }
70
+
38
71
  function parseSnykStdout(stdout) {
39
72
  if (!stdout) return [];
40
73
  const str = String(stdout).trim();
@@ -126,5 +159,6 @@ module.exports = {
126
159
  runSnykTest,
127
160
  parseSnykResults,
128
161
  parseSnykStdout,
162
+ snykOutputError,
129
163
  mergeWithFadResults,
130
164
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
4
4
  "description": "Scan ALL Maven, Gradle, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",