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.
@@ -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] || null;
73
+ return withOrigin(EOL_MAPPING.by_composer_name?.[full], "composer-name", full);
66
74
  }
67
75
  if (dep.ecosystem === "pypi") {
68
- return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
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
- return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
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,
@@ -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 by basename (caller's own skip policy).
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() && !skipDir(e.name)) queue.push(path.join(cur, e.name));
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 bin = findRetireBin();
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(bin, args, { maxBuffer: 1024 * 1024 * 64 });
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
- // exit code 13 (or anything where the output file exists) is OK
162
- if (!fs.existsSync(tmpOut)) {
163
- if (verbose) console.warn(`retire: failed to run ${err.message}`);
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 = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
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,