fad-checker 2.1.2 → 2.2.1
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 +37 -0
- package/README.md +75 -87
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +1 -0
- package/fad-checker.js +211 -49
- package/lib/codecs/binary/scan.js +71 -0
- package/lib/codecs/binary/sniff.js +37 -0
- package/lib/codecs/binary.codec.js +54 -0
- package/lib/codecs/composer.codec.js +7 -3
- package/lib/codecs/go/registry.js +19 -11
- package/lib/codecs/go.codec.js +7 -3
- package/lib/codecs/index.js +9 -4
- package/lib/codecs/maven/jar-scan.js +8 -3
- package/lib/codecs/maven.codec.js +4 -2
- package/lib/codecs/npm/parse.js +5 -2
- package/lib/codecs/npm/registry.js +29 -18
- package/lib/codecs/npm.codec.js +7 -5
- package/lib/codecs/nuget.codec.js +7 -3
- package/lib/codecs/pypi/registry.js +16 -10
- package/lib/codecs/pypi.codec.js +7 -3
- package/lib/codecs/recipes.js +9 -1
- package/lib/codecs/ruby/registry.js +16 -10
- package/lib/codecs/ruby.codec.js +7 -3
- package/lib/config.js +34 -23
- package/lib/core.js +9 -5
- package/lib/cve-match.js +3 -2
- package/lib/cve-report.js +121 -25
- package/lib/dep-record.js +12 -9
- package/lib/embedded.js +57 -0
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +15 -2
- package/lib/options-env.js +113 -0
- package/lib/outdated.js +19 -7
- package/lib/parallel-walk.js +5 -2
- package/lib/path-filter.js +58 -0
- package/lib/registries.js +112 -0
- package/lib/retire.js +158 -7
- package/lib/scan-completeness.js +22 -7
- package/lib/ui.js +4 -3
- package/lib/unmanaged.js +82 -0
- package/package.json +1 -1
package/lib/json-export.js
CHANGED
|
@@ -65,10 +65,17 @@ const SEV = ["critical", "high", "medium", "low", "none", "unknown"];
|
|
|
65
65
|
*/
|
|
66
66
|
function buildFindings(payload = {}) {
|
|
67
67
|
const {
|
|
68
|
-
cveMatches = [], retireMatches = [], eolResults = [], obsoleteResults = [],
|
|
68
|
+
cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
|
|
69
69
|
outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
|
|
70
70
|
} = payload;
|
|
71
71
|
|
|
72
|
+
const { buildInventory } = require("./unmanaged");
|
|
73
|
+
const unmanaged = resolvedDeps ? buildInventory(resolvedDeps) : [];
|
|
74
|
+
|
|
75
|
+
const { buildEmbeddedInventory } = require("./embedded");
|
|
76
|
+
const embeddedMatches = cveMatches.filter(m => m.dep?.provenance === "embedded");
|
|
77
|
+
const embedded = resolvedDeps ? buildEmbeddedInventory(resolvedDeps, embeddedMatches) : [];
|
|
78
|
+
|
|
72
79
|
const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
|
|
73
80
|
let kev = 0;
|
|
74
81
|
for (const m of cveMatches) {
|
|
@@ -90,14 +97,20 @@ function buildFindings(payload = {}) {
|
|
|
90
97
|
outdated: outdatedResults.length,
|
|
91
98
|
licensesFlagged: licenseResults?.flagged?.length || 0,
|
|
92
99
|
vendored: retireMatches.length,
|
|
100
|
+
unmanaged: unmanaged.length,
|
|
101
|
+
embedded: embedded.length,
|
|
102
|
+
vendoredJs: vendoredJsInventory.length,
|
|
93
103
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
94
104
|
},
|
|
95
105
|
cve: cveMatches.map(cveFinding),
|
|
96
106
|
vendored: retireMatches.map(cveFinding),
|
|
97
|
-
eol: eolResults.map(e => ({ product: e.product, eol: e.eol, dep: depBrief(e.dep) })),
|
|
107
|
+
eol: eolResults.map(e => ({ product: e.product, productSlug: e.productSlug || null, via: e.via || null, viaKey: e.viaKey || null, eol: e.eol, dep: depBrief(e.dep) })),
|
|
98
108
|
obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
|
|
99
109
|
outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
|
|
100
110
|
licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
|
|
111
|
+
unmanaged,
|
|
112
|
+
embedded,
|
|
113
|
+
vendoredJs: vendoredJsInventory,
|
|
101
114
|
};
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/options-env.js — layered option resolution.
|
|
3
|
+
*
|
|
4
|
+
* Layers (highest → lowest): CLI flags > config file (--config / ./.fad-env.json,
|
|
5
|
+
* JSON) > FAD_CHECKER_ENV (a CLI-flag string) > global ~/.fad-checker/config.json
|
|
6
|
+
* > commander defaults. Scalar options follow precedence; `registries` are unioned
|
|
7
|
+
* elsewhere (lib/registries.js). The source flag has aliases (src/source).
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
11
|
+
*/
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { Command } = require("commander");
|
|
15
|
+
|
|
16
|
+
/** Quote/escape-aware shell-ish tokenizer (single+double quotes, backslash). */
|
|
17
|
+
function tokenize(str) {
|
|
18
|
+
const out = [];
|
|
19
|
+
let cur = "", q = null, esc = false, has = false;
|
|
20
|
+
for (const ch of String(str)) {
|
|
21
|
+
if (esc) { cur += ch; esc = false; has = true; continue; }
|
|
22
|
+
if (ch === "\\" && q !== "'") { esc = true; continue; }
|
|
23
|
+
if (q) { if (ch === q) q = null; else cur += ch; has = true; continue; }
|
|
24
|
+
if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
|
|
25
|
+
if (/\s/.test(ch)) { if (has) { out.push(cur); cur = ""; has = false; } continue; }
|
|
26
|
+
cur += ch; has = true;
|
|
27
|
+
}
|
|
28
|
+
if (has) out.push(cur);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadConfigFile(p) {
|
|
33
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
34
|
+
try { return JSON.parse(raw); }
|
|
35
|
+
catch (e) { throw new Error(`invalid JSON in config file ${p}: ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Map `source` → `src` (src wins if both present). Returns a NEW object. */
|
|
39
|
+
function normalizeSource(obj) {
|
|
40
|
+
const o = { ...obj };
|
|
41
|
+
if (o.source != null && o.src == null) o.src = o.source;
|
|
42
|
+
delete o.source;
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a CLI-flag string into { options, repos } using a throwaway clone of the
|
|
48
|
+
* real program. Only options whose source !== "default" are returned, so unset
|
|
49
|
+
* flags don't clobber higher layers. `repos` (variadic --repo) returned separately.
|
|
50
|
+
*/
|
|
51
|
+
function parseEnvFlags(str, program) {
|
|
52
|
+
const tokens = tokenize(str);
|
|
53
|
+
if (!tokens.length) return { options: {}, repos: [] };
|
|
54
|
+
const clone = new Command();
|
|
55
|
+
clone.exitOverride().allowUnknownOption(true).configureOutput({ writeErr() {}, writeOut() {} });
|
|
56
|
+
for (const o of program.options) clone.addOption(o);
|
|
57
|
+
try { clone.parse(tokens, { from: "user" }); } catch { /* tolerate */ }
|
|
58
|
+
const all = clone.opts();
|
|
59
|
+
const options = {};
|
|
60
|
+
for (const name of Object.keys(all)) {
|
|
61
|
+
const src = clone.getOptionValueSource(name);
|
|
62
|
+
if (src && src !== "default") options[name] = all[name];
|
|
63
|
+
}
|
|
64
|
+
const repos = Array.isArray(options.repo) ? options.repo : [];
|
|
65
|
+
delete options.repo;
|
|
66
|
+
return { options: normalizeSource(options), repos };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve { fileLayer, envLayer, envRepos }. */
|
|
70
|
+
function loadLayers({ cwd = process.cwd(), configPath = null, envStr = process.env.FAD_CHECKER_ENV, program = null } = {}) {
|
|
71
|
+
let fileLayer = {};
|
|
72
|
+
const chosen = configPath || path.join(cwd, ".fad-env.json");
|
|
73
|
+
if (configPath) fileLayer = loadConfigFile(chosen);
|
|
74
|
+
else if (fs.existsSync(chosen)) fileLayer = loadConfigFile(chosen);
|
|
75
|
+
fileLayer = normalizeSource(fileLayer || {});
|
|
76
|
+
let envLayer = {}, envRepos = [];
|
|
77
|
+
if (envStr && program) {
|
|
78
|
+
// If FAD_CHECKER_ENV points to a readable file, treat its content as flags too.
|
|
79
|
+
let s = envStr;
|
|
80
|
+
try { if (fs.existsSync(envStr) && fs.statSync(envStr).isFile()) s = fs.readFileSync(envStr, "utf8"); } catch { /* inline */ }
|
|
81
|
+
const parsed = parseEnvFlags(s, program);
|
|
82
|
+
envLayer = parsed.options; envRepos = parsed.repos;
|
|
83
|
+
}
|
|
84
|
+
return { fileLayer, envLayer, envRepos };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Merge layers onto the parsed program. A file/env/global value fills an option
|
|
89
|
+
* ONLY when the CLI did not set it (source default/undefined). Order: file > env
|
|
90
|
+
* > global. Returns the effective options object (a copy of program.opts()).
|
|
91
|
+
*/
|
|
92
|
+
function applyLayers(program, layers = {}, globalStore = {}) {
|
|
93
|
+
const eff = normalizeSource(program.opts());
|
|
94
|
+
const fileLayer = normalizeSource(layers.fileLayer || {});
|
|
95
|
+
const envLayer = normalizeSource(layers.envLayer || {});
|
|
96
|
+
const cliSet = name => {
|
|
97
|
+
const s = program.getOptionValueSource(name);
|
|
98
|
+
return s && s !== "default";
|
|
99
|
+
};
|
|
100
|
+
const candidates = new Set([...Object.keys(fileLayer), ...Object.keys(envLayer), ...Object.keys(globalStore || {})]);
|
|
101
|
+
candidates.delete("registries"); // unioned separately
|
|
102
|
+
candidates.delete("excludePath"); // unioned separately
|
|
103
|
+
candidates.delete("source");
|
|
104
|
+
for (const name of candidates) {
|
|
105
|
+
if (cliSet(name)) continue; // CLI wins
|
|
106
|
+
if (name in fileLayer) eff[name] = fileLayer[name];
|
|
107
|
+
else if (name in envLayer) eff[name] = envLayer[name];
|
|
108
|
+
else if (globalStore && name in globalStore) eff[name] = globalStore[name];
|
|
109
|
+
}
|
|
110
|
+
return eff;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { tokenize, loadConfigFile, normalizeSource, parseEnvFlags, loadLayers, applyLayers };
|
package/lib/outdated.js
CHANGED
|
@@ -42,6 +42,13 @@ function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
|
|
|
42
42
|
|
|
43
43
|
// -------- EOL via endoflife.date --------
|
|
44
44
|
|
|
45
|
+
// Tag the matched mapping entry with HOW it matched (which rule + which key), so
|
|
46
|
+
// the report can show where an EOL verdict comes from. Returns a COPY — the shared
|
|
47
|
+
// EOL_MAPPING objects must never be mutated (they're reused across deps).
|
|
48
|
+
function withOrigin(entry, via, viaKey) {
|
|
49
|
+
return entry ? { ...entry, via, viaKey } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
function findEolProduct(dep) {
|
|
46
53
|
// npm packages — and WebJars, which are client-side JS shipped as Maven
|
|
47
54
|
// artifacts — resolve by JS library name, not Maven coordinate. npm deps
|
|
@@ -49,36 +56,39 @@ function findEolProduct(dep) {
|
|
|
49
56
|
// first (org.webjars:angularjs → "angularjs", org.webjars.npm:angular__core
|
|
50
57
|
// → "@angular/core"). The npm package literally named "angular" is AngularJS
|
|
51
58
|
// 1.x; modern Angular is the @angular/* scope — hence the name vs. scope maps.
|
|
59
|
+
const isWebjar = dep.ecosystem !== "npm" && webjarToNpm(dep) != null;
|
|
52
60
|
const npmName = dep.ecosystem === "npm" ? (dep.artifactId || "") : webjarToNpm(dep)?.name;
|
|
53
61
|
if (npmName != null) {
|
|
54
62
|
const byName = EOL_MAPPING.by_npm_name?.[npmName];
|
|
55
|
-
if (byName) return byName;
|
|
63
|
+
if (byName) return withOrigin(byName, isWebjar ? "webjar" : "npm-name", npmName);
|
|
56
64
|
const scopes = Object.keys(EOL_MAPPING.by_npm_scope || {})
|
|
57
65
|
.sort((a, b) => b.length - a.length);
|
|
58
66
|
for (const s of scopes) {
|
|
59
|
-
if (npmName.startsWith(s)) return EOL_MAPPING.by_npm_scope[s];
|
|
67
|
+
if (npmName.startsWith(s)) return withOrigin(EOL_MAPPING.by_npm_scope[s], isWebjar ? "webjar" : "npm-scope", s);
|
|
60
68
|
}
|
|
61
69
|
return null;
|
|
62
70
|
}
|
|
63
71
|
if (dep.ecosystem === "composer") {
|
|
64
72
|
const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
|
|
65
|
-
return EOL_MAPPING.by_composer_name?.[full]
|
|
73
|
+
return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
|
|
66
74
|
}
|
|
67
75
|
if (dep.ecosystem === "pypi") {
|
|
68
|
-
|
|
76
|
+
const k = (dep.name || dep.artifactId || "").toLowerCase();
|
|
77
|
+
return withOrigin(EOL_MAPPING.by_pypi_name?.[k], "pypi-name", k);
|
|
69
78
|
}
|
|
70
79
|
if (dep.ecosystem === "nuget") {
|
|
71
|
-
|
|
80
|
+
const k = (dep.name || dep.artifactId || "").toLowerCase();
|
|
81
|
+
return withOrigin(EOL_MAPPING.by_nuget_name?.[k], "nuget-name", k);
|
|
72
82
|
}
|
|
73
83
|
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
74
84
|
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
75
|
-
if (direct) return direct;
|
|
85
|
+
if (direct) return withOrigin(direct, "group-artifact", key);
|
|
76
86
|
// Match longest groupId prefix
|
|
77
87
|
const prefixes = Object.keys(EOL_MAPPING.by_group_prefix || {})
|
|
78
88
|
.sort((a, b) => b.length - a.length);
|
|
79
89
|
for (const p of prefixes) {
|
|
80
90
|
if (dep.groupId === p || dep.groupId.startsWith(p + ".")) {
|
|
81
|
-
return EOL_MAPPING.by_group_prefix[p];
|
|
91
|
+
return withOrigin(EOL_MAPPING.by_group_prefix[p], "group-prefix", p);
|
|
82
92
|
}
|
|
83
93
|
}
|
|
84
94
|
return null;
|
|
@@ -154,6 +164,8 @@ async function checkEolDeps(resolvedDeps, opts = {}) {
|
|
|
154
164
|
dep,
|
|
155
165
|
product: product.label || product.product,
|
|
156
166
|
productSlug: product.product,
|
|
167
|
+
via: product.via || null,
|
|
168
|
+
viaKey: product.viaKey || null,
|
|
157
169
|
cycle: cycle.cycle,
|
|
158
170
|
eol: cycle.eol === true ? "true" : String(cycle.eol),
|
|
159
171
|
latest: cycle.latest || null,
|
package/lib/parallel-walk.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* into something close to a single round-trip deep, which is dramatically faster.
|
|
10
10
|
*
|
|
11
11
|
* `onDir(absDir, entries)` is called once per visited directory with its Dirent[].
|
|
12
|
-
* `skipDir(name)` prunes a child directory
|
|
12
|
+
* `skipDir(absChildDir, name)` prunes a child directory (caller's own skip policy);
|
|
13
|
+
* it receives the child's absolute path (for path-relative globs) plus its basename.
|
|
13
14
|
*
|
|
14
15
|
* @author: N.BRAUN
|
|
15
16
|
* @email: pp9ping@gmail.com
|
|
@@ -34,7 +35,9 @@ async function walkDirs(root, { skipDir = () => false, concurrency = DEFAULT_CON
|
|
|
34
35
|
fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
|
|
35
36
|
if (onDir) onDir(cur, entries);
|
|
36
37
|
for (const e of entries) {
|
|
37
|
-
if (e.isDirectory()
|
|
38
|
+
if (!e.isDirectory()) continue;
|
|
39
|
+
const child = path.join(cur, e.name);
|
|
40
|
+
if (!skipDir(child, e.name)) queue.push(child);
|
|
38
41
|
}
|
|
39
42
|
}).catch(() => { /* unreadable dir → skip, same as readdirSync catch */ })
|
|
40
43
|
.finally(() => { active--; pump(); });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/path-filter.js — directory-walk pruning policy shared by every codec walker.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* - default skips: each walker's own basename set (node_modules, vendor, target,
|
|
6
|
+
* .git, …). Bypassable with useDefaults=false (CLI --no-default-excludes).
|
|
7
|
+
* - user globs: --exclude-path / `excludePath` config, matched gitignore-style
|
|
8
|
+
* against the directory's path RELATIVE to the scan root (`srcRoot`). A bare
|
|
9
|
+
* `foo/bar` matches that directory and its whole subtree (`foo/bar/**`).
|
|
10
|
+
*
|
|
11
|
+
* makeDirFilter() returns a predicate over a child directory's ABSOLUTE path, so
|
|
12
|
+
* it drops straight into parallel-walk's skipDir and the serial readdir walkers.
|
|
13
|
+
*
|
|
14
|
+
* @author: N.BRAUN
|
|
15
|
+
* @email: pp9ping@gmail.com
|
|
16
|
+
*/
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { minimatch } = require("minimatch");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile glob strings into (relPath) → bool matchers. Each glob prunes both the
|
|
22
|
+
* matched directory itself and its whole subtree, so `packages/legacy/**` (or the
|
|
23
|
+
* bare `packages/legacy`) stops the walk at `packages/legacy` — a manifest sitting
|
|
24
|
+
* directly in it is never collected.
|
|
25
|
+
*/
|
|
26
|
+
function compileGlobs(globs) {
|
|
27
|
+
return (globs || []).filter(Boolean).map(String).map(g => g.trim()).filter(Boolean).map(g => {
|
|
28
|
+
// Paths match relative to srcRoot, so they're already root-anchored. Accept
|
|
29
|
+
// `truc`, `/truc` and `./truc` as the same thing (strip a leading ./ or /).
|
|
30
|
+
const base = g.replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
31
|
+
const patterns = base.endsWith("/**")
|
|
32
|
+
? [base, base.slice(0, -3).replace(/\/+$/, "")] // dir + its subtree
|
|
33
|
+
: [base, base + "/**"];
|
|
34
|
+
return rel => patterns.some(p => p && minimatch(rel, p, { dot: true }));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a skipDir(absChildDir) predicate.
|
|
40
|
+
* srcRoot scan root the globs are relative to
|
|
41
|
+
* defaultSkip Set of basenames the walker prunes by default (its own SKIP)
|
|
42
|
+
* excludePath user glob strings
|
|
43
|
+
* useDefaults when false, ignore defaultSkip entirely (--no-default-excludes)
|
|
44
|
+
*/
|
|
45
|
+
function makeDirFilter({ srcRoot, defaultSkip = null, excludePath = [], useDefaults = true } = {}) {
|
|
46
|
+
const matchers = compileGlobs(excludePath);
|
|
47
|
+
return function skipDir(absChild) {
|
|
48
|
+
const name = path.basename(absChild);
|
|
49
|
+
if (useDefaults && defaultSkip && defaultSkip.has(name)) return true;
|
|
50
|
+
if (matchers.length && srcRoot) {
|
|
51
|
+
const rel = path.relative(srcRoot, absChild).split(path.sep).join("/");
|
|
52
|
+
if (rel && !rel.startsWith("..") && matchers.some(m => m(rel))) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { makeDirFilter, compileGlobs };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/registries.js — per-ecosystem registry list assembly + auth + fan-out.
|
|
3
|
+
*
|
|
4
|
+
* Generalizes lib/maven-repo.js's list-building to npm/pypi/ruby/go. Custom
|
|
5
|
+
* registries are tried first (declared order); callers append the public base
|
|
6
|
+
* last via withPublic(). Lists are unioned across config layers, deduped by URL.
|
|
7
|
+
*
|
|
8
|
+
* Entry shape: { name?, url, auth?, token? }
|
|
9
|
+
* auth "user:pass" → Authorization: Basic <base64>
|
|
10
|
+
* token "…" → Authorization: Bearer <token>
|
|
11
|
+
*
|
|
12
|
+
* @author: N.BRAUN
|
|
13
|
+
* @email: pp9ping@gmail.com
|
|
14
|
+
*/
|
|
15
|
+
const SUPPORTED = ["maven", "npm", "pypi", "ruby", "go"];
|
|
16
|
+
|
|
17
|
+
const PUBLIC_BASES = {
|
|
18
|
+
maven: "https://repo1.maven.org/maven2/",
|
|
19
|
+
npm: "https://registry.npmjs.org/",
|
|
20
|
+
pypi: "https://pypi.org/pypi/",
|
|
21
|
+
ruby: "https://rubygems.org/api/v1/gems/",
|
|
22
|
+
go: "https://proxy.golang.org/",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalise(url) {
|
|
26
|
+
if (!url) return url;
|
|
27
|
+
return url.endsWith("/") ? url : url + "/";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function splitUrlAuth(url) {
|
|
31
|
+
if (!url) return { url, auth: null };
|
|
32
|
+
try {
|
|
33
|
+
const u = new URL(url);
|
|
34
|
+
if (u.username || u.password) {
|
|
35
|
+
const auth = decodeURIComponent(u.username) + ":" + decodeURIComponent(u.password);
|
|
36
|
+
u.username = ""; u.password = "";
|
|
37
|
+
return { url: u.toString(), auth };
|
|
38
|
+
}
|
|
39
|
+
} catch { /* not a URL */ }
|
|
40
|
+
return { url, auth: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function authHeaderFor(entry) {
|
|
44
|
+
if (!entry) return null;
|
|
45
|
+
if (entry.token) return "Bearer " + entry.token;
|
|
46
|
+
if (entry.auth) return "Basic " + Buffer.from(entry.auth).toString("base64");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Union of registry entries from several layers (arrays). Dedup by URL, first wins. */
|
|
51
|
+
function buildRegistryList(_ecosystem, layers = []) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const layer of layers) {
|
|
55
|
+
for (const r of layer || []) {
|
|
56
|
+
if (!r?.url) continue;
|
|
57
|
+
const { url, auth } = splitUrlAuth(normalise(r.url));
|
|
58
|
+
if (seen.has(url)) continue;
|
|
59
|
+
seen.add(url);
|
|
60
|
+
out.push({ name: r.name || url, url, auth: r.auth || auth || null, token: r.token || null });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Append the ecosystem's public base (no auth) as the final fallback. */
|
|
67
|
+
function withPublic(ecosystem, list) {
|
|
68
|
+
const pub = PUBLIC_BASES[ecosystem];
|
|
69
|
+
const out = [...(list || [])];
|
|
70
|
+
if (pub && !out.some(r => normalise(r.url) === pub)) out.push({ name: "public", url: pub, auth: null, token: null });
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Merge two registry maps (eco → entries[]) into one (concat per eco). */
|
|
75
|
+
function mergeRegistryMaps(...maps) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const m of maps) {
|
|
78
|
+
if (!m || typeof m !== "object") continue;
|
|
79
|
+
for (const eco of Object.keys(m)) {
|
|
80
|
+
if (!Array.isArray(m[eco])) continue;
|
|
81
|
+
out[eco] = (out[eco] || []).concat(m[eco]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Try each base in order; return the first response whose `res.ok` is true,
|
|
89
|
+
* as { res, base, url }. Applies per-base auth. opts.fetcher for tests.
|
|
90
|
+
*/
|
|
91
|
+
async function fetchFirstOk(bases, buildUrl, opts = {}) {
|
|
92
|
+
const { fetcher = globalThis.fetch, userAgent = "fad-checker", timeoutMs, onMiss } = opts;
|
|
93
|
+
for (const base of bases) {
|
|
94
|
+
const url = buildUrl(normalise(base.url));
|
|
95
|
+
const headers = { "User-Agent": userAgent, Accept: "application/json" };
|
|
96
|
+
const ah = authHeaderFor(base);
|
|
97
|
+
if (ah) headers.Authorization = ah;
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetcher(url, { headers, ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}) });
|
|
101
|
+
} catch (err) { if (onMiss) onMiss(base, `network: ${err.message}`); continue; }
|
|
102
|
+
if (res.ok) return { res, base, url };
|
|
103
|
+
if (onMiss) onMiss(base, `HTTP ${res.status}`);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
SUPPORTED, PUBLIC_BASES,
|
|
110
|
+
normalise, splitUrlAuth, authHeaderFor,
|
|
111
|
+
buildRegistryList, withPublic, mergeRegistryMaps, fetchFirstOk,
|
|
112
|
+
};
|
package/lib/retire.js
CHANGED
|
@@ -36,15 +36,38 @@ const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signature
|
|
|
36
36
|
const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
|
|
37
37
|
const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
|
|
38
38
|
|
|
39
|
+
// retire always emits ABSOLUTE file paths (it resolves --jspath). Make them
|
|
40
|
+
// relative to the scan root robustly — resolving BOTH sides so it works whether
|
|
41
|
+
// the caller passed -s as a relative ("./proj") or absolute path. The old
|
|
42
|
+
// `file.startsWith(srcDir)` guard silently left paths absolute for a relative -s.
|
|
43
|
+
function relToSrc(srcDir, file) {
|
|
44
|
+
if (!file) return file;
|
|
45
|
+
if (!srcDir) return file;
|
|
46
|
+
try {
|
|
47
|
+
const rel = path.relative(path.resolve(srcDir), path.resolve(file));
|
|
48
|
+
return rel && !rel.startsWith("..") ? rel : file;
|
|
49
|
+
} catch { return file; }
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
function cacheKey(srcDir) {
|
|
40
53
|
return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
// Cache schema version. Bumped to 2 when retire started running with `--verbose`
|
|
57
|
+
// (so the cached body carries the FULL vendored-JS inventory, not just vulnerable
|
|
58
|
+
// hits). A cached entry without `_schema >= 2` was written by a pre-verbose build
|
|
59
|
+
// (e.g. 1.0.6) and its body holds vuln-only data — trusting it would silently empty
|
|
60
|
+
// the inventory chapter (1D) on an offline re-run. We treat such an entry as a
|
|
61
|
+
// cache MISS so the normal path re-scans (online, or offline with local signatures)
|
|
62
|
+
// and the offline report reproduces the online one.
|
|
63
|
+
const RETIRE_CACHE_SCHEMA = 2;
|
|
64
|
+
|
|
43
65
|
function readCache(srcDir) {
|
|
44
66
|
const p = path.join(RETIRE_CACHE_DIR, cacheKey(srcDir));
|
|
45
67
|
if (!fs.existsSync(p)) return null;
|
|
46
68
|
try {
|
|
47
69
|
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
70
|
+
if (!(data._schema >= RETIRE_CACHE_SCHEMA)) return null; // legacy / pre-verbose → re-scan
|
|
48
71
|
if (Date.now() - data._fetchedAt < RETIRE_CACHE_TTL_MS) return data.body;
|
|
49
72
|
} catch { /* ignore */ }
|
|
50
73
|
return null;
|
|
@@ -53,7 +76,7 @@ function readCache(srcDir) {
|
|
|
53
76
|
function writeCache(srcDir, body) {
|
|
54
77
|
fs.mkdirSync(RETIRE_CACHE_DIR, { recursive: true });
|
|
55
78
|
fs.writeFileSync(path.join(RETIRE_CACHE_DIR, cacheKey(srcDir)),
|
|
56
|
-
JSON.stringify({ _fetchedAt: Date.now(), body }));
|
|
79
|
+
JSON.stringify({ _schema: RETIRE_CACHE_SCHEMA, _fetchedAt: Date.now(), body }));
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
function findRetireBin() {
|
|
@@ -62,6 +85,28 @@ function findRetireBin() {
|
|
|
62
85
|
return "retire"; // fall back to PATH
|
|
63
86
|
}
|
|
64
87
|
|
|
88
|
+
// Decide HOW to launch retire. The compiled (bun) binary has no node_modules to
|
|
89
|
+
// spawn the retire CLI from and the air-gapped box has no `retire` on PATH — so it
|
|
90
|
+
// re-execs ITSELF with __FAD_RETIRE__ set, and the entry point (fad-checker.js)
|
|
91
|
+
// hands off to the statically-bundled retire CLI. Pure for testability.
|
|
92
|
+
// - localBin present (node dev / node_modules) → run it directly.
|
|
93
|
+
// - else running under bun (compiled binary) → self-invoke this executable.
|
|
94
|
+
// - else → `retire` on PATH (last resort).
|
|
95
|
+
function chooseRetireLauncher({ localBin, isBun, execPath }) {
|
|
96
|
+
if (localBin) return { cmd: localBin, env: null };
|
|
97
|
+
if (isBun) return { cmd: execPath, env: { __FAD_RETIRE__: "1" } };
|
|
98
|
+
return { cmd: "retire", env: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findRetireLauncher() {
|
|
102
|
+
const local = path.join(__dirname, "..", "node_modules", ".bin", "retire");
|
|
103
|
+
return chooseRetireLauncher({
|
|
104
|
+
localBin: fs.existsSync(local) ? local : null,
|
|
105
|
+
isBun: !!(process.versions && process.versions.bun),
|
|
106
|
+
execPath: process.execPath,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
65
110
|
/**
|
|
66
111
|
* Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
|
|
67
112
|
* be bundled by --export-cache and reused offline via --jsrepo. Network call —
|
|
@@ -101,6 +146,11 @@ async function ensureSignatures({ verbose, offline } = {}) {
|
|
|
101
146
|
// when available so retire loads signatures from disk instead of the network.
|
|
102
147
|
function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
103
148
|
const args = [
|
|
149
|
+
// --verbose makes retire list EVERY identified library, not just the
|
|
150
|
+
// vulnerable ones (its own wording: "by default only vulnerable files are
|
|
151
|
+
// shown"). We need the full set for the vendored-JS inventory chapter;
|
|
152
|
+
// vulnerable findings are still flagged in each result's `vulnerabilities`.
|
|
153
|
+
"--verbose",
|
|
104
154
|
"--outputformat", "json",
|
|
105
155
|
"--outputpath", outPath,
|
|
106
156
|
"--jspath", srcDir,
|
|
@@ -110,6 +160,64 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
|
110
160
|
return args;
|
|
111
161
|
}
|
|
112
162
|
|
|
163
|
+
const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, none: 1 };
|
|
164
|
+
|
|
165
|
+
// Pick the human-meaningful line out of retire's stderr for a failure message.
|
|
166
|
+
// retire dumps a multi-line stack trace; the useful part is the first non-empty,
|
|
167
|
+
// non-stack-frame line (preferring an ENOENT / "no such file" / permission line).
|
|
168
|
+
function retireFailureReason(stderr, fallback) {
|
|
169
|
+
const lines = String(stderr || "")
|
|
170
|
+
.split("\n")
|
|
171
|
+
.map(l => l.trim())
|
|
172
|
+
.filter(l => l && !/^at\s/.test(l));
|
|
173
|
+
if (!lines.length) return fallback;
|
|
174
|
+
const hot = lines.find(l => /ENOENT|no such file|permission denied|EACCES/i.test(l));
|
|
175
|
+
return hot || lines[0];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract the full inventory of identified vendored JS libraries (vulnerable or
|
|
180
|
+
* not) from retire's --verbose output. Each entry: the standalone library, where
|
|
181
|
+
* it lives, how retire identified it, and whether it carries known vulns.
|
|
182
|
+
* This is a governance/cyber-hygiene signal: third-party code no package manager
|
|
183
|
+
* governs (unknown provenance, integrity, patch story) — the JS twin of the
|
|
184
|
+
* native-binary inventory (chapter 1C).
|
|
185
|
+
*/
|
|
186
|
+
function extractVendoredInventory(raw, srcDir) {
|
|
187
|
+
const out = [];
|
|
188
|
+
if (!raw) return out;
|
|
189
|
+
const files = Array.isArray(raw) ? raw : (raw.data || []);
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
const file = f.file;
|
|
192
|
+
const relFile = relToSrc(srcDir, file);
|
|
193
|
+
for (const res of f.results || []) {
|
|
194
|
+
if (!res.component) continue;
|
|
195
|
+
const vulns = res.vulnerabilities || [];
|
|
196
|
+
let maxSeverity = null;
|
|
197
|
+
for (const v of vulns) {
|
|
198
|
+
const s = (v.severity || "").toLowerCase();
|
|
199
|
+
if (!maxSeverity || (SEV_RANK[s] || 0) > (SEV_RANK[maxSeverity] || 0)) maxSeverity = s;
|
|
200
|
+
}
|
|
201
|
+
out.push({
|
|
202
|
+
component: res.component,
|
|
203
|
+
version: res.version || null,
|
|
204
|
+
file: relFile || null,
|
|
205
|
+
detection: res.detection || null,
|
|
206
|
+
vulnerable: vulns.length > 0,
|
|
207
|
+
vulnCount: vulns.length,
|
|
208
|
+
maxSeverity: maxSeverity ? maxSeverity.toUpperCase() : null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Vulnerable first (by severity), then by component/version/file for stability.
|
|
213
|
+
out.sort((a, b) =>
|
|
214
|
+
(SEV_RANK[(b.maxSeverity || "").toLowerCase()] || 0) - (SEV_RANK[(a.maxSeverity || "").toLowerCase()] || 0)
|
|
215
|
+
|| String(a.component).localeCompare(String(b.component))
|
|
216
|
+
|| String(a.version).localeCompare(String(b.version))
|
|
217
|
+
|| String(a.file).localeCompare(String(b.file)));
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
113
221
|
/**
|
|
114
222
|
* Run retire.js against `srcDir`. Returns the parsed JSON output (an array
|
|
115
223
|
* of file-level findings) or null if retire is unavailable.
|
|
@@ -119,6 +227,10 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
|
119
227
|
*/
|
|
120
228
|
async function runRetire(srcDir, opts = {}) {
|
|
121
229
|
const { verbose, force, offline } = opts;
|
|
230
|
+
// Optional diagnostics collector: callers (scanWithRetireFull) read diag.error to
|
|
231
|
+
// surface a genuine SCAN FAILURE (retire crashed / produced no parseable output)
|
|
232
|
+
// as a report warning — instead of letting it masquerade as "no vendored JS found".
|
|
233
|
+
const diag = opts.diag || {};
|
|
122
234
|
// No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
|
|
123
235
|
if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
|
|
124
236
|
// Findings-cache fast path (path-keyed). Works online and offline.
|
|
@@ -139,7 +251,7 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
139
251
|
return null;
|
|
140
252
|
}
|
|
141
253
|
|
|
142
|
-
const
|
|
254
|
+
const launcher = findRetireLauncher();
|
|
143
255
|
const ignoredDirs = [
|
|
144
256
|
"node_modules", "bower_components", "jspm_packages",
|
|
145
257
|
".git", ".idea", ".vscode", ".gradle", ".mvn",
|
|
@@ -152,15 +264,27 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
152
264
|
const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
|
|
153
265
|
if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
|
|
154
266
|
|
|
267
|
+
let execErr = null;
|
|
155
268
|
try {
|
|
156
269
|
// retire.js exits with code 13 when it finds vulnerabilities — that's
|
|
157
270
|
// expected. Catch and ignore the non-zero exit; the JSON file is still
|
|
158
271
|
// produced.
|
|
159
|
-
await execFileP(
|
|
272
|
+
await execFileP(launcher.cmd, args, {
|
|
273
|
+
maxBuffer: 1024 * 1024 * 64,
|
|
274
|
+
env: launcher.env ? { ...process.env, ...launcher.env } : process.env,
|
|
275
|
+
});
|
|
160
276
|
} catch (err) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
277
|
+
execErr = err;
|
|
278
|
+
// exit code 13 (or anything where a non-empty output file exists) is OK.
|
|
279
|
+
// A missing OR empty (0-byte) output file means retire crashed mid-walk
|
|
280
|
+
// (e.g. ENOENT on the source path, an unreadable file) — a real failure.
|
|
281
|
+
let size = -1;
|
|
282
|
+
try { size = fs.statSync(tmpOut).size; } catch { /* missing */ }
|
|
283
|
+
if (size <= 0) {
|
|
284
|
+
const reason = retireFailureReason(err.stderr, err.message);
|
|
285
|
+
diag.error = `retire.js scan failed: ${reason}`;
|
|
286
|
+
if (verbose) console.warn(`retire: failed to run — ${reason}`);
|
|
287
|
+
try { fs.unlinkSync(tmpOut); } catch { /* best effort */ }
|
|
164
288
|
return null;
|
|
165
289
|
}
|
|
166
290
|
}
|
|
@@ -170,6 +294,8 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
170
294
|
const body = fs.readFileSync(tmpOut, "utf8");
|
|
171
295
|
parsed = JSON.parse(body);
|
|
172
296
|
} catch (err) {
|
|
297
|
+
const reason = retireFailureReason(execErr && execErr.stderr, err.message);
|
|
298
|
+
diag.error = `retire.js scan failed: ${reason}`;
|
|
173
299
|
if (verbose) console.warn(`retire: could not parse output — ${err.message}`);
|
|
174
300
|
return null;
|
|
175
301
|
} finally {
|
|
@@ -212,7 +338,7 @@ function normaliseRetireResults(raw, srcDir) {
|
|
|
212
338
|
const files = Array.isArray(raw) ? raw : (raw.data || []);
|
|
213
339
|
for (const f of files) {
|
|
214
340
|
const file = f.file;
|
|
215
|
-
const relFile =
|
|
341
|
+
const relFile = relToSrc(srcDir, file);
|
|
216
342
|
for (const res of f.results || []) {
|
|
217
343
|
const component = res.component;
|
|
218
344
|
const version = res.version;
|
|
@@ -263,14 +389,39 @@ async function scanWithRetire(srcDir, opts = {}) {
|
|
|
263
389
|
return normaliseRetireResults(raw, srcDir);
|
|
264
390
|
}
|
|
265
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Like scanWithRetire but returns BOTH the vulnerable matches (chapter "Vendored
|
|
394
|
+
* JS") and the full identified-library inventory (chapter "Unmanaged / vendored
|
|
395
|
+
* JavaScript"). One retire run feeds both.
|
|
396
|
+
*/
|
|
397
|
+
async function scanWithRetireFull(srcDir, opts = {}) {
|
|
398
|
+
const diag = {};
|
|
399
|
+
const raw = await runRetire(srcDir, { ...opts, diag });
|
|
400
|
+
if (!raw) return { matches: [], inventory: [], error: diag.error || null };
|
|
401
|
+
return {
|
|
402
|
+
matches: normaliseRetireResults(raw, srcDir),
|
|
403
|
+
inventory: extractVendoredInventory(raw, srcDir),
|
|
404
|
+
error: null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
266
408
|
module.exports = {
|
|
267
409
|
scanWithRetire,
|
|
410
|
+
scanWithRetireFull,
|
|
411
|
+
extractVendoredInventory,
|
|
268
412
|
runRetire,
|
|
269
413
|
normaliseRetireResults,
|
|
270
414
|
findRetireBin,
|
|
415
|
+
findRetireLauncher,
|
|
416
|
+
chooseRetireLauncher,
|
|
271
417
|
warmRetireSignatures,
|
|
272
418
|
ensureSignatures,
|
|
273
419
|
buildRetireArgs,
|
|
420
|
+
retireFailureReason,
|
|
421
|
+
readCache,
|
|
422
|
+
writeCache,
|
|
423
|
+
cacheKey,
|
|
424
|
+
RETIRE_CACHE_SCHEMA,
|
|
274
425
|
RETIRE_CACHE_DIR,
|
|
275
426
|
RETIRE_SIG_DIR,
|
|
276
427
|
RETIRE_SIG_FILE,
|