fad-checker 1.0.6 → 2.0.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 +62 -0
- package/README.md +77 -15
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/fad-checker.js +353 -241
- package/lib/codecs/codec.interface.js +27 -0
- package/lib/codecs/composer/parse.js +59 -0
- package/lib/codecs/composer/registry.js +92 -0
- package/lib/codecs/composer.codec.js +93 -0
- package/lib/codecs/index.js +37 -0
- package/lib/codecs/maven.codec.js +71 -0
- package/lib/{npm → codecs/npm}/collect.js +58 -35
- package/lib/{npm → codecs/npm}/parse.js +114 -10
- package/lib/{npm → codecs/npm}/registry.js +4 -3
- package/lib/codecs/npm.codec.js +52 -0
- package/lib/codecs/nuget/parse.js +75 -0
- package/lib/codecs/nuget/registry.js +88 -0
- package/lib/codecs/nuget.codec.js +94 -0
- package/lib/codecs/pypi/parse.js +163 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +102 -0
- package/lib/codecs/recipes.js +108 -0
- package/lib/codecs/select.js +27 -0
- package/lib/codecs/yarn.codec.js +19 -0
- package/lib/core.js +35 -1
- package/lib/cve-match.js +18 -11
- package/lib/cve-report.js +34 -70
- package/lib/dep-record.js +60 -0
- package/lib/deps-descriptor.js +110 -0
- package/lib/nvd.js +4 -3
- package/lib/osv.js +29 -18
- package/lib/outdated.js +20 -4
- package/lib/retire.js +77 -13
- package/lib/transitive.js +3 -3
- package/lib/ui.js +87 -0
- package/package.json +4 -2
- package/CLAUDE.md +0 -129
- package/docs/ARCHITECTURE.md +0 -154
- package/docs/USAGE.md +0 -179
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -171
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -9
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -146
- package/test/outdated.test.js +0 -101
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/dep-record.js — contrat de données unifié partagé par tous les codecs.
|
|
3
|
+
*
|
|
4
|
+
* `coordKey` est la clé de la Map résolue ; elle ne collisionne jamais entre
|
|
5
|
+
* écosystèmes grâce au préfixe `ecosystem:`. `groupId`/`artifactId`/`pomPaths`
|
|
6
|
+
* sont conservés comme alias rétro-compat le temps de migrer tous les
|
|
7
|
+
* consommateurs vers `namespace`/`name`/`manifestPaths`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// NuGet, Composer et PyPI sont case-insensitive : on normalise la clé en lower
|
|
11
|
+
// (l'affichage garde la casse d'origine via dep.name).
|
|
12
|
+
function normalizeForKey(ecosystem, s) {
|
|
13
|
+
if (ecosystem === "nuget" || ecosystem === "composer" || ecosystem === "pypi") return String(s).toLowerCase();
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// La clé de la Map résolue.
|
|
18
|
+
// - Maven : clé BRUTE "g:a" (pas de préfixe "maven:"). Collision-free face aux
|
|
19
|
+
// préfixes des autres écosystèmes (un groupId reverse-DNS ne commence jamais
|
|
20
|
+
// par "npm:"/"nuget:"/…), et ça garde transitive.js + les tests inchangés.
|
|
21
|
+
// - npm : "npm:<name>" où name peut inclure le scope (@org/pkg) — namespace reste "".
|
|
22
|
+
// - composer : "composer:vendor/pkg". pypi/nuget : "<eco>:<name>".
|
|
23
|
+
function coordKeyFor(ecosystem, namespace, name) {
|
|
24
|
+
if (ecosystem === "maven") {
|
|
25
|
+
const g = namespace || "";
|
|
26
|
+
return g ? `${g}:${name}` : name;
|
|
27
|
+
}
|
|
28
|
+
const ns = normalizeForKey(ecosystem, namespace || "");
|
|
29
|
+
const nm = normalizeForKey(ecosystem, name || "");
|
|
30
|
+
const joined = ns ? `${ns}/${nm}` : nm;
|
|
31
|
+
return `${ecosystem}:${joined}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeDepRecord(input) {
|
|
35
|
+
const { ecosystem, namespace = "", name, version = null, manifestPath, scope = "compile", isDev = false, ecosystemType } = input;
|
|
36
|
+
const concrete = version && !/\$\{/.test(version) ? version : null;
|
|
37
|
+
const manifestPaths = manifestPath ? [manifestPath] : [];
|
|
38
|
+
return {
|
|
39
|
+
ecosystem,
|
|
40
|
+
ecosystemType: ecosystemType || ecosystem,
|
|
41
|
+
namespace: namespace || "",
|
|
42
|
+
name,
|
|
43
|
+
version: version || null,
|
|
44
|
+
versions: concrete ? [concrete] : [],
|
|
45
|
+
coordKey: coordKeyFor(ecosystem, namespace, name),
|
|
46
|
+
scope,
|
|
47
|
+
isDev: !!isDev,
|
|
48
|
+
manifestPaths,
|
|
49
|
+
// Alias rétro-compat : CHAMPS DUPLIQUÉS RÉELS (pas des getters — les depRecords
|
|
50
|
+
// sont spreadés dans des chemins chauds : cve-match.js:175, scan-completeness.js,
|
|
51
|
+
// cve-report.js:1036, snyk.js:105 — un getter serait perdu au spread).
|
|
52
|
+
// groupId/artifactId sont des strings jamais réassignées → pas de dérive.
|
|
53
|
+
// pomPaths PARTAGE la référence de manifestPaths → les push restent synchrones.
|
|
54
|
+
groupId: namespace || "",
|
|
55
|
+
artifactId: name,
|
|
56
|
+
pomPaths: manifestPaths,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { makeDepRecord, coordKeyFor };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/deps-descriptor.js — serialize / deserialize an *anonymized* descriptor of
|
|
3
|
+
* the resolved dependency set, for PASSI-style offline→online→offline audits.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 (offline): collect deps → serializeDeps → write JSON. No paths, URLs,
|
|
6
|
+
* hostnames or usernames leave the air-gapped machine — only public package
|
|
7
|
+
* coordinates (ecosystem / namespace / name / version) + scope.
|
|
8
|
+
* Phase 2 (online): read JSON → deserializeDeps → run the scan flow to warm the
|
|
9
|
+
* coordinate-keyed caches, then `--export-cache`.
|
|
10
|
+
* Phase 3 (offline): `--import-cache` + a normal `--offline` scan re-collects the
|
|
11
|
+
* source locally (real paths) and gets cache hits → full detailed report.
|
|
12
|
+
*
|
|
13
|
+
* See docs/superpowers/specs/2026-05-30-anonymized-deps-descriptor-passi-design.md
|
|
14
|
+
*
|
|
15
|
+
* Pure functions: no I/O, no console. The caller does file read/write.
|
|
16
|
+
*/
|
|
17
|
+
const { makeDepRecord, coordKeyFor } = require("./dep-record");
|
|
18
|
+
|
|
19
|
+
const SCHEMA = "fad-deps/1";
|
|
20
|
+
|
|
21
|
+
// Fields kept in the descriptor — everything required to query the public vuln
|
|
22
|
+
// databases and to group the report. Anything path/URL/host-bearing is dropped:
|
|
23
|
+
// manifestPath(s) / pomPaths, resolved (registry URL), integrity, from
|
|
24
|
+
// (parent chain), depth, lockType.
|
|
25
|
+
const ANON_NOTE = "Anonymized: public coordinates only — no paths, URLs, or host info. Review before transfer.";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* serializeDeps(resolvedMap, opts) -> descriptor object (ready for JSON.stringify).
|
|
29
|
+
*
|
|
30
|
+
* opts:
|
|
31
|
+
* generator — tool id/version string for traceability (no env info)
|
|
32
|
+
* generatedAt — ISO timestamp (override for deterministic tests)
|
|
33
|
+
* note — human-readable banner (defaults to the anonymization notice)
|
|
34
|
+
*/
|
|
35
|
+
function serializeDeps(resolvedMap, opts = {}) {
|
|
36
|
+
const { generator = "fad-checker", generatedAt = new Date().toISOString(), note = ANON_NOTE } = opts;
|
|
37
|
+
const deps = [];
|
|
38
|
+
const byEcosystem = {};
|
|
39
|
+
for (const d of resolvedMap.values()) {
|
|
40
|
+
if (!d || !d.ecosystem || !d.name) continue;
|
|
41
|
+
const versions = Array.isArray(d.versions) && d.versions.length
|
|
42
|
+
? [...new Set(d.versions)]
|
|
43
|
+
: (d.version ? [d.version] : []);
|
|
44
|
+
deps.push({
|
|
45
|
+
ecosystem: d.ecosystem,
|
|
46
|
+
ecosystemType: d.ecosystemType || d.ecosystem,
|
|
47
|
+
namespace: d.namespace || "",
|
|
48
|
+
name: d.name,
|
|
49
|
+
version: d.version || (versions[0] || null),
|
|
50
|
+
versions,
|
|
51
|
+
scope: d.scope || "prod",
|
|
52
|
+
isDev: !!d.isDev,
|
|
53
|
+
});
|
|
54
|
+
byEcosystem[d.ecosystem] = (byEcosystem[d.ecosystem] || 0) + 1;
|
|
55
|
+
}
|
|
56
|
+
// Stable order so two runs over the same tree produce identical files (easier
|
|
57
|
+
// to diff/review and to detect tampering during transfer).
|
|
58
|
+
deps.sort((a, b) => (a.ecosystem + "\0" + a.namespace + "\0" + a.name).localeCompare(b.ecosystem + "\0" + b.namespace + "\0" + b.name));
|
|
59
|
+
return {
|
|
60
|
+
schema: SCHEMA,
|
|
61
|
+
generator,
|
|
62
|
+
generatedAt,
|
|
63
|
+
note,
|
|
64
|
+
summary: { total: deps.length, byEcosystem },
|
|
65
|
+
deps,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* deserializeDeps(descriptor) -> { resolved, activeIds, runMaven, runNpm }.
|
|
71
|
+
*
|
|
72
|
+
* Rebuilds depRecords with EMPTY manifestPaths (the descriptor carries none) and
|
|
73
|
+
* a RECOMPUTED coordKey (never trusts the input). Throws on schema mismatch.
|
|
74
|
+
*/
|
|
75
|
+
function deserializeDeps(descriptor) {
|
|
76
|
+
if (!descriptor || typeof descriptor !== "object") throw new Error("invalid descriptor: not an object");
|
|
77
|
+
if (descriptor.schema !== SCHEMA) {
|
|
78
|
+
throw new Error(`unsupported descriptor schema: got "${descriptor.schema}", expected "${SCHEMA}"`);
|
|
79
|
+
}
|
|
80
|
+
const list = Array.isArray(descriptor.deps) ? descriptor.deps : [];
|
|
81
|
+
const resolved = new Map();
|
|
82
|
+
const ecosystems = new Set();
|
|
83
|
+
for (const e of list) {
|
|
84
|
+
if (!e || !e.ecosystem || !e.name) continue;
|
|
85
|
+
const rec = makeDepRecord({
|
|
86
|
+
ecosystem: e.ecosystem,
|
|
87
|
+
ecosystemType: e.ecosystemType || e.ecosystem,
|
|
88
|
+
namespace: e.namespace || "",
|
|
89
|
+
name: e.name,
|
|
90
|
+
version: e.version || null,
|
|
91
|
+
scope: e.scope || "prod",
|
|
92
|
+
isDev: !!e.isDev,
|
|
93
|
+
// manifestPath intentionally omitted → manifestPaths/pomPaths = []
|
|
94
|
+
});
|
|
95
|
+
// Restore the full multi-version set (makeDepRecord only derives it from the
|
|
96
|
+
// single `version`); CVE/OSV matching iterates `versions`.
|
|
97
|
+
if (Array.isArray(e.versions) && e.versions.length) rec.versions = [...new Set(e.versions)];
|
|
98
|
+
resolved.set(coordKeyFor(e.ecosystem, e.namespace || "", e.name), rec);
|
|
99
|
+
ecosystems.add(e.ecosystem);
|
|
100
|
+
}
|
|
101
|
+
const activeIds = [...ecosystems];
|
|
102
|
+
return {
|
|
103
|
+
resolved,
|
|
104
|
+
activeIds,
|
|
105
|
+
runMaven: ecosystems.has("maven"),
|
|
106
|
+
runNpm: ecosystems.has("npm") || ecosystems.has("yarn"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { serializeDeps, deserializeDeps, SCHEMA, ANON_NOTE };
|
package/lib/nvd.js
CHANGED
|
@@ -191,7 +191,7 @@ async function fetchOne(cveId, opts = {}) {
|
|
|
191
191
|
* Rate limited per the NIST policy (use NVD_API_KEY for faster access).
|
|
192
192
|
*/
|
|
193
193
|
async function enrichMatches(matches, opts = {}) {
|
|
194
|
-
const { offline } = opts;
|
|
194
|
+
const { offline, onProgress } = opts;
|
|
195
195
|
const uniqueCves = new Set();
|
|
196
196
|
for (const m of matches) if (m.cve?.id?.startsWith("CVE-")) uniqueCves.add(m.cve.id);
|
|
197
197
|
const hasKey = !!getNvdApiKey();
|
|
@@ -212,13 +212,13 @@ async function enrichMatches(matches, opts = {}) {
|
|
|
212
212
|
liveIds.push(cveId);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
if (liveIds.length) {
|
|
215
|
+
if (liveIds.length && !onProgress) {
|
|
216
216
|
const etaSec = Math.ceil((liveIds.length / burst) * (NVD_WINDOW_MS / 1000));
|
|
217
217
|
const keyHint = hasKey ? "with API key (50/30s)" : "no API key — throttled to 5/30s, run `fad-checker --set-nvd-key <KEY>` (free, instant) to be 10× faster";
|
|
218
218
|
const cachedCount = byId.size;
|
|
219
219
|
const cachedHint = cachedCount ? `, ${cachedCount} cached` : "";
|
|
220
220
|
console.log(`🔍 NVD: enriching ${liveIds.length} CVEs${cachedHint} — ${keyHint}. ETA ~${etaSec}s.`);
|
|
221
|
-
} else if (uniqueCves.size) {
|
|
221
|
+
} else if (uniqueCves.size && !onProgress) {
|
|
222
222
|
console.log(`🔍 NVD: ${uniqueCves.size} CVEs (all cached).`);
|
|
223
223
|
}
|
|
224
224
|
|
|
@@ -229,6 +229,7 @@ async function enrichMatches(matches, opts = {}) {
|
|
|
229
229
|
const startedAt = [];
|
|
230
230
|
const startProgress = Date.now();
|
|
231
231
|
const printProgress = (final = false) => {
|
|
232
|
+
if (onProgress) { onProgress(done, liveIds.length); return; }
|
|
232
233
|
const elapsed = Math.round((Date.now() - startProgress) / 1000);
|
|
233
234
|
const pct = liveIds.length ? Math.round((done / liveIds.length) * 100) : 100;
|
|
234
235
|
const line = ` NVD: ${done}/${liveIds.length} (${pct}%) — ${elapsed}s elapsed`;
|
package/lib/osv.js
CHANGED
|
@@ -96,8 +96,18 @@ function pickPrimaryId(vuln) {
|
|
|
96
96
|
return cveAlias || vuln.id;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
// OSV ecosystem name per codec id. OSV.dev natively supports all of these.
|
|
100
|
+
const OSV_ECO = { maven: "Maven", npm: "npm", yarn: "npm", nuget: "NuGet", composer: "Packagist", pypi: "PyPI" };
|
|
101
|
+
|
|
102
|
+
/** OSV ecosystem string for a dep, derived from its codec id. */
|
|
103
|
+
function osvEcosystemFor(dep) { return OSV_ECO[dep.ecosystem] || "Maven"; }
|
|
104
|
+
|
|
105
|
+
/** Build the OSV package-name key for a dep — delegated to the dep's codec. */
|
|
100
106
|
function osvPkgName(dep) {
|
|
107
|
+
const { getCodec } = require("./codecs");
|
|
108
|
+
const c = getCodec(dep.ecosystem);
|
|
109
|
+
if (c) return c.osvPackageName(dep);
|
|
110
|
+
// Fallback historique si le codec est introuvable.
|
|
101
111
|
return dep.ecosystem === "npm" ? dep.artifactId : `${dep.groupId}:${dep.artifactId}`;
|
|
102
112
|
}
|
|
103
113
|
|
|
@@ -142,7 +152,7 @@ function vulnToMatch(dep, vuln) {
|
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
async function queryBatch(deps, opts = {}) {
|
|
145
|
-
const { verbose, offline, fetcher = globalThis.fetch } = opts;
|
|
155
|
+
const { verbose, offline, fetcher = globalThis.fetch, onProgress } = opts;
|
|
146
156
|
// Build query list + parallel cached/uncached split
|
|
147
157
|
const queries = [];
|
|
148
158
|
const indexMap = []; // index in `deps` → either { cached: vulns } or { queryIdx: N }
|
|
@@ -155,11 +165,10 @@ async function queryBatch(deps, opts = {}) {
|
|
|
155
165
|
indexMap[i] = { cached: hit };
|
|
156
166
|
} else {
|
|
157
167
|
indexMap[i] = { queryIdx: queries.length, cacheKey: ck };
|
|
158
|
-
// Ecosystem
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const pkgName = ecosystem === "npm" ? d.artifactId : `${d.groupId}:${d.artifactId}`;
|
|
168
|
+
// Ecosystem + package name are both derived from the dep's codec, so
|
|
169
|
+
// adding a new ecosystem (NuGet/Packagist/PyPI) needs no change here.
|
|
170
|
+
const ecosystem = osvEcosystemFor(d);
|
|
171
|
+
const pkgName = osvPkgName(d);
|
|
163
172
|
queries.push({
|
|
164
173
|
package: { name: pkgName, ecosystem },
|
|
165
174
|
version: d.version,
|
|
@@ -178,7 +187,8 @@ async function queryBatch(deps, opts = {}) {
|
|
|
178
187
|
let batchIdx = 0;
|
|
179
188
|
for (const batch of batches) {
|
|
180
189
|
batchIdx++;
|
|
181
|
-
if (
|
|
190
|
+
if (onProgress) onProgress(batchIdx, batches.length);
|
|
191
|
+
else if (process.stdout.isTTY) process.stdout.write(`\r OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)… `);
|
|
182
192
|
else if (batches.length > 1) console.log(` OSV batch ${batchIdx}/${batches.length} (${batch.length} deps)…`);
|
|
183
193
|
const res = await fetcher(`${OSV_BASE}/v1/querybatch`, {
|
|
184
194
|
method: "POST",
|
|
@@ -196,7 +206,8 @@ async function queryBatch(deps, opts = {}) {
|
|
|
196
206
|
allResults[(batchIdx - 1) * BATCH_SIZE + j] = results[j] || { vulns: [] };
|
|
197
207
|
}
|
|
198
208
|
}
|
|
199
|
-
if (
|
|
209
|
+
if (onProgress) { /* finalized by caller */ }
|
|
210
|
+
else if (process.stdout.isTTY) process.stdout.write(`\r OSV batches complete (${batches.length}) \n`);
|
|
200
211
|
|
|
201
212
|
// Persist per-dep cache (stub list — details cached separately)
|
|
202
213
|
for (let i = 0; i < deps.length; i++) {
|
|
@@ -230,12 +241,13 @@ async function queryBatch(deps, opts = {}) {
|
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
if (liveIds.length) {
|
|
233
|
-
console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
|
|
244
|
+
if (!onProgress) console.log(` OSV details: ${liveIds.length} to fetch${allIds.size - liveIds.length ? `, ${allIds.size - liveIds.length} cached` : ""}…`);
|
|
234
245
|
const concurrency = 10;
|
|
235
246
|
let cursor = 0;
|
|
236
247
|
let fetched = 0;
|
|
237
248
|
const startedAt = Date.now();
|
|
238
249
|
const printOsvProgress = (final = false) => {
|
|
250
|
+
if (onProgress) { onProgress(fetched, liveIds.length); return; }
|
|
239
251
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
240
252
|
const pct = Math.round((fetched / liveIds.length) * 100);
|
|
241
253
|
const line = ` OSV details: ${fetched}/${liveIds.length} (${pct}%) — ${elapsed}s`;
|
|
@@ -306,14 +318,11 @@ async function queryOsvForDeps(resolvedDeps, opts = {}) {
|
|
|
306
318
|
// need a concrete version. Lockfiles always provide one; package.json-
|
|
307
319
|
// only deps are skipped here.
|
|
308
320
|
if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
ecosystemType: d.ecosystemType,
|
|
315
|
-
isDev: !!d.isDev,
|
|
316
|
-
});
|
|
321
|
+
// Spread the whole depRecord (carries namespace/name/coordKey, which the
|
|
322
|
+
// codec's osvPackageName + the report's dedup rely on) and override only
|
|
323
|
+
// the version for this per-version query. Cherry-picking fields here used
|
|
324
|
+
// to drop name/namespace → OSV queried `undefined` for composer/pypi/nuget.
|
|
325
|
+
deps.push({ ...d, version: ver });
|
|
317
326
|
}
|
|
318
327
|
}
|
|
319
328
|
return queryBatch(deps, opts);
|
|
@@ -325,5 +334,7 @@ module.exports = {
|
|
|
325
334
|
vulnToMatch,
|
|
326
335
|
severityFromOsv,
|
|
327
336
|
fixVersionFromOsv,
|
|
337
|
+
osvEcosystemFor,
|
|
338
|
+
osvPkgName,
|
|
328
339
|
OSV_CACHE_DIR,
|
|
329
340
|
};
|
package/lib/outdated.js
CHANGED
|
@@ -9,7 +9,7 @@ const fs = require("fs");
|
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const os = require("os");
|
|
11
11
|
const { compareMavenVersions } = require("./maven-version");
|
|
12
|
-
const { webjarToNpm } = require("./npm/collect");
|
|
12
|
+
const { webjarToNpm } = require("./codecs/npm/collect");
|
|
13
13
|
|
|
14
14
|
const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
|
|
15
15
|
const EOL_MAPPING = require("../data/eol-mapping.json");
|
|
@@ -57,6 +57,16 @@ function findEolProduct(dep) {
|
|
|
57
57
|
}
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
|
+
if (dep.ecosystem === "composer") {
|
|
61
|
+
const full = `${dep.namespace || dep.groupId || ""}/${dep.name || dep.artifactId}`.toLowerCase();
|
|
62
|
+
return EOL_MAPPING.by_composer_name?.[full] || null;
|
|
63
|
+
}
|
|
64
|
+
if (dep.ecosystem === "pypi") {
|
|
65
|
+
return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
|
|
66
|
+
}
|
|
67
|
+
if (dep.ecosystem === "nuget") {
|
|
68
|
+
return EOL_MAPPING.by_nuget_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
|
|
69
|
+
}
|
|
60
70
|
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
61
71
|
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
62
72
|
if (direct) return direct;
|
|
@@ -241,18 +251,19 @@ function parseMavenMetadataLatest(xml) {
|
|
|
241
251
|
}
|
|
242
252
|
|
|
243
253
|
async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
244
|
-
const { verbose, offline, concurrency = 8, repos } = opts;
|
|
254
|
+
const { verbose, offline, concurrency = 8, repos, onProgress } = opts;
|
|
245
255
|
const cache = loadJsonCache(VERSION_CACHE_PATH);
|
|
246
256
|
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < VERSION_CACHE_MAX_AGE_MS;
|
|
247
257
|
if (!fresh && !offline) cache.entries = {};
|
|
248
|
-
|
|
258
|
+
// Maven Central only — npm/composer/pypi/nuget have their own registries (codec.checkRegistry).
|
|
259
|
+
const list = [...resolvedDeps.values()].filter(d => d.version && !/\$\{|SNAPSHOT/i.test(d.version) && d.ecosystem === "maven");
|
|
249
260
|
const results = [];
|
|
250
261
|
|
|
251
262
|
// Progress indicator — Maven Central can serve hundreds of deps in a few
|
|
252
263
|
// seconds with 8-way concurrency, but on first run (cold cache) the user
|
|
253
264
|
// would see total silence for 20-60s.
|
|
254
265
|
const liveCount = offline ? 0 : list.filter(d => !cache.entries[`${d.groupId}:${d.artifactId}`]).length;
|
|
255
|
-
if (liveCount && !offline) {
|
|
266
|
+
if (liveCount && !offline && !onProgress) {
|
|
256
267
|
console.log(`📅 Outdated: checking ${list.length} deps against Maven Central (${liveCount} live, ${list.length - liveCount} cached)…`);
|
|
257
268
|
}
|
|
258
269
|
|
|
@@ -260,6 +271,7 @@ async function checkOutdatedDeps(resolvedDeps, opts = {}) {
|
|
|
260
271
|
let processed = 0;
|
|
261
272
|
const startedAt = Date.now();
|
|
262
273
|
const printOutdatedProgress = (final = false) => {
|
|
274
|
+
if (onProgress) { onProgress(processed, list.length); return; }
|
|
263
275
|
if (!liveCount) return;
|
|
264
276
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
265
277
|
const pct = Math.round((processed / list.length) * 100);
|
|
@@ -297,6 +309,10 @@ module.exports = {
|
|
|
297
309
|
checkObsolete,
|
|
298
310
|
checkOutdatedDeps,
|
|
299
311
|
findEolProduct,
|
|
312
|
+
findCycleForVersion,
|
|
313
|
+
isEol,
|
|
300
314
|
isEolCacheFresh,
|
|
315
|
+
EOL_MAPPING,
|
|
316
|
+
EOL_CACHE_PATH,
|
|
301
317
|
KNOWN_OBSOLETE,
|
|
302
318
|
};
|
package/lib/retire.js
CHANGED
|
@@ -24,6 +24,15 @@ const execFileP = promisify(execFile);
|
|
|
24
24
|
const RETIRE_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "retire-cache");
|
|
25
25
|
const RETIRE_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
26
26
|
|
|
27
|
+
// retire's own signature DB. By default retire caches it in /tmp/.retire-cache
|
|
28
|
+
// (outside ~/.fad-checker/, with a 1h TTL → a network refetch on expiry). For the
|
|
29
|
+
// PASSI offline workflow we instead keep a stable local copy INSIDE ~/.fad-checker/
|
|
30
|
+
// so `--export-cache` carries it, and feed it to retire via `--jsrepo <file>`
|
|
31
|
+
// (loaded from file, never the network — no TTL).
|
|
32
|
+
const RETIRE_SIG_DIR = path.join(os.homedir(), ".fad-checker", "retire-signatures");
|
|
33
|
+
const RETIRE_SIG_FILE = path.join(RETIRE_SIG_DIR, "jsrepository-v5.json");
|
|
34
|
+
const RETIRE_REPO_URL = "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v5.json";
|
|
35
|
+
|
|
27
36
|
function cacheKey(srcDir) {
|
|
28
37
|
return crypto.createHash("md5").update(path.resolve(srcDir)).digest("hex") + ".json";
|
|
29
38
|
}
|
|
@@ -50,6 +59,54 @@ function findRetireBin() {
|
|
|
50
59
|
return "retire"; // fall back to PATH
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Fetch retire's signature DB to a stable file inside ~/.fad-checker/ so it can
|
|
64
|
+
* be bundled by --export-cache and reused offline via --jsrepo. Network call —
|
|
65
|
+
* online only. Returns { ok, path } | { ok:false, error }. Never throws.
|
|
66
|
+
*/
|
|
67
|
+
async function warmRetireSignatures(opts = {}) {
|
|
68
|
+
const { verbose, force } = opts;
|
|
69
|
+
if (!force && fs.existsSync(RETIRE_SIG_FILE)) {
|
|
70
|
+
if (verbose) console.log(`retire: signatures already present (${RETIRE_SIG_FILE})`);
|
|
71
|
+
return { ok: true, path: RETIRE_SIG_FILE, cached: true };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(RETIRE_REPO_URL);
|
|
75
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
JSON.parse(text); // validate it's the repo JSON before persisting
|
|
78
|
+
fs.mkdirSync(RETIRE_SIG_DIR, { recursive: true });
|
|
79
|
+
fs.writeFileSync(RETIRE_SIG_FILE, text);
|
|
80
|
+
if (verbose) console.log(`retire: signatures warmed → ${RETIRE_SIG_FILE}`);
|
|
81
|
+
return { ok: true, path: RETIRE_SIG_FILE };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (verbose) console.warn(`retire: signature warm failed — ${e.message}`);
|
|
84
|
+
return { ok: false, error: e.message };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ensure a local signature file exists. Online: fetch it if missing. Offline:
|
|
89
|
+
// only report whether it's already there (no network).
|
|
90
|
+
async function ensureSignatures({ verbose, offline } = {}) {
|
|
91
|
+
if (fs.existsSync(RETIRE_SIG_FILE)) return true;
|
|
92
|
+
if (offline) return false;
|
|
93
|
+
const r = await warmRetireSignatures({ verbose });
|
|
94
|
+
return !!r.ok;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pure: build the retire CLI argv. `jsRepo` (a local signature file) is added
|
|
98
|
+
// when available so retire loads signatures from disk instead of the network.
|
|
99
|
+
function buildRetireArgs({ srcDir, outPath, ignoredDirs, jsRepo }) {
|
|
100
|
+
const args = [
|
|
101
|
+
"--outputformat", "json",
|
|
102
|
+
"--outputpath", outPath,
|
|
103
|
+
"--jspath", srcDir,
|
|
104
|
+
"--ignore", ignoredDirs,
|
|
105
|
+
];
|
|
106
|
+
if (jsRepo) { args.push("--jsrepo", jsRepo); }
|
|
107
|
+
return args;
|
|
108
|
+
}
|
|
109
|
+
|
|
53
110
|
/**
|
|
54
111
|
* Run retire.js against `srcDir`. Returns the parsed JSON output (an array
|
|
55
112
|
* of file-level findings) or null if retire is unavailable.
|
|
@@ -59,12 +116,9 @@ function findRetireBin() {
|
|
|
59
116
|
*/
|
|
60
117
|
async function runRetire(srcDir, opts = {}) {
|
|
61
118
|
const { verbose, force, offline } = opts;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (verbose) console.warn("retire: --offline and no cache — skipped");
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
119
|
+
// No source tree (e.g. --import-anonymized) → nothing to scan for vendored JS.
|
|
120
|
+
if (!srcDir) { if (verbose) console.warn("retire: no source dir — skipped"); return null; }
|
|
121
|
+
// Findings-cache fast path (path-keyed). Works online and offline.
|
|
68
122
|
if (!force) {
|
|
69
123
|
const cached = readCache(srcDir);
|
|
70
124
|
if (cached) {
|
|
@@ -73,6 +127,15 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
73
127
|
}
|
|
74
128
|
}
|
|
75
129
|
|
|
130
|
+
// No findings cache → we must scan. We need a signature DB. Offline, we can
|
|
131
|
+
// only proceed if a local signature file was previously warmed (and bundled
|
|
132
|
+
// via --export-cache / --import-cache); otherwise honor --offline and skip.
|
|
133
|
+
const haveSig = await ensureSignatures({ verbose, offline });
|
|
134
|
+
if (offline && !haveSig) {
|
|
135
|
+
if (verbose) console.warn("retire: --offline, no findings cache and no local signatures — skipped");
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
const bin = findRetireBin();
|
|
77
140
|
const ignoredDirs = [
|
|
78
141
|
"node_modules", "bower_components", "jspm_packages",
|
|
@@ -83,13 +146,8 @@ async function runRetire(srcDir, opts = {}) {
|
|
|
83
146
|
// retire.js refuses to write to /dev/stdout, so we use a real temp file
|
|
84
147
|
// and read it back. Falls back to stdout if --outputpath is rejected.
|
|
85
148
|
const tmpOut = path.join(os.tmpdir(), `fad-checker-retire-${process.pid}-${Date.now()}.json`);
|
|
86
|
-
const args =
|
|
87
|
-
|
|
88
|
-
"--outputpath", tmpOut,
|
|
89
|
-
"--jspath", srcDir,
|
|
90
|
-
"--ignore", ignoredDirs,
|
|
91
|
-
];
|
|
92
|
-
if (verbose) console.log(`retire: scanning ${srcDir}…`);
|
|
149
|
+
const args = buildRetireArgs({ srcDir, outPath: tmpOut, ignoredDirs, jsRepo: haveSig ? RETIRE_SIG_FILE : null });
|
|
150
|
+
if (verbose) console.log(`retire: scanning ${srcDir}…${haveSig ? " (local signatures)" : ""}`);
|
|
93
151
|
|
|
94
152
|
try {
|
|
95
153
|
// retire.js exits with code 13 when it finds vulnerabilities — that's
|
|
@@ -207,5 +265,11 @@ module.exports = {
|
|
|
207
265
|
runRetire,
|
|
208
266
|
normaliseRetireResults,
|
|
209
267
|
findRetireBin,
|
|
268
|
+
warmRetireSignatures,
|
|
269
|
+
ensureSignatures,
|
|
270
|
+
buildRetireArgs,
|
|
210
271
|
RETIRE_CACHE_DIR,
|
|
272
|
+
RETIRE_SIG_DIR,
|
|
273
|
+
RETIRE_SIG_FILE,
|
|
274
|
+
RETIRE_REPO_URL,
|
|
211
275
|
};
|
package/lib/transitive.js
CHANGED
|
@@ -396,12 +396,12 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
396
396
|
// Progress visible whenever stdout is a TTY — transitive resolution
|
|
397
397
|
// dominates wall-clock time on first run and used to look like a hang.
|
|
398
398
|
// On non-TTY (pipes, CI, tests) we skip the \r-overwrite spam.
|
|
399
|
-
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length - head} `);
|
|
399
|
+
if (verbose && process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length - head} `);
|
|
400
400
|
}
|
|
401
401
|
});
|
|
402
402
|
await Promise.all(workers);
|
|
403
|
-
if (process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives \n`);
|
|
404
|
-
else if (out.size) console.log(` resolved ${out.size} transitives`);
|
|
403
|
+
if (verbose && process.stdout.isTTY) process.stdout.write(`\r resolved ${out.size} transitives \n`);
|
|
404
|
+
else if (verbose && out.size) console.log(` resolved ${out.size} transitives`);
|
|
405
405
|
|
|
406
406
|
return out;
|
|
407
407
|
}
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ui.js — shared CLI presentation: banner, section headers, severity colors,
|
|
3
|
+
* and a global step progress indicator for the cache/database update phase.
|
|
4
|
+
*
|
|
5
|
+
* The Progress indicator renders a "[n/N] <spinner> label — summary" checklist.
|
|
6
|
+
* TTY: one animated line per step, rewritten in place, finalized with ✓/⊘/✗.
|
|
7
|
+
* Non-TTY (pipes, CI, files): a single plain line per finished step, no escapes.
|
|
8
|
+
*/
|
|
9
|
+
const chalk = require("chalk");
|
|
10
|
+
|
|
11
|
+
const isTTY = !!(process.stdout && process.stdout.isTTY) && process.env.TERM !== "dumb";
|
|
12
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
13
|
+
|
|
14
|
+
const TITLE_A = "fad-checker";
|
|
15
|
+
const TITLE_B = "Autonomous Dependency Checker";
|
|
16
|
+
|
|
17
|
+
function banner() {
|
|
18
|
+
const raw = `${TITLE_A} · ${TITLE_B}`;
|
|
19
|
+
const bar = "─".repeat(raw.length + 2);
|
|
20
|
+
console.log(chalk.cyan(`\n╭${bar}╮`));
|
|
21
|
+
console.log(chalk.cyan("│ ") + chalk.bold.white(TITLE_A) + chalk.cyan(" · ") + chalk.whiteBright(TITLE_B) + chalk.cyan(" │"));
|
|
22
|
+
console.log(chalk.cyan(`╰${bar}╯`));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function section(title) {
|
|
26
|
+
console.log(chalk.bold.cyan("\n▸ ") + chalk.bold(title));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// " label value" aligned key/value line under a section.
|
|
30
|
+
function kv(label, value, { pad = 10 } = {}) {
|
|
31
|
+
console.log(" " + chalk.dim(String(label).padEnd(pad)) + " " + value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ok(msg) { console.log(" " + chalk.green("✓") + " " + msg); }
|
|
35
|
+
function warn(msg) { console.log(" " + chalk.yellow("⚠") + " " + msg); }
|
|
36
|
+
function info(msg) { console.log(" " + chalk.dim("·") + " " + msg); }
|
|
37
|
+
|
|
38
|
+
function sevColor(sev) {
|
|
39
|
+
switch (String(sev || "").toUpperCase()) {
|
|
40
|
+
case "CRITICAL": return chalk.bold.red;
|
|
41
|
+
case "HIGH": return chalk.red;
|
|
42
|
+
case "MEDIUM": return chalk.yellow;
|
|
43
|
+
case "LOW": return chalk.blue;
|
|
44
|
+
default: return chalk.gray;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class Step {
|
|
49
|
+
constructor(n, total, label) {
|
|
50
|
+
this.n = n; this.total = total; this.label = label;
|
|
51
|
+
this.live = ""; this.frame = 0; this.timer = null; this.ended = false;
|
|
52
|
+
this.prefix = chalk.dim(`[${n}/${total}]`);
|
|
53
|
+
if (isTTY) {
|
|
54
|
+
this._render();
|
|
55
|
+
this.timer = setInterval(() => { this.frame++; this._render(); }, 80);
|
|
56
|
+
if (this.timer.unref) this.timer.unref();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
_render() {
|
|
60
|
+
if (!isTTY || this.ended) return;
|
|
61
|
+
const sp = chalk.cyan(SPINNER[this.frame % SPINNER.length]);
|
|
62
|
+
const live = this.live ? chalk.dim(" " + this.live) : chalk.dim(" …");
|
|
63
|
+
process.stdout.write(`\r ${this.prefix} ${sp} ${this.label}${live}\x1b[K`);
|
|
64
|
+
}
|
|
65
|
+
tick(processed, total) {
|
|
66
|
+
this.live = total ? `${processed}/${total}` : String(processed || "");
|
|
67
|
+
// rendering is driven by the timer; nothing to print on non-TTY
|
|
68
|
+
}
|
|
69
|
+
_finalize(symbol, color, summary) {
|
|
70
|
+
if (this.ended) return;
|
|
71
|
+
this.ended = true;
|
|
72
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
73
|
+
const line = ` ${this.prefix} ${color(symbol)} ${this.label}` + (summary ? chalk.dim(" — " + summary) : "");
|
|
74
|
+
if (isTTY) process.stdout.write(`\r${line}\x1b[K\n`);
|
|
75
|
+
else console.log(line);
|
|
76
|
+
}
|
|
77
|
+
done(summary) { this._finalize("✓", chalk.green, summary); }
|
|
78
|
+
skip(reason) { this._finalize("⊘", chalk.gray, reason); }
|
|
79
|
+
fail(msg) { this._finalize("✗", chalk.red, msg); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class Progress {
|
|
83
|
+
constructor(total) { this.total = total || 0; this.n = 0; }
|
|
84
|
+
start(label) { this.n += 1; return new Step(this.n, this.total, label); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { banner, section, kv, ok, warn, info, sevColor, Progress, isTTY };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Scan ALL Maven, npm, Yarn, Composer, Python & C#/.NET dependencies in a source tree ONE SHOT without mvn/python/etc, deal with private deps, multi mvn modules, EOL, obsolete, outdated deps & give fix recos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "https://github.com/n8tz/fad-checker",
|
|
7
7
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"chalk": "^4.1.2",
|
|
27
27
|
"commander": "^14.0.1",
|
|
28
|
+
"js-yaml": "^4.1.1",
|
|
28
29
|
"p-limit": "^3.1.0",
|
|
29
30
|
"retire": "^5.4.2",
|
|
30
31
|
"rimraf": "^6.0.1",
|
|
32
|
+
"smol-toml": "^1.6.1",
|
|
31
33
|
"xml2js": "^0.6.2"
|
|
32
34
|
},
|
|
33
35
|
"engines": {
|