fad-checker 2.1.1 → 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 +100 -32
- package/fad-checker-report/cve-report.doc +643 -105
- package/fad-checker-report/cve-report.html +653 -108
- package/fad-checker.js +265 -55
- 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 +73 -4
- package/lib/codecs/maven/jar-scan.js +179 -28
- package/lib/codecs/maven.codec.js +4 -2
- package/lib/codecs/npm/collect.js +3 -1
- package/lib/codecs/npm/parse.js +29 -1
- package/lib/codecs/npm/registry.js +29 -18
- package/lib/codecs/npm.codec.js +10 -4
- 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 +22 -3
- package/lib/cve-match.js +3 -2
- package/lib/cve-report.js +59 -6
- package/lib/dep-record.js +12 -9
- package/lib/hash-id.js +78 -0
- package/lib/json-export.js +8 -1
- package/lib/license-policy.js +3 -1
- package/lib/options-env.js +113 -0
- package/lib/outdated.js +3 -0
- package/lib/parallel-walk.js +50 -0
- 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
package/lib/cve-report.js
CHANGED
|
@@ -12,12 +12,15 @@ const fs = require("fs");
|
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
14
14
|
const { computePriority, sortByPriority } = require("./priority");
|
|
15
|
+
const { buildInventory } = require("./unmanaged");
|
|
15
16
|
|
|
16
17
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
17
18
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
18
19
|
const CWE_NAMES = (() => {
|
|
19
20
|
try {
|
|
20
|
-
|
|
21
|
+
// require() (not fs.readFileSync) so bun --compile bundles the data file
|
|
22
|
+
// into the standalone binary instead of reading it off disk at runtime.
|
|
23
|
+
const raw = { ...require("../data/cwe-names.json") };
|
|
21
24
|
delete raw._comment;
|
|
22
25
|
return raw;
|
|
23
26
|
} catch { return {}; }
|
|
@@ -1360,7 +1363,7 @@ function renderLicenseChapter(licenseResults) {
|
|
|
1360
1363
|
return intro + blocks;
|
|
1361
1364
|
}
|
|
1362
1365
|
|
|
1363
|
-
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1366
|
+
function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings, wrapTables = true }) {
|
|
1364
1367
|
setRenderCtx({ srcRoot: projectInfo?.src || null });
|
|
1365
1368
|
// Dedupe rows so a single (dep, cve) shows once with all via paths merged.
|
|
1366
1369
|
cveMatches = mergeMatches(cveMatches || []);
|
|
@@ -1395,6 +1398,10 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1395
1398
|
const licenseTotal = licenseResults?.assessed?.length || 0;
|
|
1396
1399
|
const licenseFlagged = licenseResults?.flagged?.length || 0;
|
|
1397
1400
|
const embeddedContent = renderEmbeddedChapter(embMatchesActive, projectInfo?.src);
|
|
1401
|
+
const unmanagedInventory = buildInventory(resolvedDeps || new Map());
|
|
1402
|
+
const unmanagedContent = renderUnmanagedInventory(unmanagedInventory, projectInfo?.src);
|
|
1403
|
+
const vendoredJsInv = vendoredJsInventory || [];
|
|
1404
|
+
const vendoredJsContent = renderVendoredJsInventory(vendoredJsInv, projectInfo?.src);
|
|
1398
1405
|
const fpContent = renderFalsePositives([...prodMatchesFP, ...devMatchesFP, ...embMatchesFP]);
|
|
1399
1406
|
|
|
1400
1407
|
const toolbar = `<div class="toolbar">
|
|
@@ -1427,6 +1434,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1427
1434
|
licenseTotal,
|
|
1428
1435
|
licenseFlagged,
|
|
1429
1436
|
embeddedTotal: embStats.total,
|
|
1437
|
+
unmanagedTotal: unmanagedInventory.length,
|
|
1438
|
+
vendoredJsTotal: vendoredJsInv.length,
|
|
1430
1439
|
fpTotal: prodMatchesFP.length + devMatchesFP.length + embMatchesFP.length,
|
|
1431
1440
|
});
|
|
1432
1441
|
|
|
@@ -1447,6 +1456,8 @@ function buildBody({ cveMatches, devCveMatches, embeddedMatches, retireMatches,
|
|
|
1447
1456
|
${(warnings?.length || 0) ? majorSection(`0. Warnings & scan-completeness (${warnings.length})`, renderWarnings(warnings), { open: true, id: "ch0" }) : ""}
|
|
1448
1457
|
${majorSection(`1. CVE Vulnerabilities — production (${prodStats.total})`, cveContent, { id: "ch1" })}
|
|
1449
1458
|
${embStats.total ? majorSection(`1B. Embedded binaries — JAR/WAR/EAR (${embStats.total})`, embeddedContent, { open: embStats.total <= 50, id: "ch1e" }) : ""}
|
|
1459
|
+
${unmanagedInventory.length ? majorSection(`1C. Unmanaged / vendored binaries (${unmanagedInventory.length})`, unmanagedContent, { open: unmanagedInventory.length <= 50, id: "ch1c" }) : ""}
|
|
1460
|
+
${vendoredJsInv.length ? majorSection(`1D. Unmanaged / vendored JavaScript (${vendoredJsInv.length})`, vendoredJsContent, { open: vendoredJsInv.length <= 50, id: "ch1d" }) : ""}
|
|
1450
1461
|
${majorSection(`2. Vendored JS scan — retire.js (${retireMatches.length})`, vendoredContent, { open: retireMatches.length > 0 && retireMatches.length <= 50, id: "ch2" })}
|
|
1451
1462
|
${majorSection(`3. CVE in dev dependencies (${devStats.total})`, devCveContent, { open: devStats.total > 0 && devStats.total <= 50, id: "ch3" })}
|
|
1452
1463
|
${majorSection(`4. End-of-Life Frameworks (${eolResults?.length || 0})`, renderEolTable(eolResults), { id: "ch4" })}
|
|
@@ -1491,12 +1502,14 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
|
|
|
1491
1502
|
return out;
|
|
1492
1503
|
}
|
|
1493
1504
|
|
|
1494
|
-
function renderToc({ hasWarnings, prodTotal, embeddedTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1505
|
+
function renderToc({ hasWarnings, prodTotal, embeddedTotal, unmanagedTotal, vendoredJsTotal, retireTotal, devTotal, eolTotal, obsoleteTotal, outdatedTotal, licenseTotal, licenseFlagged, fpTotal }) {
|
|
1495
1506
|
const entries = [];
|
|
1496
1507
|
if (hasWarnings) entries.push({ id: "ch0", label: "0. Warnings" });
|
|
1497
1508
|
entries.push({ id: "ch1", label: `1. Prod CVE (${prodTotal})` });
|
|
1498
1509
|
if (embeddedTotal) entries.push({ id: "ch1e", label: `1B. Embedded (${embeddedTotal})` });
|
|
1499
|
-
entries.push({ id: "
|
|
1510
|
+
if (unmanagedTotal) entries.push({ id: "ch1c", label: `1C. Unmanaged (${unmanagedTotal})` });
|
|
1511
|
+
if (vendoredJsTotal) entries.push({ id: "ch1d", label: `1D. Vendored JS (${vendoredJsTotal})` });
|
|
1512
|
+
entries.push({ id: "ch2", label: `2. Vendored JS vulns (${retireTotal})` });
|
|
1500
1513
|
entries.push({ id: "ch3", label: `3. Dev CVE (${devTotal})` });
|
|
1501
1514
|
entries.push({ id: "ch4", label: `4. EOL (${eolTotal})` });
|
|
1502
1515
|
entries.push({ id: "ch5", label: `5. Obsolete (${obsoleteTotal})` });
|
|
@@ -1526,6 +1539,46 @@ function renderEmbeddedChapter(matches, srcRoot) {
|
|
|
1526
1539
|
return intro + blocks;
|
|
1527
1540
|
}
|
|
1528
1541
|
|
|
1542
|
+
// Unmanaged native-binary inventory (Part C). Committed .dll/.exe/.so/.dylib that no
|
|
1543
|
+
// package manager governs, identified by checksum (deps.dev + CIRCL). Self-contained
|
|
1544
|
+
// inline styles, matching the report's no-external-CSS philosophy.
|
|
1545
|
+
function pill(text, bg, fg = "#fff") {
|
|
1546
|
+
return `<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${bg};color:${fg};font-weight:600;font-size:11px">${esc(text)}</span>`;
|
|
1547
|
+
}
|
|
1548
|
+
function renderUnmanagedInventory(inventory, srcRoot) {
|
|
1549
|
+
if (!inventory.length) return `<div class="empty">No committed native binaries found.</div>`;
|
|
1550
|
+
const intro = `<div class="fp-intro">Committed native binaries (<code>.dll</code>/<code>.exe</code>/<code>.so</code>/<code>.dylib</code>) — not governed by any package manager. Identified by checksum (deps.dev + CIRCL). <strong>should-be-managed</strong> = a published package committed as a blob; <strong>name≠checksum</strong> = the filename disagrees with what the hash resolves to; <strong>not recognised by any source</strong> = neither deps.dev nor CIRCL knows this exact file (vendored/custom build — verify its origin manually). Identification is by checksum only; a file absent from both databases stays unidentified even when its name is suggestive (e.g. <code>openssl.exe</code>).</div>`;
|
|
1551
|
+
const rows = inventory.map(e => {
|
|
1552
|
+
let rel = e.path;
|
|
1553
|
+
if (srcRoot) { try { rel = path.relative(srcRoot, e.path); } catch { /* keep abs */ } }
|
|
1554
|
+
const id = e.identity
|
|
1555
|
+
? `${esc(e.identity.ecosystem ? e.identity.ecosystem + ":" : "")}${esc(e.identity.name || "")}${e.identity.version ? "@" + esc(e.identity.version) : ""} <span style="color:#6b7280">(${esc(e.identity.source || "")})</span>`
|
|
1556
|
+
: `<span style="color:#6b7280">unknown</span>`;
|
|
1557
|
+
const flags = [
|
|
1558
|
+
e.knownMalicious ? pill("⚠ MALICIOUS", "#7c0008") : null,
|
|
1559
|
+
e.integrity === "pristine" ? pill("pristine", "#166534") : (e.integrity === "known-good" ? pill("known-good", "#15803d") : null),
|
|
1560
|
+
e.shouldBeManaged ? pill("should-be-managed", "#2563eb") : null,
|
|
1561
|
+
e.nameMismatch ? pill("name≠checksum", "#ea580c") : null,
|
|
1562
|
+
e.noOnlineInfo ? pill("not recognised by any source", "#6b7280") : null,
|
|
1563
|
+
].filter(Boolean).join(" ");
|
|
1564
|
+
return `<tr><td class="dep"><code class="path">${esc(rel)}</code></td><td>${id}</td><td>${flags}</td><td><code class="path" style="color:#6b7280">${esc((e.hashes?.sha256 || "").slice(0, 16))}…</code></td></tr>`;
|
|
1565
|
+
}).join("\n");
|
|
1566
|
+
return intro + `<table><thead><tr><th>File</th><th>Identity (by checksum)</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function renderVendoredJsInventory(inventory, srcRoot) {
|
|
1570
|
+
if (!inventory || !inventory.length) return `<div class="empty">No vendored JavaScript libraries identified.</div>`;
|
|
1571
|
+
const vulnN = inventory.filter(e => e.vulnerable).length;
|
|
1572
|
+
const intro = `<div class="fp-intro">Standalone JavaScript libraries committed into the tree (jQuery, Bootstrap, PDF.js, …) that <strong>no package manager governs</strong> — unknown provenance, integrity and patch story, identified by signature (retire.js). This is a cyber-hygiene inventory: even a non-vulnerable copy is unmanaged third-party code. <strong>${vulnN}</strong> of ${inventory.length} carry known vulnerabilities (detailed in chapter 2).</div>`;
|
|
1573
|
+
const rows = inventory.map(e => {
|
|
1574
|
+
const sevPill = e.vulnerable
|
|
1575
|
+
? pill(`${e.vulnCount} vuln${e.vulnCount > 1 ? "s" : ""}${e.maxSeverity ? " · " + e.maxSeverity : ""}`, "#b91c1c")
|
|
1576
|
+
: pill("no known vuln", "#15803d");
|
|
1577
|
+
return `<tr><td>${esc(e.component || "?")}</td><td>${esc(e.version || "?")}</td><td class="dep"><code class="path">${esc(e.file || "")}</code></td><td><span style="color:#6b7280">${esc(e.detection || "")}</span></td><td>${sevPill}</td></tr>`;
|
|
1578
|
+
}).join("\n");
|
|
1579
|
+
return intro + `<table><thead><tr><th>Library</th><th>Version</th><th>File</th><th>Detection</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1529
1582
|
function renderFalsePositives(matches) {
|
|
1530
1583
|
if (!matches.length) return `<div class="empty">No CPE-filtered findings.</div>`;
|
|
1531
1584
|
const intro = `<div class="fp-intro">These entries were initially matched by name but NVD's CPE configurations show your dep version is outside every vulnerable range. They are almost certainly false positives — kept here for audit transparency.</div>`;
|
|
@@ -1909,12 +1962,12 @@ ${WORD_SECTION_PR}
|
|
|
1909
1962
|
// Render the HTML and/or Word report. Callers pass explicit target paths;
|
|
1910
1963
|
// a null/omitted path skips that format (so `--report-html` alone writes only HTML).
|
|
1911
1964
|
// Back-compat: `outputDir` still writes both under the default file names.
|
|
1912
|
-
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
1965
|
+
async function writeReports({ cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, outputDir, htmlPath, docPath, warnings }) {
|
|
1913
1966
|
if (outputDir) {
|
|
1914
1967
|
if (htmlPath === undefined) htmlPath = path.join(outputDir, "cve-report.html");
|
|
1915
1968
|
if (docPath === undefined) docPath = path.join(outputDir, "cve-report.doc");
|
|
1916
1969
|
}
|
|
1917
|
-
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
1970
|
+
const payload = { cveMatches, devCveMatches, embeddedMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps, projectInfo, warnings };
|
|
1918
1971
|
const written = { htmlPath: null, docPath: null };
|
|
1919
1972
|
if (htmlPath) {
|
|
1920
1973
|
await fs.promises.mkdir(path.dirname(path.resolve(htmlPath)), { recursive: true });
|
package/lib/dep-record.js
CHANGED
|
@@ -35,17 +35,18 @@ function coordKeyFor(ecosystem, namespace, name) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function makeDepRecord(input) {
|
|
38
|
-
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest" } = input;
|
|
38
|
+
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType, provenance = "manifest", hashes = null, declaredName = null } = input;
|
|
39
39
|
const concrete = version && !/\$\{/.test(version) ? version : null;
|
|
40
40
|
const manifestPaths = manifestPath ? [manifestPath] : [];
|
|
41
|
-
// Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear
|
|
42
|
-
//
|
|
43
|
-
// they'd merge and the
|
|
44
|
-
// them by their unique physical location instead. provenance
|
|
45
|
-
// so the report/exports can label & group them; "both" is set
|
|
46
|
-
// across provenances ever happens.
|
|
47
|
-
const
|
|
48
|
-
|
|
41
|
+
// Embedded binaries (a dep discovered INSIDE a .jar/.war/.ear) and standalone
|
|
42
|
+
// committed native binaries (.dll/.so/…) must not share the Map key of a declared
|
|
43
|
+
// dep with the same coordinate — they'd merge and the unmanaged report chapter
|
|
44
|
+
// would lose them. Key them by their unique physical location instead. provenance
|
|
45
|
+
// stays on the record so the report/exports can label & group them; "both" is set
|
|
46
|
+
// later if a merge across provenances ever happens.
|
|
47
|
+
const byLocation = (provenance === "embedded" || provenance === "binary") && manifestPath;
|
|
48
|
+
const coordKey = byLocation
|
|
49
|
+
? `${provenance}:${manifestPath}`
|
|
49
50
|
: coordKeyFor(ecosystem, namespace, name);
|
|
50
51
|
return {
|
|
51
52
|
ecosystem,
|
|
@@ -56,6 +57,8 @@ function makeDepRecord(input) {
|
|
|
56
57
|
versions: concrete ? [concrete] : [],
|
|
57
58
|
coordKey,
|
|
58
59
|
provenance,
|
|
60
|
+
hashes,
|
|
61
|
+
declaredName,
|
|
59
62
|
scope,
|
|
60
63
|
isDev: !!isDev,
|
|
61
64
|
manifestPaths,
|
package/lib/hash-id.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hash-id.js — identity-by-checksum for unmanaged artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Two known-good sources, tried in order:
|
|
5
|
+
* 1. deps.dev query-by-hash → exact package coordinate (whole published archive).
|
|
6
|
+
* 2. CIRCL hashlookup → known OS/distro/CDN/NSRL file + free KnownMalicious flag.
|
|
7
|
+
*
|
|
8
|
+
* Cache-backed (~/.fad-checker/hash-id-cache.json, 24h) and --offline-aware: offline
|
|
9
|
+
* reads cache only and never touches the network (project air-gapped principle).
|
|
10
|
+
*
|
|
11
|
+
* @author: N.BRAUN
|
|
12
|
+
* @email: pp9ping@gmail.com
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
|
|
18
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
19
|
+
const CACHE_PATH = path.join(CACHE_DIR, "hash-id-cache.json");
|
|
20
|
+
const CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
21
|
+
const DEPSDEV = "https://api.deps.dev/v3/query";
|
|
22
|
+
const CIRCL = "https://hashlookup.circl.lu/lookup/sha256";
|
|
23
|
+
|
|
24
|
+
const SYSTEM_TO_ECO = { MAVEN: "maven", NPM: "npm", NUGET: "nuget", PYPI: "pypi", RUBYGEMS: "ruby", CARGO: "cargo", GO: "go" };
|
|
25
|
+
|
|
26
|
+
function sha1ToBase64(hex) { return Buffer.from(hex, "hex").toString("base64"); }
|
|
27
|
+
|
|
28
|
+
function parseDepsDev(body) {
|
|
29
|
+
const vk = body?.results?.[0]?.version?.versionKey;
|
|
30
|
+
if (!vk?.name) return null;
|
|
31
|
+
return { ecosystem: SYSTEM_TO_ECO[vk.system] || (vk.system || "").toLowerCase() || null, name: vk.name, version: vk.version || null, source: "deps.dev" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseCircl(body) {
|
|
35
|
+
if (!body || body.message || !(body.FileName || body.ProductCode)) return null;
|
|
36
|
+
const malicious = Array.isArray(body.KnownMalicious) ? body.KnownMalicious.length > 0 : !!body.KnownMalicious;
|
|
37
|
+
return {
|
|
38
|
+
ecosystem: null,
|
|
39
|
+
name: body.ProductCode?.ProductName || body.FileName || null,
|
|
40
|
+
version: body.ProductCode?.ProductVersion || null,
|
|
41
|
+
source: `circl:${body.db || "hashlookup"}`,
|
|
42
|
+
trust: body["hashlookup:trust"] != null ? body["hashlookup:trust"] : null,
|
|
43
|
+
knownMalicious: malicious,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadCache() { try { const d = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); if (Date.now() - (d._fetchedAt || 0) < CACHE_TTL_MS) return d.entries || {}; } catch { /* ignore */ } return {}; }
|
|
48
|
+
function saveCache(entries) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify({ _fetchedAt: Date.now(), entries })); } catch { /* ignore */ } }
|
|
49
|
+
|
|
50
|
+
async function lookupHash(hashes, opts = {}) {
|
|
51
|
+
const { fetcher = globalThis.fetch, offline = false, cache } = opts;
|
|
52
|
+
const entries = cache || loadCache();
|
|
53
|
+
const key = hashes.sha256 || hashes.sha1;
|
|
54
|
+
if (!key) return null;
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(entries, key)) return entries[key];
|
|
56
|
+
if (offline) return null;
|
|
57
|
+
|
|
58
|
+
let identity = null;
|
|
59
|
+
// 1) deps.dev (SHA1 base64)
|
|
60
|
+
if (hashes.sha1) {
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetcher(`${DEPSDEV}?hash.type=SHA1&hash.value=${encodeURIComponent(sha1ToBase64(hashes.sha1))}`, { headers: { "User-Agent": "fad-checker-hashid" } });
|
|
63
|
+
if (r.ok) identity = parseDepsDev(await r.json());
|
|
64
|
+
} catch { /* ignore, try CIRCL */ }
|
|
65
|
+
}
|
|
66
|
+
// 2) CIRCL (SHA-256)
|
|
67
|
+
if (!identity && hashes.sha256) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetcher(`${CIRCL}/${hashes.sha256}`, { headers: { "User-Agent": "fad-checker-hashid", Accept: "application/json" } });
|
|
70
|
+
if (r.ok) identity = parseCircl(await r.json());
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
entries[key] = identity;
|
|
74
|
+
if (!cache) saveCache(entries);
|
|
75
|
+
return identity;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { sha1ToBase64, parseDepsDev, parseCircl, lookupHash, loadCache, saveCache, CACHE_PATH };
|
package/lib/json-export.js
CHANGED
|
@@ -65,10 +65,13 @@ 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
|
+
|
|
72
75
|
const sevCounts = Object.fromEntries(SEV.map(s => [s, 0]));
|
|
73
76
|
let kev = 0;
|
|
74
77
|
for (const m of cveMatches) {
|
|
@@ -90,6 +93,8 @@ function buildFindings(payload = {}) {
|
|
|
90
93
|
outdated: outdatedResults.length,
|
|
91
94
|
licensesFlagged: licenseResults?.flagged?.length || 0,
|
|
92
95
|
vendored: retireMatches.length,
|
|
96
|
+
unmanaged: unmanaged.length,
|
|
97
|
+
vendoredJs: vendoredJsInventory.length,
|
|
93
98
|
suppressed: cveMatches.filter(m => m.suppressed).length,
|
|
94
99
|
},
|
|
95
100
|
cve: cveMatches.map(cveFinding),
|
|
@@ -98,6 +103,8 @@ function buildFindings(payload = {}) {
|
|
|
98
103
|
obsolete: obsoleteResults.map(o => ({ reason: o.reason || null, replacement: o.replacement || null, source: o.source || null, dep: depBrief(o.dep) })),
|
|
99
104
|
outdated: outdatedResults.map(o => ({ latest: o.latest, releaseDate: o.releaseDate || null, dep: depBrief(o.dep) })),
|
|
100
105
|
licenses: (licenseResults?.assessed || []).map(e => ({ category: e.category, licenses: e.ids.concat(e.raw), source: e.source || null, dep: depBrief(e.dep) })),
|
|
106
|
+
unmanaged,
|
|
107
|
+
vendoredJs: vendoredJsInventory,
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
|
package/lib/license-policy.js
CHANGED
|
@@ -14,7 +14,9 @@ const path = require("path");
|
|
|
14
14
|
|
|
15
15
|
const POLICY = (() => {
|
|
16
16
|
try {
|
|
17
|
-
|
|
17
|
+
// require() (not fs.readFileSync) so bun --compile bundles the data file
|
|
18
|
+
// into the standalone binary instead of reading it off disk at runtime.
|
|
19
|
+
const raw = require("../data/license-policy.json");
|
|
18
20
|
return { categories: raw.categories || {}, aliases: raw.aliases || {} };
|
|
19
21
|
} catch { return { categories: {}, aliases: {} }; }
|
|
20
22
|
})();
|
|
@@ -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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/parallel-walk.js — bounded-concurrency recursive directory walk.
|
|
3
|
+
*
|
|
4
|
+
* A serial readdirSync walk is fine on a fast local disk, but on a high-latency
|
|
5
|
+
* filesystem (WSL/9p, network mounts, the air-gapped VM in the field) each readdir
|
|
6
|
+
* pays a round-trip, so a big tree takes tens of seconds per walk — and fad-checker
|
|
7
|
+
* walks the tree several times (detection, pom discovery, jar discovery, JS manifest
|
|
8
|
+
* discovery). Issuing many readdirs concurrently turns that latency-bound serial walk
|
|
9
|
+
* into something close to a single round-trip deep, which is dramatically faster.
|
|
10
|
+
*
|
|
11
|
+
* `onDir(absDir, entries)` is called once per visited directory with its Dirent[].
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* @author: N.BRAUN
|
|
16
|
+
* @email: pp9ping@gmail.com
|
|
17
|
+
*/
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONCURRENCY = 48;
|
|
22
|
+
|
|
23
|
+
async function walkDirs(root, { skipDir = () => false, concurrency = DEFAULT_CONCURRENCY, onDir } = {}) {
|
|
24
|
+
const queue = [root];
|
|
25
|
+
let active = 0;
|
|
26
|
+
await new Promise(resolve => {
|
|
27
|
+
let settled = false;
|
|
28
|
+
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
29
|
+
const pump = () => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
if (queue.length === 0 && active === 0) return finish();
|
|
32
|
+
while (active < concurrency && queue.length) {
|
|
33
|
+
const cur = queue.pop();
|
|
34
|
+
active++;
|
|
35
|
+
fs.promises.readdir(cur, { withFileTypes: true }).then(entries => {
|
|
36
|
+
if (onDir) onDir(cur, entries);
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (!e.isDirectory()) continue;
|
|
39
|
+
const child = path.join(cur, e.name);
|
|
40
|
+
if (!skipDir(child, e.name)) queue.push(child);
|
|
41
|
+
}
|
|
42
|
+
}).catch(() => { /* unreadable dir → skip, same as readdirSync catch */ })
|
|
43
|
+
.finally(() => { active--; pump(); });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
pump();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { walkDirs };
|
|
@@ -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
|
+
};
|