fad-checker 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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
@@ -32,6 +69,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
32
69
  across every ecosystem. Repeatable; also `excludePath: [...]` in `.fad-env.json`,
33
70
  unioned across config layers. **`--no-default-excludes`** walks the normally
34
71
  pruned dirs (`node_modules`, `vendor`, `target`, `.git`, …). New `lib/path-filter.js`.
72
+ - **Ignored-directories appendix (report chapter 11 + JSON `excludedDirs`).** The
73
+ HTML/`.doc` report now ends with an appendix listing the ACTUAL directories the
74
+ scan did not walk — resolved by re-walking `--src` once under the same prune
75
+ policy the codecs use (the default-exclude set at any depth + your
76
+ `--exclude-path` rules), each path shown relative to the scan root and tagged
77
+ with the rule that pruned it (`default` vs `--exclude-path`). Surfaced in the
78
+ findings JSON as `excludedDirs[]` + `summary.excludedDirs`. New
79
+ `collectExcludedDirs()` in `lib/path-filter.js`.
35
80
 
36
81
  ### Changed
37
82
  - **BREAKING:** the persisted-registry store moved from the Maven-only
@@ -40,6 +85,37 @@ This project adheres to [Semantic Versioning](https://semver.org/).
40
85
  the `<ecosystem>=<url>` form (a bare URL is rejected). Re-add any private Maven
41
86
  repos with `--add-repo maven <name> <url>`.
42
87
 
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).
100
+ - **retire.js now skips the same dirs as the rest of the scan.** The vendored-JS
101
+ scan walks the tree itself and was handed a bare `--ignore node_modules,…` list,
102
+ which retire `path.resolve()`s against its **own working directory** — so a
103
+ `node_modules` (or `target`/`dist`/…) nested anywhere under `--src` was scanned
104
+ whenever fad-checker ran from a different directory than the source tree. retire
105
+ is now driven by a generated `--ignorefile` anchored to `--src` that prunes the
106
+ default SKIP dirs **at any depth** and honors `--exclude-path` /
107
+ `--no-default-excludes`, matching `lib/path-filter.js`.
108
+ - **Offline NVD enrichment (incl. CWEs) no longer silently dropped.** The NVD cache
109
+ enforces a 7-day TTL and a schema version; offline, a TTL-expired or older-schema
110
+ entry was treated as a miss — and since an air-gapped box can't re-fetch, the CVE
111
+ lost **all** its NVD enrichment (CWE list, CVSS vector, references, CPE configs).
112
+ That was the "offline scan was missing some CWE titles that the online scan had".
113
+ Offline now reads the warmed cache regardless of age/schema (a missing field just
114
+ stays missing — strictly better than dropping everything); online still enforces
115
+ TTL + schema so it re-fetches and upgrades. CWE IDs were already persisted in the
116
+ cache body (`_schema:2`) and travel in `--export-cache`; CWE *titles* come from the
117
+ bundled `data/cwe-names.json` (identical online/offline).
118
+
43
119
  ## [2.1.0]
44
120
 
45
121
  ### Added
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)")
@@ -495,7 +540,7 @@ async function timedPhase(label, fn) {
495
540
  // (still honor an explicit --report-<type> if a user really wants a path-free one).
496
541
  const anyReportRequested = [options.reportHtml, options.reportDoc, options.reportSbom, options.reportCsaf, options.reportJson, options.reportSarif].some(v => v !== undefined);
497
542
  if (!anyReportRequested) options.report = false;
498
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [] });
543
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds: [], mavenRepos, regMap, collectWarnings: [], walkOpts });
499
544
  return;
500
545
  }
501
546
 
@@ -716,7 +761,7 @@ async function timedPhase(label, fn) {
716
761
  // The scan always runs — it feeds the terminal summary, the file outputs and the
717
762
  // CI gate. Which files get written is decided by the --report-* family inside
718
763
  // (HTML + .doc by default; --no-report writes nothing).
719
- await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests });
764
+ await runReportFlow(resolved, { activeIds, runMaven, runGradle, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store, gradlePlatformBoms: gradleCtx?.platformBoms || [], parsedManifests, walkOpts });
720
765
  if (!readOnly) {
721
766
  ui.section("Next step");
722
767
  ui.info(`run Snyk on the cleaned tree:`);
@@ -725,7 +770,8 @@ async function timedPhase(label, fn) {
725
770
  })();
726
771
 
727
772
  async function runReportFlow(resolved, ecoFlags = {}) {
728
- const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [] } = ecoFlags;
773
+ const { activeIds = [], runMaven = true, runGradle = false, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null, gradlePlatformBoms = [], parsedManifests = [], walkOpts = {} } = ecoFlags;
774
+ const { excludePath = [], defaultExcludes = true } = walkOpts;
729
775
  const registriesFor = eco => regMap[eco] || [];
730
776
  const { expandWithTransitives } = require("./lib/cve-match");
731
777
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -1045,7 +1091,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1045
1091
  else if (!options.src) { st.skip("no source tree (descriptor import)"); }
1046
1092
  else {
1047
1093
  try {
1048
- const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
1094
+ const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline, excludePath, defaultExcludes });
1049
1095
  retireMatches = r.matches || [];
1050
1096
  if (options.vendoredJsInventory !== false) vendoredJsInventory = r.meta?.inventory || [];
1051
1097
  const invN = vendoredJsInventory.length;
@@ -1253,6 +1299,13 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1253
1299
  toolVersion: pkg.version,
1254
1300
  cveDataDate,
1255
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}`); }
1256
1309
 
1257
1310
  // --- Output target resolution -------------------------------------------------
1258
1311
  // One --report-<type> flag per output, each taking an OPTIONAL path: a string is
@@ -1273,6 +1326,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1273
1326
  : { html: resolveOut("html"), doc: resolveOut("doc"), sbom: resolveOut("sbom"), csaf: resolveOut("csaf"), json: resolveOut("json"), sarif: resolveOut("sarif") };
1274
1327
  const ensureDir = async p => { if (p) await fs.promises.mkdir(path.dirname(path.resolve(p)), { recursive: true }); };
1275
1328
 
1329
+ // Ignored-directories appendix: re-walk --src ONCE under the same prune policy the
1330
+ // codec walkers use (the default-exclude union at any depth + --exclude-path,
1331
+ // anchored to --src) so the report can list exactly which directories the scan
1332
+ // skipped, and why. Only computed when an output that renders it is requested.
1333
+ let excludedDirs = [];
1334
+ if (options.src && (out.html || out.doc || out.json)) {
1335
+ try {
1336
+ const { collectExcludedDirs } = require("./lib/path-filter");
1337
+ excludedDirs = collectExcludedDirs({ srcRoot: options.src, excludePath, defaultExcludes });
1338
+ } catch { /* best effort — never block the report on the appendix walk */ }
1339
+ }
1340
+
1276
1341
  const reportWarnings = [
1277
1342
  ...(suppressedCount ? [{
1278
1343
  type: "suppressed",
@@ -1294,13 +1359,29 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1294
1359
  }] : []),
1295
1360
  ];
1296
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
+
1297
1378
  const wrote = [];
1298
1379
  if (out.html || out.doc) {
1299
1380
  await ensureDir(out.html); await ensureDir(out.doc);
1300
1381
  const { htmlPath, docPath } = await writeReports({
1301
1382
  cveMatches: prodMatches, devCveMatches: devMatches, embeddedMatches, retireMatches, vendoredJsInventory,
1302
- eolResults, obsoleteResults, outdatedResults, licenseResults,
1303
- resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests,
1383
+ eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs,
1384
+ resolvedDeps: resolved, projectInfo, warnings: reportWarnings, parsedManifests, diff,
1304
1385
  htmlPath: out.html, docPath: out.doc,
1305
1386
  });
1306
1387
  if (htmlPath) wrote.push(["HTML report", htmlPath]);
@@ -1329,7 +1410,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1329
1410
  try {
1330
1411
  const { writeFindings } = require("./lib/json-export");
1331
1412
  await ensureDir(out.json);
1332
- writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, 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);
1333
1414
  wrote.push(["Findings JSON", out.json]);
1334
1415
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1335
1416
  }
@@ -1342,6 +1423,20 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1342
1423
  } catch (err) { ui.warn(`SARIF export failed: ${err.message}`); }
1343
1424
  }
1344
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
+
1345
1440
  if (wrote.length) {
1346
1441
  ui.section("Output");
1347
1442
  for (const [label, p] of wrote) ui.ok(`${label} → ${chalk.white(p)}`);
@@ -1352,21 +1447,36 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1352
1447
  }
1353
1448
  console.log();
1354
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
+
1355
1460
  // CI gating — set a non-zero exit code (after all reports/exports are written)
1356
- // when a production finding meets the --fail-on threshold.
1357
- 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) {
1358
1465
  const { evaluateGate } = require("./lib/gate");
1359
1466
  // Embedded-binary findings are real production risk → gate on them too.
1360
- 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: "" };
1361
1470
  // A known-malicious package always blocks, regardless of the --fail-on level.
1362
1471
  const maliciousActive = [...prodActive, ...embeddedActive].filter(m => m.malicious);
1363
- if (gate.failed || maliciousActive.length) {
1472
+ if (gate.failed || maliciousActive.length || newProd > 0) {
1364
1473
  ui.section("Gate");
1365
1474
  if (maliciousActive.length) console.log(chalk.red.bold(`✗ ${maliciousActive.length} known-malicious package(s) detected — always blocks`));
1366
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`));
1367
1477
  process.exitCode = 1;
1368
1478
  } else if (verbose) {
1369
- ui.info(chalk.dim(`--fail-on ${options.failOn}: no blocking finding`));
1479
+ ui.info(chalk.dim(`gate: no blocking finding`));
1370
1480
  }
1371
1481
  }
1372
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 };
@@ -21,7 +21,7 @@ const retireScanner = {
21
21
  kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
22
22
  async scan(_deps, opts = {}) {
23
23
  const { scanWithRetireFull } = require("../retire");
24
- const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
24
+ const { matches, inventory, error } = await scanWithRetireFull(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline, excludePath: opts.excludePath, defaultExcludes: opts.defaultExcludes });
25
25
  return { matches, meta: { inventory, error } };
26
26
  },
27
27
  };
@@ -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 };