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 +76 -0
- package/README.md +15 -14
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +3 -0
- package/fad-checker.js +122 -12
- package/lib/codecs/composer/registry.js +38 -6
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget/registry.js +49 -10
- package/lib/cve-report.js +182 -58
- package/lib/diff.js +0 -0
- package/lib/json-export.js +9 -1
- package/lib/nvd.js +14 -3
- package/lib/path-filter.js +85 -6
- package/lib/provenance.js +135 -0
- package/lib/registries.js +6 -1
- package/lib/report-integrity.js +52 -0
- package/lib/retire.js +77 -15
- package/lib/snyk.js +37 -3
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
|
59
|
-
| **
|
|
60
|
-
| **
|
|
61
|
-
| **
|
|
62
|
-
| **
|
|
63
|
-
| **
|
|
64
|
-
| **
|
|
65
|
-
| **
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 };
|
package/lib/codecs/npm.codec.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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 };
|