fad-checker 2.3.2 → 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 +48 -0
- package/README.md +15 -14
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +3 -0
- package/fad-checker.js +104 -7
- package/lib/codecs/composer/registry.js +38 -6
- package/lib/codecs/nuget/registry.js +49 -10
- package/lib/cve-report.js +168 -61
- package/lib/diff.js +0 -0
- package/lib/json-export.js +7 -1
- package/lib/provenance.js +135 -0
- package/lib/registries.js +6 -1
- package/lib/report-integrity.js +52 -0
- 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
|
|
@@ -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
|
-
- **
|
|
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)")
|
|
@@ -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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 };
|
|
@@ -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 };
|
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; }
|
|
@@ -1170,18 +1173,23 @@ function attachRootUpdates(cveMatches, outdatedResults, resolvedDeps) {
|
|
|
1170
1173
|
}
|
|
1171
1174
|
}
|
|
1172
1175
|
|
|
1176
|
+
// Every chapter / sub-chapter renders EXPANDED by default (the user wants the whole
|
|
1177
|
+
// report open on load). The `open` opt is accepted for call-site compatibility but
|
|
1178
|
+
// intentionally ignored — collapse is still available via the toolbar / clicking a
|
|
1179
|
+
// header. (Per-CVE detail rows are NOT <details>, so they stay collapsed-by-default.)
|
|
1173
1180
|
function majorSection(title, contentHtml, opts = {}) {
|
|
1174
|
-
const {
|
|
1181
|
+
const { id } = opts;
|
|
1175
1182
|
const idAttr = id ? ` id="${esc(id)}"` : "";
|
|
1176
|
-
return `<details class="report-section"
|
|
1183
|
+
return `<details class="report-section" open${idAttr}>
|
|
1177
1184
|
<summary><h2>${title}</h2></summary>
|
|
1178
1185
|
<div>${contentHtml}</div>
|
|
1179
1186
|
</details>`;
|
|
1180
1187
|
}
|
|
1181
1188
|
|
|
1182
1189
|
function minorSection(title, contentHtml, opts = {}) {
|
|
1183
|
-
const {
|
|
1184
|
-
|
|
1190
|
+
const { id } = opts;
|
|
1191
|
+
const idAttr = id ? ` id="${esc(id)}"` : "";
|
|
1192
|
+
return `<details class="report-subsection" open${idAttr}>
|
|
1185
1193
|
<summary><h3>${title}</h3></summary>
|
|
1186
1194
|
<div>${contentHtml}</div>
|
|
1187
1195
|
</details>`;
|
|
@@ -1485,7 +1493,7 @@ function renderLicenseChapter(licenseResults) {
|
|
|
1485
1493
|
return intro + blocks;
|
|
1486
1494
|
}
|
|
1487
1495
|
|
|
1488
|
-
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], wrapTables = true }) {
|
|
1496
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs = [], resolvedDeps, projectInfo, warnings, parsedManifests = [], diff = null, wrapTables = true }) {
|
|
1489
1497
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1490
1498
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1491
1499
|
cveMatches = mergeMatches(cveMatches || []);
|
|
@@ -1513,9 +1521,9 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1513
1521
|
// Recos are built from prod matches only (dev/test issues aren't a release blocker).
|
|
1514
1522
|
const recos = buildFixRecommendations(prodMatchesActive, outdatedResults, resolvedDeps);
|
|
1515
1523
|
|
|
1516
|
-
const cveContent = renderCveBySectionByEco(prodMatchesActive, "1", projectInfo?.src);
|
|
1524
|
+
const cveContent = renderCveBySectionByEco(prodMatchesActive, "1.1", projectInfo?.src);
|
|
1517
1525
|
const vendoredContent = renderRetireTable(retireMatches);
|
|
1518
|
-
const devCveContent = renderCveBySectionByEco(devMatchesActive, "3", projectInfo?.src);
|
|
1526
|
+
const devCveContent = renderCveBySectionByEco(devMatchesActive, "1.3", projectInfo?.src);
|
|
1519
1527
|
const licenseContent = renderLicenseChapter(licenseResults);
|
|
1520
1528
|
const licenseTotal = licenseResults?.assessed?.length || 0;
|
|
1521
1529
|
const licenseFlagged = licenseResults?.flagged?.length || 0;
|
|
@@ -1548,27 +1556,86 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1548
1556
|
interactive: wrapTables, // copy button + scripts only in the HTML output
|
|
1549
1557
|
});
|
|
1550
1558
|
|
|
1551
|
-
//
|
|
1552
|
-
// chapters that actually have content (warnings, dev CVE, retire, FP appendix
|
|
1553
|
-
// might be empty).
|
|
1559
|
+
// ---- Chapters are grouped under "root" chapters (a two-level hierarchy) ----
|
|
1554
1560
|
const scanned = buildScannedManifests(resolvedDeps, projectInfo?.src, parsedManifests);
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1561
|
+
const fpTotal = prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length;
|
|
1562
|
+
const eolTotal = eolResults?.length || 0;
|
|
1563
|
+
const obsTotal = obsoleteResults?.length || 0;
|
|
1564
|
+
const outTotal = outdatedResults?.length || 0;
|
|
1565
|
+
const embTotal = embeddedInventory.length;
|
|
1566
|
+
const unmTotal = unmanagedInventory.length;
|
|
1567
|
+
const vjsTotal = vendoredJsInv.length;
|
|
1568
|
+
const excTotal = excludedDirs?.length || 0;
|
|
1569
|
+
// CVE root header breakdown: direct vs indirect (transitive) production vulns + the dev count.
|
|
1570
|
+
const prodDirect = prodMatchesActive.filter(m => m.dep?.scope !== "transitive").length;
|
|
1571
|
+
const prodIndirect = prodMatchesActive.filter(m => m.dep?.scope === "transitive").length;
|
|
1572
|
+
|
|
1573
|
+
// Root 1 — CVE (production + vendored-JS vulns + dev + likely false positives).
|
|
1574
|
+
const cveRoot = majorSection(
|
|
1575
|
+
`1. CVE (${prodDirect} direct, ${prodIndirect} indirect, ${devStats.total} dev)`,
|
|
1576
|
+
minorSection(`1.1 Production (${prodStats.total})`, cveContent, { id: "ch1", open: true }) +
|
|
1577
|
+
minorSection(`1.2 Vendored JS vulns — retire.js (${retireMatches.length})`, vendoredContent, { id: "ch2", open: retireMatches.length > 0 && retireMatches.length <= 50 }) +
|
|
1578
|
+
minorSection(`1.3 Dev dependencies (${devStats.total})`, devCveContent, { id: "ch3", open: devStats.total > 0 && devStats.total <= 50 }) +
|
|
1579
|
+
(fpTotal ? minorSection(`1.4 Likely false positives — CPE-filtered (${fpTotal})`, fpContent, { id: "ch9", open: false }) : ""),
|
|
1580
|
+
{ id: "chcve" });
|
|
1581
|
+
|
|
1582
|
+
// Root 2 — Unmanaged / unversioned components (binaries + vendored JS, no package manager).
|
|
1583
|
+
const binRoot = majorSection(
|
|
1584
|
+
`2. Unmanaged / unversioned components (${embTotal} embedded, ${unmTotal} native, ${vjsTotal} vendored JS)`,
|
|
1585
|
+
(embTotal ? minorSection(`2.1 Embedded binaries — JAR/WAR/EAR (${embTotal})`, embeddedContent, { id: "ch1e", open: embTotal <= 50 }) : "") +
|
|
1586
|
+
(unmTotal ? minorSection(`2.2 Unmanaged / vendored native binaries (${unmTotal})`, unmanagedContent, { id: "ch1c", open: unmTotal <= 50 }) : "") +
|
|
1587
|
+
(vjsTotal ? minorSection(`2.3 Unmanaged / vendored JavaScript (${vjsTotal})`, vendoredJsContent, { id: "ch1d", open: vjsTotal <= 50 }) : "") ||
|
|
1588
|
+
`<div class="empty">No unmanaged binaries or vendored JavaScript found.</div>`,
|
|
1589
|
+
{ id: "chbin", open: (embTotal + unmTotal + vjsTotal) > 0 });
|
|
1590
|
+
|
|
1591
|
+
// Root 3 — Maintenance / lifecycle (EOL + obsolete + outdated).
|
|
1592
|
+
const maintRoot = majorSection(
|
|
1593
|
+
`3. Maintenance / lifecycle (${eolTotal} EOL, ${obsTotal} obsolete, ${outTotal} outdated)`,
|
|
1594
|
+
minorSection(`3.1 End-of-Life frameworks (${eolTotal})`, renderEolTable(eolResults), { id: "ch4", open: eolTotal > 0 }) +
|
|
1595
|
+
minorSection(`3.2 Obsolete / deprecated (${obsTotal})`, renderObsoleteTable(obsoleteResults), { id: "ch5", open: obsTotal > 0 }) +
|
|
1596
|
+
minorSection(`3.3 Outdated (${outTotal})`, renderOutdatedTable(outdatedResults), { id: "ch6", open: outTotal > 0 && outTotal <= 50 }),
|
|
1597
|
+
{ id: "chmaint" });
|
|
1598
|
+
|
|
1599
|
+
// Root 4 — Licenses (standalone). Root 5 — Fix Recommendations (standalone).
|
|
1600
|
+
const licRoot = majorSection(`4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged} to review` : ""})`, licenseContent, { open: licenseFlagged > 0, id: "ch7" });
|
|
1601
|
+
const fixRoot = majorSection(`5. Fix Recommendations`, renderFixRecommendations(recos, prodMatchesActive, outdatedResults), { open: false, id: "ch8" });
|
|
1602
|
+
|
|
1603
|
+
// Root 6 — Scan context & limitations (scanned descriptors + ignored dirs + methodology).
|
|
1604
|
+
const scanRoot = majorSection(
|
|
1605
|
+
`6. Scan context & limitations`,
|
|
1606
|
+
minorSection(`6.1 Scanned dependency descriptors (${scanned.length})`, renderScannedManifests(scanned), { id: "chsrc", open: false }) +
|
|
1607
|
+
(excTotal ? minorSection(`6.2 Ignored directories (${excTotal})`, renderExcludedDirs(excludedDirs), { id: "chexcl", open: false }) : "") +
|
|
1608
|
+
minorSection(`6.3 Methodology, data sources & limitations`, renderMethodologyChapter(projectInfo), { id: "chmethod", open: false }),
|
|
1609
|
+
{ id: "chscan", open: false });
|
|
1610
|
+
|
|
1611
|
+
// (C) Table of contents — sticky nav, hierarchical (roots + indented sub-chapters).
|
|
1612
|
+
const toc = renderToc([
|
|
1613
|
+
...(warnings?.length ? [{ id: "ch0", label: "0. Warnings" }] : []),
|
|
1614
|
+
...(diff && diff.summary ? [{ id: "chdiff", label: "Δ Baseline diff" }] : []),
|
|
1615
|
+
{ id: "chcve", label: `1. CVE (${prodStats.total})`, sub: [
|
|
1616
|
+
{ id: "ch1", label: `Prod (${prodStats.total})` },
|
|
1617
|
+
{ id: "ch2", label: `Vendored JS (${retireMatches.length})` },
|
|
1618
|
+
{ id: "ch3", label: `Dev (${devStats.total})` },
|
|
1619
|
+
...(fpTotal ? [{ id: "ch9", label: `Likely FP (${fpTotal})` }] : []),
|
|
1620
|
+
] },
|
|
1621
|
+
{ id: "chbin", label: `2. Unmanaged components (${embTotal + unmTotal + vjsTotal})`, sub: [
|
|
1622
|
+
...(embTotal ? [{ id: "ch1e", label: `Embedded (${embTotal})` }] : []),
|
|
1623
|
+
...(unmTotal ? [{ id: "ch1c", label: `Native (${unmTotal})` }] : []),
|
|
1624
|
+
...(vjsTotal ? [{ id: "ch1d", label: `Vendored JS (${vjsTotal})` }] : []),
|
|
1625
|
+
] },
|
|
1626
|
+
{ id: "chmaint", label: `3. Maintenance (${eolTotal + obsTotal + outTotal})`, sub: [
|
|
1627
|
+
{ id: "ch4", label: `EOL (${eolTotal})` },
|
|
1628
|
+
{ id: "ch5", label: `Obsolete (${obsTotal})` },
|
|
1629
|
+
{ id: "ch6", label: `Outdated (${outTotal})` },
|
|
1630
|
+
] },
|
|
1631
|
+
{ id: "ch7", label: `4. Licenses (${licenseTotal}${licenseFlagged ? `, ${licenseFlagged}⚠` : ""})` },
|
|
1632
|
+
{ id: "ch8", label: `5. Fix Recos` },
|
|
1633
|
+
{ id: "chscan", label: `6. Scan context`, sub: [
|
|
1634
|
+
{ id: "chsrc", label: `Descriptors (${scanned.length})` },
|
|
1635
|
+
...(excTotal ? [{ id: "chexcl", label: `Ignored dirs (${excTotal})` }] : []),
|
|
1636
|
+
{ id: "chmethod", label: `Methodology` },
|
|
1637
|
+
] },
|
|
1638
|
+
]);
|
|
1572
1639
|
|
|
1573
1640
|
// Overview charts row, rendered right under the compact totals.
|
|
1574
1641
|
const chartsRow = renderCharts({ prodMatches: prodMatchesActive }, { interactive: wrapTables });
|
|
@@ -1589,20 +1656,13 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1589
1656
|
${toolbar}
|
|
1590
1657
|
|
|
1591
1658
|
${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
|
|
1592
|
-
${
|
|
1593
|
-
${
|
|
1594
|
-
${
|
|
1595
|
-
${
|
|
1596
|
-
${
|
|
1597
|
-
${
|
|
1598
|
-
${
|
|
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" }) : ""}
|
|
1659
|
+
${(diff && diff.summary) ? majorSection(`Δ Changes since baseline (+${diff.summary.cve.addedProduction} new prod CVE)`, renderDiffChapter(diff), { open: true, id: "chdiff" }) : ""}
|
|
1660
|
+
${cveRoot}
|
|
1661
|
+
${binRoot}
|
|
1662
|
+
${maintRoot}
|
|
1663
|
+
${licRoot}
|
|
1664
|
+
${fixRoot}
|
|
1665
|
+
${scanRoot}
|
|
1606
1666
|
`;
|
|
1607
1667
|
return wrapTables ? wrapTablesWithCopyButtons(body) : body;
|
|
1608
1668
|
}
|
|
@@ -1666,24 +1726,15 @@ function pickTopEol(eolResults, n) {
|
|
|
1666
1726
|
}).slice(0, n);
|
|
1667
1727
|
}
|
|
1668
1728
|
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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>`;
|
|
1729
|
+
// Hierarchical TOC. `entries` is an array of { id, label, sub?: [{id,label}] };
|
|
1730
|
+
// roots render as bold chips, their sub-chapters as smaller muted chips after them.
|
|
1731
|
+
function renderToc(entries) {
|
|
1732
|
+
const renderOne = e => {
|
|
1733
|
+
const root = `<a class="toc-root" href="#${esc(e.id)}">${esc(e.label)}</a>`;
|
|
1734
|
+
const subs = (e.sub || []).map(s => `<a class="toc-sub" href="#${esc(s.id)}">${esc(s.label)}</a>`).join("");
|
|
1735
|
+
return root + subs;
|
|
1736
|
+
};
|
|
1737
|
+
return `<nav class="toc">${(entries || []).map(renderOne).join("")}</nav>`;
|
|
1687
1738
|
}
|
|
1688
1739
|
|
|
1689
1740
|
// Embedded-binary inventory chapter (1B): every embedded coordinate grouped by its
|
|
@@ -1776,6 +1827,62 @@ function renderExcludedDirs(excludedDirs) {
|
|
|
1776
1827
|
return intro + `<table><thead><tr><th>Directory (relative to --src)</th><th>Rule</th><th>Matched</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1777
1828
|
}
|
|
1778
1829
|
|
|
1830
|
+
// What fad-checker does NOT assess — stated explicitly so the audit's scope and
|
|
1831
|
+
// liability are unambiguous. Keep this list honest and in sync with the README.
|
|
1832
|
+
const LIMITATIONS = [
|
|
1833
|
+
"<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.",
|
|
1834
|
+
"<strong>Runtime configuration & mitigations.</strong> WAFs, feature flags, network isolation, disabled features, and other compensating controls are not modelled.",
|
|
1835
|
+
"<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.",
|
|
1836
|
+
"<strong>Business-logic & first-party code flaws.</strong> No SAST of your own source; only third-party components are assessed.",
|
|
1837
|
+
"<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.",
|
|
1838
|
+
"<strong>License legal advice.</strong> License classification is informational SPDX categorisation, not a legal determination.",
|
|
1839
|
+
"<strong>Private / internal coordinates.</strong> Components absent from public registries can't be CVE-matched here; audit them against your internal feed.",
|
|
1840
|
+
"<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.",
|
|
1841
|
+
];
|
|
1842
|
+
|
|
1843
|
+
function renderMethodologyChapter(projectInfo) {
|
|
1844
|
+
const prov = projectInfo && projectInfo.provenance;
|
|
1845
|
+
const STATUS_PILL = { cached: ["cached", "#15803d"], disabled: ["disabled", "#6b7280"], missing: ["not warmed", "#b45309"] };
|
|
1846
|
+
let sourcesTable = `<div class="empty">No provenance manifest available for this run.</div>`;
|
|
1847
|
+
let cfgHtml = "";
|
|
1848
|
+
if (prov) {
|
|
1849
|
+
const rows = (prov.dataSources || []).map(s => {
|
|
1850
|
+
const [txt, bg] = STATUS_PILL[s.status] || [s.status, "#6b7280"];
|
|
1851
|
+
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>`;
|
|
1852
|
+
}).join("\n");
|
|
1853
|
+
sourcesTable = `<table><thead><tr><th>Data source</th><th>State</th><th>As of</th><th>Detail</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1854
|
+
const c = prov.configuration || {};
|
|
1855
|
+
const rt = prov.runtime || {};
|
|
1856
|
+
const kv = (k, v) => `<span class="meta-kv"><strong>${esc(k)}:</strong> ${esc(String(v))}</span>`;
|
|
1857
|
+
cfgHtml = `<div class="fp-intro" style="margin-top:14px">
|
|
1858
|
+
${kv("mode", prov.mode)} ${kv("ecosystems", c.ecosystems)} ${kv("transitive", c.transitive ? `on (depth ${c.transitiveDepth || "?"})` : "off")}
|
|
1859
|
+
${kv("OSV", c.osv)} ${kv("NVD", c.nvd)} ${kv("EPSS", c.epss)} ${kv("KEV", c.kev)} ${kv("licenses", c.licenses)} ${kv("typosquat", c.typosquat)}
|
|
1860
|
+
${kv("fail-on", c.failOn)}${c.ignoreFile ? " " + kv("ignore", c.ignoreFile) : ""}${c.vexFile ? " " + kv("vex", c.vexFile) : ""}
|
|
1861
|
+
${kv("runtime", `${rt.node || "?"} · ${rt.platform || "?"}/${rt.arch || "?"}`)}
|
|
1862
|
+
</div>`;
|
|
1863
|
+
}
|
|
1864
|
+
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>`;
|
|
1865
|
+
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>`;
|
|
1866
|
+
return intro + sourcesTable + cfgHtml + limitations;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Differential-audit chapter (vs --baseline): what changed since the prior findings JSON.
|
|
1870
|
+
function renderDiffChapter(diff) {
|
|
1871
|
+
if (!diff || !diff.summary) return "";
|
|
1872
|
+
const s = diff.summary;
|
|
1873
|
+
const sevTxt = Object.entries(s.cve.addedBySeverity || {}).filter(([, n]) => n).map(([k, n]) => `${n} ${k.toLowerCase()}`).join(", ") || "none";
|
|
1874
|
+
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>`;
|
|
1875
|
+
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>`;
|
|
1876
|
+
const table = `<table><thead><tr><th>Category</th><th>New</th><th>Resolved</th><th>Unchanged</th></tr></thead><tbody>
|
|
1877
|
+
${cat("CVE", s.cve)}${cat("EOL", s.eol)}${cat("Obsolete", s.obsolete)}${cat("Outdated", s.outdated)}${cat("Licenses", s.licenses)}
|
|
1878
|
+
</tbody></table>`;
|
|
1879
|
+
const newCves = (diff.cve?.added || []).filter(f => !f.suppressed && !f.cpeFiltered);
|
|
1880
|
+
const list = newCves.length
|
|
1881
|
+
? `<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>`
|
|
1882
|
+
: "";
|
|
1883
|
+
return intro + table + list;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1779
1886
|
function renderFalsePositives(matches) {
|
|
1780
1887
|
if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
|
|
1781
1888
|
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 +2344,12 @@ ${WORD_SECTION_PR}
|
|
|
2237
2344
|
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
2238
2345
|
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
2239
2346
|
// 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 }) {
|
|
2347
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings, parsedManifests, diff }) {
|
|
2241
2348
|
if (outputDir) {
|
|
2242
2349
|
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
2243
2350
|
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
2244
2351
|
}
|
|
2245
|
-
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests };
|
|
2352
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, excludedDirs, resolvedDeps, projectInfo, warnings, parsedManifests, diff };
|
|
2246
2353
|
const written = { htmlPath: null, docPath: null };
|
|
2247
2354
|
if (htmlPath) {
|
|
2248
2355
|
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
package/lib/diff.js
ADDED
|
Binary file
|
package/lib/json-export.js
CHANGED
|
@@ -68,7 +68,7 @@ function buildFindings(payload = {}) {
|
|
|
68
68
|
const {
|
|
69
69
|
cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
|
|
70
70
|
outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
|
|
71
|
-
typosquats = [], excludedDirs = [],
|
|
71
|
+
typosquats = [], excludedDirs = [], diff = null,
|
|
72
72
|
} = payload;
|
|
73
73
|
|
|
74
74
|
const { buildInventory } = require("./unmanaged");
|
|
@@ -91,6 +91,9 @@ function buildFindings(payload = {}) {
|
|
|
91
91
|
tool: { name: "fad-checker", version: String(toolVersion) },
|
|
92
92
|
generatedAt: projectInfo.generatedAt || null,
|
|
93
93
|
project: { name: projectInfo.name || null, src: projectInfo.src || null },
|
|
94
|
+
// Scan-provenance manifest (data-source freshness + run configuration) for a
|
|
95
|
+
// reproducible/defensible audit. Null if the caller didn't supply one.
|
|
96
|
+
provenance: projectInfo.provenance || payload.provenance || null,
|
|
94
97
|
summary: {
|
|
95
98
|
dependencies: resolvedDeps?.size ?? null,
|
|
96
99
|
cve: { ...sevCounts, kev, total: cveMatches.filter(m => !m.cpeFiltered && !m.suppressed).length },
|
|
@@ -118,6 +121,9 @@ function buildFindings(payload = {}) {
|
|
|
118
121
|
vendoredJs: vendoredJsInventory,
|
|
119
122
|
typosquat: typosquats,
|
|
120
123
|
excludedDirs,
|
|
124
|
+
// Differential audit vs a --baseline document (summary + per-category deltas).
|
|
125
|
+
// Null when no baseline was provided.
|
|
126
|
+
diff: diff || null,
|
|
121
127
|
};
|
|
122
128
|
}
|
|
123
129
|
|
|
@@ -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
|
|
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
|
+
"version": "2.4.0",
|
|
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",
|