fad-checker 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +15 -14
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +3 -0
- package/fad-checker.js +122 -12
- package/lib/codecs/composer/registry.js +38 -6
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget/registry.js +49 -10
- package/lib/cve-report.js +182 -58
- package/lib/diff.js +0 -0
- package/lib/json-export.js +9 -1
- package/lib/nvd.js +14 -3
- package/lib/path-filter.js +85 -6
- package/lib/provenance.js +135 -0
- package/lib/registries.js +6 -1
- package/lib/report-integrity.js +52 -0
- package/lib/retire.js +77 -15
- package/lib/snyk.js +37 -3
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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;
|
|
226
|
-
* dirs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|