fad-checker 2.1.2 → 2.2.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 +37 -0
- package/README.md +93 -20
- package/fad-checker.js +164 -45
- 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 +56 -5
- package/lib/dep-record.js +12 -9
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +8 -1
- package/lib/options-env.js +113 -0
- package/lib/parallel-walk.js +5 -2
- package/lib/path-filter.js +58 -0
- package/lib/registries.js +112 -0
- package/lib/retire.js +66 -0
- package/lib/scan-completeness.js +7 -1
- package/lib/unmanaged.js +82 -0
- package/package.json +1 -1
|
@@ -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
|
@@ -101,6 +101,11 @@ async function ensureSignatures({ verbose, offline } = {}) {
|
|
|
101
101
|
// when available so retire loads signatures from disk instead of the network.
|
|
102
102
|
function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
103
103
|
const args = [
|
|
104
|
+
// --verbose makes retire list EVERY identified library, not just the
|
|
105
|
+
// vulnerable ones (its own wording: "by default only vulnerable files are
|
|
106
|
+
// shown"). We need the full set for the vendored-JS inventory chapter;
|
|
107
|
+
// vulnerable findings are still flagged in each result's `vulnerabilities`.
|
|
108
|
+
"--verbose",
|
|
104
109
|
"--outputformat", "json",
|
|
105
110
|
"--outputpath", outPath,
|
|
106
111
|
"--jspath", srcDir,
|
|
@@ -110,6 +115,51 @@ function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
|
110
115
|
return args;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, none: 1 };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract the full inventory of identified vendored JS libraries (vulnerable or
|
|
122
|
+
* not) from retire's --verbose output. Each entry: the standalone library, where
|
|
123
|
+
* it lives, how retire identified it, and whether it carries known vulns.
|
|
124
|
+
* This is a governance/cyber-hygiene signal: third-party code no package manager
|
|
125
|
+
* governs (unknown provenance, integrity, patch story) — the JS twin of the
|
|
126
|
+
* native-binary inventory (chapter 1C).
|
|
127
|
+
*/
|
|
128
|
+
function extractVendoredInventory(raw, srcDir) {
|
|
129
|
+
const out = [];
|
|
130
|
+
if (!raw) return out;
|
|
131
|
+
const files = Array.isArray(raw) ? raw : (raw.data || []);
|
|
132
|
+
for (const f of files) {
|
|
133
|
+
const file = f.file;
|
|
134
|
+
const relFile = srcDir && file?.startsWith(srcDir) ? path.relative(srcDir, file) : file;
|
|
135
|
+
for (const res of f.results || []) {
|
|
136
|
+
if (!res.component) continue;
|
|
137
|
+
const vulns = res.vulnerabilities || [];
|
|
138
|
+
let maxSeverity = null;
|
|
139
|
+
for (const v of vulns) {
|
|
140
|
+
const s = (v.severity || "").toLowerCase();
|
|
141
|
+
if (!maxSeverity || (SEV_RANK[s] || 0) > (SEV_RANK[maxSeverity] || 0)) maxSeverity = s;
|
|
142
|
+
}
|
|
143
|
+
out.push({
|
|
144
|
+
component: res.component,
|
|
145
|
+
version: res.version || null,
|
|
146
|
+
file: relFile || null,
|
|
147
|
+
detection: res.detection || null,
|
|
148
|
+
vulnerable: vulns.length > 0,
|
|
149
|
+
vulnCount: vulns.length,
|
|
150
|
+
maxSeverity: maxSeverity ? maxSeverity.toUpperCase() : null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Vulnerable first (by severity), then by component/version/file for stability.
|
|
155
|
+
out.sort((a, b) =>
|
|
156
|
+
(SEV_RANK[(b.maxSeverity || "").toLowerCase()] || 0) - (SEV_RANK[(a.maxSeverity || "").toLowerCase()] || 0)
|
|
157
|
+
|| String(a.component).localeCompare(String(b.component))
|
|
158
|
+
|| String(a.version).localeCompare(String(b.version))
|
|
159
|
+
|| String(a.file).localeCompare(String(b.file)));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
/**
|
|
114
164
|
* Run retire.js against `srcDir`. Returns the parsed JSON output (an array
|
|
115
165
|
* of file-level findings) or null if retire is unavailable.
|
|
@@ -263,8 +313,24 @@ async function scanWithRetire(srcDir, opts = {}) {
|
|
|
263
313
|
return normaliseRetireResults(raw, srcDir);
|
|
264
314
|
}
|
|
265
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Like scanWithRetire but returns BOTH the vulnerable matches (chapter "Vendored
|
|
318
|
+
* JS") and the full identified-library inventory (chapter "Unmanaged / vendored
|
|
319
|
+
* JavaScript"). One retire run feeds both.
|
|
320
|
+
*/
|
|
321
|
+
async function scanWithRetireFull(srcDir, opts = {}) {
|
|
322
|
+
const raw = await runRetire(srcDir, opts);
|
|
323
|
+
if (!raw) return { matches: [], inventory: [] };
|
|
324
|
+
return {
|
|
325
|
+
matches: normaliseRetireResults(raw, srcDir),
|
|
326
|
+
inventory: extractVendoredInventory(raw, srcDir),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
266
330
|
module.exports = {
|
|
267
331
|
scanWithRetire,
|
|
332
|
+
scanWithRetireFull,
|
|
333
|
+
extractVendoredInventory,
|
|
268
334
|
runRetire,
|
|
269
335
|
normaliseRetireResults,
|
|
270
336
|
findRetireBin,
|
package/lib/scan-completeness.js
CHANGED
|
@@ -40,7 +40,13 @@ function detectScanCompletenessWarnings(resolvedDeps, opts = {}) {
|
|
|
40
40
|
// BOMs themselves: a present-and-correct BOM is not a problem.
|
|
41
41
|
const unresolved = [];
|
|
42
42
|
for (const d of resolvedDeps.values()) {
|
|
43
|
-
|
|
43
|
+
// Maven-only: the unresolved-version case (external BOM / out-of-tree
|
|
44
|
+
// property) is specific to Maven. Other ecosystems pin via lockfile, and
|
|
45
|
+
// native binaries (provenance:"binary") are identified by checksum, not a
|
|
46
|
+
// version — they belong to the Unmanaged/vendored-binaries chapter, NOT
|
|
47
|
+
// here. Including them mislabelled e.g. libeay32.dll as a Maven dep.
|
|
48
|
+
if (d.ecosystem !== "maven") continue;
|
|
49
|
+
if (d.provenance === "binary") continue;
|
|
44
50
|
if (d.scope === "import") continue; // BOM-pointer entries themselves: not a dep
|
|
45
51
|
if (!d.version) unresolved.push({ ...d, reason: "no-version" });
|
|
46
52
|
else if (/\$\{[^}]+\}/.test(String(d.version))) unresolved.push({ ...d, reason: "unresolved-property" });
|
package/lib/unmanaged.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/unmanaged.js — enrich unmanaged (hash-bearing) records with online identity
|
|
3
|
+
* + an integrity classification.
|
|
4
|
+
*
|
|
5
|
+
* integrity:
|
|
6
|
+
* "pristine" — deps.dev matched: file is byte-identical to a PUBLISHED package
|
|
7
|
+
* artifact (so it's unmodified, and ought to be a managed dep).
|
|
8
|
+
* "known-good" — CIRCL matched: a known OS/distro/CDN/NSRL file.
|
|
9
|
+
* "unknown" — no source recognises the hash (suspicious / vendored unknown).
|
|
10
|
+
*
|
|
11
|
+
* Records carrying a declared coordinate (embedded jars) gain a "modified" status in
|
|
12
|
+
* a later refinement; Plan 2 covers the hash-bearing binary records.
|
|
13
|
+
*
|
|
14
|
+
* @author: N.BRAUN
|
|
15
|
+
* @email: pp9ping@gmail.com
|
|
16
|
+
*/
|
|
17
|
+
const { lookupHash, loadCache, saveCache } = require("./hash-id");
|
|
18
|
+
|
|
19
|
+
async function enrichUnmanaged(resolved, opts = {}) {
|
|
20
|
+
const { fetcher, offline = false, cache, onProgress } = opts;
|
|
21
|
+
const targets = [...resolved.values()].filter(d => d.hashes && (d.hashes.sha1 || d.hashes.sha256));
|
|
22
|
+
const summary = { total: targets.length, identified: 0, pristine: 0, knownGood: 0, unknown: 0, malicious: 0 };
|
|
23
|
+
if (!targets.length) return summary;
|
|
24
|
+
const entries = cache || loadCache();
|
|
25
|
+
let done = 0;
|
|
26
|
+
for (const d of targets) {
|
|
27
|
+
const id = await lookupHash(d.hashes, { fetcher, offline, cache: entries });
|
|
28
|
+
d.identity = id || null;
|
|
29
|
+
if (!id) d.integrity = "unknown";
|
|
30
|
+
else if (id.source === "deps.dev") d.integrity = "pristine";
|
|
31
|
+
else d.integrity = "known-good";
|
|
32
|
+
if (id) summary.identified++;
|
|
33
|
+
if (d.integrity === "pristine") summary.pristine++;
|
|
34
|
+
else if (d.integrity === "known-good") summary.knownGood++;
|
|
35
|
+
else summary.unknown++;
|
|
36
|
+
if (id?.knownMalicious) summary.malicious++;
|
|
37
|
+
if (onProgress) onProgress(++done, targets.length);
|
|
38
|
+
}
|
|
39
|
+
if (!cache && !offline) saveCache(entries);
|
|
40
|
+
return summary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normName(s) {
|
|
44
|
+
return String(s || "").toLowerCase()
|
|
45
|
+
.replace(/\.(dll|exe|so|dylib)(\.\d+)*$/, "") // drop binary extension (+ soname version)
|
|
46
|
+
.replace(/^lib/, "") // libssl → ssl
|
|
47
|
+
.replace(/[-_.]?\d[\d.]*$/, "") // trailing version
|
|
48
|
+
.replace(/[^a-z0-9]/g, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Lenient compare of a filename to an online identity name (last :/ segment). */
|
|
52
|
+
function nameMatches(declared, identityName) {
|
|
53
|
+
const a = normName(declared);
|
|
54
|
+
const b = normName(String(identityName).split(/[:/]/).pop());
|
|
55
|
+
if (!a || !b) return true; // can't compare → don't raise a false alarm
|
|
56
|
+
return a.includes(b) || b.includes(a);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Turn the unmanaged (hash-bearing) records into an inventory with derived signals. */
|
|
60
|
+
function buildInventory(resolved) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const d of resolved.values()) {
|
|
63
|
+
if (!d.hashes || !(d.provenance === "binary" || d.provenance === "embedded")) continue;
|
|
64
|
+
const identity = d.identity || null;
|
|
65
|
+
out.push({
|
|
66
|
+
path: d.manifestPaths?.[0] || d.declaredName || null,
|
|
67
|
+
declaredName: d.declaredName || d.name || null,
|
|
68
|
+
provenance: d.provenance,
|
|
69
|
+
hashes: d.hashes,
|
|
70
|
+
identity,
|
|
71
|
+
integrity: d.integrity || "unknown",
|
|
72
|
+
noOnlineInfo: !identity,
|
|
73
|
+
shouldBeManaged: !!(identity && identity.ecosystem),
|
|
74
|
+
nameMismatch: !!(identity && identity.name && !nameMatches(d.declaredName || d.name, identity.name)),
|
|
75
|
+
knownMalicious: !!(identity && identity.knownMalicious),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
out.sort((a, b) => String(a.path).localeCompare(String(b.path)));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { enrichUnmanaged, buildInventory, nameMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Scan ALL Maven, 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",
|