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.
@@ -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/retire.js CHANGED
@@ -142,9 +142,58 @@ async function ensureSignatures({ verbose, offline } = {}) {
142
142
  return !!r.ok;
143
143
  }
144
144
 
145
- // Pure: build the retire CLI argv. `jsRepo` (a local signature file) is added
146
- // when available so retire loads signatures from disk instead of the network.
147
- function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
145
+ // Directories retire never needs to walk. Mirrors the per-walker default SKIP sets
146
+ // the codecs use (lib/path-filter.js / npm parse.js): package stores, build output,
147
+ // IDE/VCS metadata. Matched by BASENAME at any depth (see buildRetireIgnorePatterns).
148
+ const DEFAULT_RETIRE_SKIP_DIRS = [
149
+ "node_modules", "bower_components", "jspm_packages",
150
+ ".git", ".idea", ".vscode", ".gradle", ".mvn",
151
+ "target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
152
+ ];
153
+
154
+ /**
155
+ * Build the lines for a retire ignorefile, mirroring the rest of the scan's
156
+ * exclude policy (lib/path-filter.js#makeDirFilter) so retire skips the SAME dirs
157
+ * every codec walker prunes.
158
+ *
159
+ * Why an ignorefile and not `--ignore`: retire `path.resolve()`s every `--ignore`
160
+ * entry against ITS OWN cwd (cli.js), so a bare "node_modules" becomes
161
+ * `<cwd>/node_modules` and silently misses `<srcDir>/…/node_modules` whenever fad
162
+ * runs from a different directory than `-s` — and it can never express "this dir
163
+ * at ANY depth". An ignorefile line prefixed with `@` is kept VERBATIM (no resolve)
164
+ * and used as a substring regex, which is the only retire mechanism that can.
165
+ *
166
+ * Two layers, exactly like makeDirFilter:
167
+ * - default skip dirs → `@/<name>/` : segment-bounded substring → matches the dir
168
+ * at ANY depth (the bug: a deeply-nested node_modules), and the surrounding
169
+ * slashes keep lookalike files (`node_modules_shim.js`) from being clipped.
170
+ * Dropped entirely when defaultExcludes === false (--no-default-excludes).
171
+ * - user --exclude-path globs → `@<absSrcDir>/<glob>/` : ANCHORED to the scan
172
+ * root (not any-depth), retire expands `*`→[^/]* and `**`→.* itself.
173
+ *
174
+ * retire walks with POSIX-style paths (its own matchers use `/`), so we emit `/`.
175
+ */
176
+ function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true } = {}) {
177
+ const lines = [];
178
+ if (defaultExcludes !== false) {
179
+ for (const d of DEFAULT_RETIRE_SKIP_DIRS) lines.push(`@/${d}/`);
180
+ }
181
+ const root = srcDir ? path.resolve(srcDir).split(path.sep).join("/").replace(/\/+$/, "") : "";
182
+ for (const g of excludePath || []) {
183
+ // Normalise like compileGlobs: strip a leading ./ or /, a trailing /, and a
184
+ // trailing /** (the dir's whole subtree is already covered by the segment).
185
+ const base = String(g || "").trim().replace(/^\.?\/+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "");
186
+ if (!base) continue;
187
+ lines.push(root ? `@${root}/${base}/` : `@/${base}/`);
188
+ }
189
+ return lines;
190
+ }
191
+
192
+ // Pure: build the retire CLI argv. `ignoreFile` (a path-anchored ignorefile, see
193
+ // buildRetireIgnorePatterns) is passed when there's anything to exclude. `jsRepo`
194
+ // (a local signature file) is added when available so retire loads signatures from
195
+ // disk instead of the network.
196
+ function buildRetireArgs({ srcDir, outPath, ignoreFile, jsRepo }) {
148
197
  const args = [
149
198
  // --verbose makes retire list EVERY identified library, not just the
150
199
  // vulnerable ones (its own wording: "by default only vulnerable files are
@@ -154,8 +203,8 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
154
203
  "--outputformat", "json",
155
204
  "--outputpath", outPath,
156
205
  "--jspath", srcDir,
157
- "--ignore", ignoredDirs,
158
206
  ];
207
+ if (ignoreFile) { args.push("--ignorefile", ignoreFile); }
159
208
  if (jsRepo) { args.push("--jsrepo", jsRepo); }
160
209
  return args;
161
210
  }
@@ -222,11 +271,13 @@ function extractVendoredInventory(raw, srcDir) {
222
271
  * Run retire.js against `srcDir`. Returns the parsed JSON output (an array
223
272
  * of file-level findings) or null if retire is unavailable.
224
273
  *
225
- * `--outputformat json` is the structured form; `--ignore` takes a list of
226
- * dirs to skip; we add the standard suspects to keep runtime bounded.
274
+ * `--outputformat json` is the structured form; a generated `--ignorefile`
275
+ * prunes the same dirs the rest of the scan skips (default SKIP set at any depth
276
+ * + the user's --exclude-path globs, anchored to the scan root) so retire never
277
+ * walks node_modules/target/… or anything the auditor excluded.
227
278
  */
228
279
  async function runRetire(srcDir, opts = {}) {
229
- const { verbose, force, offline } = opts;
280
+ const { verbose, force, offline, excludePath, defaultExcludes } = opts;
230
281
  // Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
231
282
  // surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
232
283
  // as a report warning — instead of letting it masquerade as "no vendored JS found".
@@ -252,16 +303,25 @@ async function runRetire(srcDir, opts = {}) {
252
303
  }
253
304
 
254
305
  const launcher = findRetireLauncher();
255
- const ignoredDirs = [
256
- "node_modules", "bower_components", "jspm_packages",
257
- ".git", ".idea", ".vscode", ".gradle", ".mvn",
258
- "target", "dist", "build", "build-output", "out", "coverage", ".next", ".nuxt",
259
- ].join(",");
260
306
 
261
307
  // retire.js refuses to write to /dev/stdout, so we use a real temp file
262
308
  // and read it back. Falls back to stdout if --outputpath is rejected.
263
309
  const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
264
- const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
310
+
311
+ // Pruning policy, written to a temp ignorefile so retire honors it for the tree
312
+ // it ACTUALLY walks (anchored to srcDir, not retire's cwd — the nested
313
+ // node_modules bug) and respects the same --exclude-path the codecs do.
314
+ const ignorePatterns = buildRetireIgnorePatterns({ srcDir, excludePath, defaultExcludes });
315
+ // retire treats a `.json` ignorefile as the structured descriptor form; keep a
316
+ // plain-text extension so the `@`-prefixed regex lines are read as such.
317
+ const tmpIgnore = ignorePatterns.length ? path.join(os.tmpdir(), `fad-checker-retire-ignore-${process.pid}-${Date.now()}.txt`) : null;
318
+ if (tmpIgnore) fs.writeFileSync(tmpIgnore, ignorePatterns.join("\n") + "\n");
319
+ const cleanup = () => {
320
+ try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
321
+ if (tmpIgnore) { try { fs.unlinkSync(tmpIgnore); } catch { /* best effort */ } }
322
+ };
323
+
324
+ const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoreFile: tmpIgnore, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
265
325
  if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
266
326
 
267
327
  let execErr = null;
@@ -284,7 +344,7 @@ async function runRetire(srcDir, opts = {}) {
284
344
  const reason = retireFailureReason(err.stderr, err.message);
285
345
  diag.error = `retire.js scan failed: ${reason}`;
286
346
  if (verbose) console.warn(`retire: failed to run — ${reason}`);
287
- try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
347
+ cleanup();
288
348
  return null;
289
349
  }
290
350
  }
@@ -299,7 +359,7 @@ async function runRetire(srcDir, opts = {}) {
299
359
  if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
300
360
  return null;
301
361
  } finally {
302
- try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
362
+ cleanup();
303
363
  }
304
364
  writeCache(srcDir, parsed);
305
365
  return parsed;
@@ -417,6 +477,8 @@ module.exports = {
417
477
  warmRetireSignatures,
418
478
  ensureSignatures,
419
479
  buildRetireArgs,
480
+ buildRetireIgnorePatterns,
481
+ DEFAULT_RETIRE_SKIP_DIRS,
420
482
  retireFailureReason,
421
483
  readCache,
422
484
  writeCache,
package/lib/snyk.js CHANGED
@@ -28,13 +28,46 @@ async function runSnykTest(outputDir, opts = {}) {
28
28
  });
29
29
  return parseSnykStdout(stdout);
30
30
  } catch (err) {
31
- // snyk exits 1 when vulns are found — stdout still contains the JSON
32
- if (err.stdout) return parseSnykStdout(err.stdout);
33
31
  if (err.code === "ENOENT") throw new Error("snyk CLI not found on PATH — install snyk separately");
34
- throw err;
32
+ if (err.killed) throw new Error(`snyk timed out after ${Math.round(timeoutMs / 1000)}s (raise opts.timeoutMs)`);
33
+ // snyk exits 1 when vulnerabilities are found — stdout holds the real findings
34
+ // JSON, so that's NOT a failure. BUT exit 2 (command error, e.g. auth) and
35
+ // exit 3 (no supported projects) ALSO write JSON to stdout, shaped
36
+ // { ok:false, error:"…" }. Returning that as-is makes parseSnykResults read it
37
+ // as "0 vulnerabilities" → a silent success that hides a real snyk failure.
38
+ if (err.stdout) {
39
+ const parsed = parseSnykStdout(err.stdout);
40
+ const snykErr = snykOutputError(parsed);
41
+ if (snykErr) throw new Error(`snyk failed (exit ${err.code}): ${snykErr}`);
42
+ return parsed;
43
+ }
44
+ // No JSON on stdout at all → surface stderr (the human-readable reason) rather
45
+ // than execFile's generic "Command failed: snyk test…".
46
+ const detail = (err.stderr && String(err.stderr).trim()) || err.message;
47
+ throw new Error(`snyk failed (exit ${err.code}): ${detail}`);
35
48
  }
36
49
  }
37
50
 
51
+ /**
52
+ * Inspect parsed `snyk --json` output and return a human-readable error string when
53
+ * snyk actually FAILED — i.e. every project is an error stub ({ ok:false, error }) and
54
+ * none carries usable results — or `null` when the output holds real findings.
55
+ *
56
+ * Snyk emits this error-shaped JSON on stdout for exit 2 (command error, e.g. auth) and
57
+ * exit 3 (no supported projects). Without this check those are read as "0 vulnerabilities",
58
+ * silently hiding the failure behind a green "Snyk: 0 findings merged".
59
+ */
60
+ function snykOutputError(parsed) {
61
+ const projects = Array.isArray(parsed) ? parsed : (parsed != null ? [parsed] : []);
62
+ if (!projects.length) return null;
63
+ // A usable project either lists vulnerabilities (exit 1) or reports ok:true (clean).
64
+ const usable = projects.some(p => p && (Array.isArray(p.vulnerabilities) || p.ok === true));
65
+ if (usable) return null;
66
+ const errored = projects.filter(p => p && p.ok === false && p.error);
67
+ if (!errored.length) return null;
68
+ return [...new Set(errored.map(p => String(p.error).trim()).filter(Boolean))].join("; ");
69
+ }
70
+
38
71
  function parseSnykStdout(stdout) {
39
72
  if (!stdout) return [];
40
73
  const str = String(stdout).trim();
@@ -126,5 +159,6 @@ module.exports = {
126
159
  runSnykTest,
127
160
  parseSnykResults,
128
161
  parseSnykStdout,
162
+ snykOutputError,
129
163
  mergeWithFadResults,
130
164
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.3.1",
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",