fad-checker 1.0.5 → 2.0.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 +47 -0
- package/CLAUDE.md +28 -9
- package/README.md +27 -13
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/docs/ARCHITECTURE.md +35 -7
- package/docs/USAGE.md +15 -7
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
- package/fad-checker.js +109 -83
- 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 +32 -18
- 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 +92 -0
- package/lib/codecs/pypi/parse.js +71 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +81 -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/cve-match.js +30 -14
- package/lib/cve-report.js +130 -83
- package/lib/dep-record.js +60 -0
- package/lib/osv.js +33 -19
- package/lib/outdated.js +13 -2
- package/package.json +3 -2
- package/test/cli-ecosystem.test.js +30 -0
- package/test/codec-edge-cases.test.js +131 -0
- package/test/codec-integration.test.js +119 -0
- package/test/codecs.test.js +92 -0
- package/test/composer.test.js +71 -0
- package/test/cve-match.test.js +40 -0
- package/test/cve-report.test.js +9 -0
- package/test/dep-record.test.js +31 -0
- package/test/fixtures/csharp-config/packages.config +4 -0
- package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
- package/test/fixtures/csharp-csproj/app.csproj +7 -0
- package/test/fixtures/csharp-lock/packages.lock.json +6 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
- package/test/fixtures/php-app/composer.json +5 -0
- package/test/fixtures/php-app/composer.lock +10 -0
- package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
- package/test/fixtures/polyglot/js/package-lock.json +1 -0
- package/test/fixtures/polyglot/js/package.json +1 -0
- package/test/fixtures/polyglot/mvn/pom.xml +7 -0
- package/test/fixtures/polyglot/php/composer.lock +1 -0
- package/test/fixtures/polyglot/py/poetry.lock +3 -0
- package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
- package/test/fixtures/python-poetry/poetry.lock +7 -0
- package/test/fixtures/python-reqs/requirements.txt +5 -0
- package/test/fixtures/python-uv/uv.lock +3 -0
- package/test/monorepo.test.js +1 -1
- package/test/npm-registry.test.js +1 -1
- package/test/npm.test.js +8 -4
- package/test/nuget.test.js +66 -0
- package/test/osv.test.js +62 -0
- package/test/pypi.test.js +72 -0
- package/test/webjar.test.js +1 -1
- /package/lib/{npm → codecs/npm}/parse.js +0 -0
- /package/lib/{npm → codecs/npm}/registry.js +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/composer/registry.js — query Packagist for a package's latest stable
|
|
3
|
+
* version and its `abandoned` flag (≈ npm `deprecated`).
|
|
4
|
+
*
|
|
5
|
+
* API: https://packagist.org/packages/{vendor}/{pkg}.json
|
|
6
|
+
* → { package: { abandoned: bool|string, versions: { "<v>": {...} } } }
|
|
7
|
+
*
|
|
8
|
+
* Cached in ~/.fad-checker/packagist-cache.json for 24h.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
|
|
14
|
+
|
|
15
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
16
|
+
const CACHE_PATH = path.join(CACHE_DIR, "packagist-cache.json");
|
|
17
|
+
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
18
|
+
const API = "https://packagist.org/packages";
|
|
19
|
+
|
|
20
|
+
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
|
|
21
|
+
function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
|
|
22
|
+
|
|
23
|
+
function isStable(v) { return /^\d+(\.\d+)*$/.test(String(v || "").replace(/^v/, "")); }
|
|
24
|
+
function cmp(a, b) {
|
|
25
|
+
const pa = String(a).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
26
|
+
const pb = String(b).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
27
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Extract {abandoned, outdated} from a Packagist `package` object.
|
|
32
|
+
function packagistToFindings(pkg, { version }) {
|
|
33
|
+
const out = { abandoned: null, outdated: null };
|
|
34
|
+
if (pkg.abandoned === true) out.abandoned = { replacement: null };
|
|
35
|
+
else if (typeof pkg.abandoned === "string") out.abandoned = { replacement: pkg.abandoned };
|
|
36
|
+
const stable = Object.keys(pkg.versions || {}).map(v => v.replace(/^v/, "")).filter(isStable);
|
|
37
|
+
if (stable.length) {
|
|
38
|
+
const latest = stable.sort(cmp).at(-1);
|
|
39
|
+
if (latest && cmp(latest, String(version).replace(/^v/, "")) > 0) out.outdated = { latest };
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchPackage(name, { offline }) {
|
|
45
|
+
if (offline) return null;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${API}/${name}.json`, { headers: { "User-Agent": "fad-checker-packagist" } });
|
|
48
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
49
|
+
const json = await res.json();
|
|
50
|
+
return json.package || { error: "no package field" };
|
|
51
|
+
} catch (e) { return { error: e.message }; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
|
|
55
|
+
async function checkComposerRegistryDeps(deps, opts = {}) {
|
|
56
|
+
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
57
|
+
const targets = [...deps.values()].filter(d => d.ecosystem === "composer" && d.version);
|
|
58
|
+
const result = { deprecated: [], outdated: [] };
|
|
59
|
+
if (!targets.length) return result;
|
|
60
|
+
const cache = loadCache();
|
|
61
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
62
|
+
if (!fresh && !offline) cache.entries = {};
|
|
63
|
+
const limit = pLimit(concurrency);
|
|
64
|
+
await Promise.all(targets.map(t => limit(async () => {
|
|
65
|
+
const name = `${t.namespace}/${t.name}`.toLowerCase();
|
|
66
|
+
const key = `${name}@${t.version}`;
|
|
67
|
+
let ex = cache.entries[key];
|
|
68
|
+
if (!ex) {
|
|
69
|
+
const pkg = await fetchPackage(name, { offline });
|
|
70
|
+
if (pkg && !pkg.error) {
|
|
71
|
+
const f = packagistToFindings(pkg, { version: t.version });
|
|
72
|
+
ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null };
|
|
73
|
+
cache.entries[key] = ex;
|
|
74
|
+
} else {
|
|
75
|
+
ex = { abandoned: null, latest: null };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ex.abandoned) {
|
|
79
|
+
result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.abandoned.replacement, reason: "Package marked abandoned on Packagist", source: "packagist" });
|
|
80
|
+
if (verbose) process.stdout.write(` abandoned: ${name}@${t.version}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (allLibs && ex.latest) {
|
|
83
|
+
result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
|
|
84
|
+
if (verbose) process.stdout.write(` outdated: ${name} ${t.version} → ${ex.latest}\n`);
|
|
85
|
+
}
|
|
86
|
+
})));
|
|
87
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
88
|
+
saveCache(cache);
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { packagistToFindings, checkComposerRegistryDeps };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/composer.codec.js — codec PHP/Composer.
|
|
3
|
+
*
|
|
4
|
+
* Vuln scanning is OSV (ecosystem "Packagist", wired in Plan A). This codec adds
|
|
5
|
+
* collection (composer.lock, composer.json fallback), Packagist registry
|
|
6
|
+
* (abandoned + outdated), and EOL via endoflife.date.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { makeDepRecord, coordKeyFor } = require("../dep-record");
|
|
11
|
+
const { parseComposerLock, parseComposerJson, isConcrete } = require("./composer/parse");
|
|
12
|
+
|
|
13
|
+
const SKIP = new Set(["vendor", ".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target"]);
|
|
14
|
+
|
|
15
|
+
function findComposerManifests(dir) {
|
|
16
|
+
const groups = [];
|
|
17
|
+
const stack = [dir];
|
|
18
|
+
while (stack.length) {
|
|
19
|
+
const cur = stack.pop();
|
|
20
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
21
|
+
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
22
|
+
if (names.has("composer.json") || names.has("composer.lock")) {
|
|
23
|
+
groups.push({
|
|
24
|
+
dir: cur,
|
|
25
|
+
composerJson: names.has("composer.json") ? path.join(cur, "composer.json") : null,
|
|
26
|
+
composerLock: names.has("composer.lock") ? path.join(cur, "composer.lock") : null,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
|
|
30
|
+
}
|
|
31
|
+
return groups;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
id: "composer",
|
|
36
|
+
label: "Composer",
|
|
37
|
+
osvEcosystem: "Packagist",
|
|
38
|
+
manifestNames: ["composer.json", "composer.lock"],
|
|
39
|
+
|
|
40
|
+
detect(dir) { return findComposerManifests(dir).length > 0; },
|
|
41
|
+
|
|
42
|
+
async collect(dir, opts = {}) {
|
|
43
|
+
const { ignoreTest, deps2Exclude } = opts;
|
|
44
|
+
const out = new Map();
|
|
45
|
+
const warnings = [];
|
|
46
|
+
for (const g of findComposerManifests(dir)) {
|
|
47
|
+
if (g.composerLock) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try { parsed = parseComposerLock(g.composerLock); }
|
|
50
|
+
catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerLock, message: `composer.lock parse failed: ${e.message}` }); continue; }
|
|
51
|
+
const { deps } = parsed;
|
|
52
|
+
for (const d of deps) {
|
|
53
|
+
if (ignoreTest && d.isDev) continue;
|
|
54
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) continue;
|
|
55
|
+
out.set(coordKeyFor("composer", d.vendor, d.pkg),
|
|
56
|
+
makeDepRecord({ ecosystem: "composer", namespace: d.vendor, name: d.pkg, version: d.version, manifestPath: g.composerLock, scope: d.scope, isDev: d.isDev }));
|
|
57
|
+
}
|
|
58
|
+
} else if (g.composerJson) {
|
|
59
|
+
// No lockfile → best-effort: pinned exact versions only + warning.
|
|
60
|
+
let parsed;
|
|
61
|
+
try { parsed = parseComposerJson(g.composerJson); }
|
|
62
|
+
catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerJson, message: `composer.json parse failed: ${e.message}` }); continue; }
|
|
63
|
+
const { deps } = parsed;
|
|
64
|
+
let pinned = 0, ranges = 0;
|
|
65
|
+
for (const d of deps) {
|
|
66
|
+
if (ignoreTest && d.isDev) continue;
|
|
67
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) continue;
|
|
68
|
+
if (isConcrete(d.version)) {
|
|
69
|
+
out.set(coordKeyFor("composer", d.vendor, d.pkg),
|
|
70
|
+
makeDepRecord({ ecosystem: "composer", namespace: d.vendor, name: d.pkg, version: String(d.version).replace(/^v/, ""), manifestPath: g.composerJson, scope: d.scope, isDev: d.isDev }));
|
|
71
|
+
pinned++;
|
|
72
|
+
} else {
|
|
73
|
+
ranges++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
warnings.push({ type: "no-lockfile", manifestPath: g.composerJson, message: `composer.json without composer.lock — best-effort: ${pinned} pinned, ${ranges} range(s) skipped (run "composer install")` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { deps: out, warnings };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
coordKey(d) { return coordKeyFor("composer", d.namespace || "", d.name); },
|
|
83
|
+
formatCoord(d) { return `${d.namespace || ""}/${d.name}`; },
|
|
84
|
+
osvPackageName(d) { return `${d.namespace || ""}/${d.name}`; },
|
|
85
|
+
|
|
86
|
+
async checkRegistry(deps, opts = {}) {
|
|
87
|
+
const { checkComposerRegistryDeps } = require("./composer/registry");
|
|
88
|
+
return checkComposerRegistryDeps(deps, opts);
|
|
89
|
+
},
|
|
90
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
91
|
+
recipe: require("./recipes").composer,
|
|
92
|
+
nativeScanners: [],
|
|
93
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/index.js — registre des codecs.
|
|
3
|
+
*
|
|
4
|
+
* getCodec(id) → le codec, ou null
|
|
5
|
+
* allCodecs() → tous les codecs enregistrés, dans l'ordre report stable
|
|
6
|
+
* detectCodecs(dir) → les codecs dont detect() est vrai sur ce répertoire
|
|
7
|
+
*/
|
|
8
|
+
const { assertCodecShape } = require("./codec.interface");
|
|
9
|
+
const maven = require("./maven.codec");
|
|
10
|
+
const npm = require("./npm.codec");
|
|
11
|
+
const yarn = require("./yarn.codec");
|
|
12
|
+
const composer = require("./composer.codec");
|
|
13
|
+
const pypi = require("./pypi.codec");
|
|
14
|
+
const nuget = require("./nuget.codec");
|
|
15
|
+
|
|
16
|
+
// Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
|
|
17
|
+
const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi"];
|
|
18
|
+
|
|
19
|
+
const REGISTRY = new Map();
|
|
20
|
+
for (const c of [maven, npm, yarn, composer, pypi, nuget]) {
|
|
21
|
+
assertCodecShape(c);
|
|
22
|
+
REGISTRY.set(c.id, c);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getCodec(id) { return REGISTRY.get(id) || null; }
|
|
26
|
+
|
|
27
|
+
function allCodecs() {
|
|
28
|
+
return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
|
|
32
|
+
// doublon de détection (npm.collect ramasse déjà yarn.lock).
|
|
33
|
+
function detectCodecs(dir) {
|
|
34
|
+
return allCodecs().filter(c => c.id !== "yarn" && c.detect(dir));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { getCodec, allCodecs, detectCodecs, ORDER };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/maven.codec.js — codec Maven.
|
|
3
|
+
*
|
|
4
|
+
* N'INTRODUIT AUCUNE LOGIQUE NOUVELLE : enveloppe la résolution POM/BOM de
|
|
5
|
+
* lib/core.js (parse, parent, merge multi-profils, dependencyManagement, imports
|
|
6
|
+
* scope=import), le collecteur lib/cve-match.js, le walker transitif
|
|
7
|
+
* lib/transitive.js et le scanner natif CVE-index (cvelistV5).
|
|
8
|
+
*/
|
|
9
|
+
const core = require("../core");
|
|
10
|
+
const { collectResolvedDeps, expandWithTransitives, matchDepsAgainstCves } = require("../cve-match");
|
|
11
|
+
const { coordKeyFor } = require("../dep-record");
|
|
12
|
+
|
|
13
|
+
// Scanner natif : CVE-index local cvelistV5. scan(deps,opts) → {matches, meta}.
|
|
14
|
+
const cveIndexScanner = {
|
|
15
|
+
id: "cve-index",
|
|
16
|
+
kind: "cve", // résultats → chapitre CVE (mergés avec OSV)
|
|
17
|
+
async scan(deps, opts = {}) {
|
|
18
|
+
const { ensureCveIndex } = require("../cve-download");
|
|
19
|
+
const idx = await ensureCveIndex({
|
|
20
|
+
force: !!opts.cveRefresh && !opts.offline,
|
|
21
|
+
offline: !!opts.cveOffline || !!opts.offline,
|
|
22
|
+
verbose: opts.verbose,
|
|
23
|
+
});
|
|
24
|
+
return { matches: matchDepsAgainstCves(deps, idx), meta: { cveDataDate: idx?.meta?.builtAt || null } };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
id: "maven",
|
|
30
|
+
label: "Maven",
|
|
31
|
+
osvEcosystem: "Maven",
|
|
32
|
+
manifestNames: ["pom.xml"],
|
|
33
|
+
|
|
34
|
+
detect(dir) { return core.findPomFiles(dir).length > 0; },
|
|
35
|
+
|
|
36
|
+
// collect enveloppe parse + inheritance + collectResolvedDeps existants.
|
|
37
|
+
// Expose _maven (store/propsByPom/pomFiles) pour que l'orchestrateur garde la
|
|
38
|
+
// phase de réécriture des POM nettoyés (lib/core.rewritePoms).
|
|
39
|
+
async collect(dir, opts = {}) {
|
|
40
|
+
const pomFiles = core.findPomFiles(dir);
|
|
41
|
+
const store = core.newMetadataStore();
|
|
42
|
+
const propsByPom = {};
|
|
43
|
+
for (const pom of pomFiles) {
|
|
44
|
+
try { await core.parsePom(pom, store); } catch { /* logged by orchestrator */ }
|
|
45
|
+
}
|
|
46
|
+
for (const pom of Object.keys(store.byPath)) {
|
|
47
|
+
try { await core.getAllInheritedProps(pom, store, propsByPom); } catch { /* logged */ }
|
|
48
|
+
}
|
|
49
|
+
const deps = collectResolvedDeps(store, propsByPom, { ignoreTest: opts.ignoreTest, deps2Exclude: opts.deps2Exclude });
|
|
50
|
+
return { deps, warnings: [], _maven: { store, propsByPom, pomFiles } };
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
coordKey(d) { return coordKeyFor("maven", d.namespace || d.groupId, d.name || d.artifactId); },
|
|
54
|
+
formatCoord(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
|
|
55
|
+
osvPackageName(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
|
|
56
|
+
|
|
57
|
+
async checkRegistry(deps, opts = {}) {
|
|
58
|
+
const outdated = require("../outdated");
|
|
59
|
+
const out = opts.allLibs ? await outdated.checkOutdatedDeps(deps, opts) : [];
|
|
60
|
+
const deprecated = outdated.checkObsoleteDeps(deps);
|
|
61
|
+
return { outdated: out, deprecated };
|
|
62
|
+
},
|
|
63
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
64
|
+
|
|
65
|
+
recipe: require("./recipes").maven,
|
|
66
|
+
|
|
67
|
+
nativeScanners: [cveIndexScanner],
|
|
68
|
+
|
|
69
|
+
// Exposé pour l'orchestrateur (étape transitive, --transitive).
|
|
70
|
+
expandTransitives: expandWithTransitives,
|
|
71
|
+
};
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const path = require("path");
|
|
18
18
|
const fs = require("fs");
|
|
19
19
|
const { parsePackageJson, parsePackageLock, parseYarnLockV1, findJsManifests } = require("./parse");
|
|
20
|
+
const { makeDepRecord } = require("../../dep-record");
|
|
20
21
|
|
|
21
22
|
const SCOPE_RANK = { prod: 4, peer: 3, optional: 2, dev: 1, transitive: 0 };
|
|
22
23
|
|
|
@@ -55,30 +56,30 @@ function upsert(out, dep, manifestPath, lockType) {
|
|
|
55
56
|
const ecosystemType = (lockType || "").startsWith("package-lock") ? "npm"
|
|
56
57
|
: (lockType || "").startsWith("yarn") ? "yarn"
|
|
57
58
|
: "npm";
|
|
58
|
-
const record = {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const record = makeDepRecord({
|
|
60
|
+
ecosystem: "npm",
|
|
61
|
+
ecosystemType,
|
|
62
|
+
namespace: "",
|
|
63
|
+
name: dep.name,
|
|
61
64
|
version: dep.version || null,
|
|
65
|
+
manifestPath,
|
|
62
66
|
scope: dep.scope || "prod",
|
|
63
67
|
isDev: !!dep.isDev,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
integrity: dep.integrity || null,
|
|
72
|
-
from: dep.from || null,
|
|
73
|
-
};
|
|
68
|
+
});
|
|
69
|
+
// npm-specific extras (not part of the shared depRecord contract)
|
|
70
|
+
record.lockType = lockType;
|
|
71
|
+
record.depth = dep.depth ?? null;
|
|
72
|
+
record.resolved = dep.resolved || null;
|
|
73
|
+
record.integrity = dep.integrity || null;
|
|
74
|
+
record.from = dep.from || null;
|
|
74
75
|
if (!existing) {
|
|
75
76
|
out.set(key, record);
|
|
76
77
|
return;
|
|
77
78
|
}
|
|
78
|
-
// Merge
|
|
79
|
+
// Merge. NB: pomPaths shares the manifestPaths array reference (makeDepRecord),
|
|
80
|
+
// so we push once — pushing to both would duplicate the entry.
|
|
79
81
|
if (!existing.manifestPaths.includes(manifestPath)) {
|
|
80
82
|
existing.manifestPaths.push(manifestPath);
|
|
81
|
-
existing.pomPaths.push(manifestPath);
|
|
82
83
|
}
|
|
83
84
|
// Prefer the resolved (lockfile) version over a range from package.json
|
|
84
85
|
const incoming = record.version;
|
|
@@ -134,15 +135,28 @@ function collectNpmDeps(rootDir, opts = {}) {
|
|
|
134
135
|
const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
|
|
135
136
|
const hasLock = !!(pl || yl);
|
|
136
137
|
|
|
137
|
-
// package.json without lockfile →
|
|
138
|
+
// package.json without lockfile → best-effort fallback (changed contract):
|
|
139
|
+
// scan pinned exact versions ("1.2.3"), skip ranges ("^1.0.0"), and warn
|
|
140
|
+
// that results are partial. Previously this manifest was skipped entirely.
|
|
138
141
|
if (pj && !hasLock) {
|
|
142
|
+
let pinned = 0, ranges = 0;
|
|
143
|
+
for (const d of pj.deps) {
|
|
144
|
+
if (ignoreTest && (d.scope === "dev" || d.scope === "optional")) continue;
|
|
145
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) continue;
|
|
146
|
+
if (isResolvedVersion(d.version)) {
|
|
147
|
+
upsert(out, { name: d.name, version: d.version, scope: d.scope || "prod", isDev: !!d.isDev }, group.packageJson, "package-json-nolock");
|
|
148
|
+
pinned++;
|
|
149
|
+
} else {
|
|
150
|
+
ranges++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
139
153
|
warnings.push({
|
|
140
154
|
type: "no-lockfile",
|
|
141
155
|
manifestPath: group.packageJson,
|
|
142
156
|
packageName: pj.packageName || null,
|
|
143
|
-
message: `package.json without lockfile — skipped (run "npm install" or "yarn install"
|
|
157
|
+
message: `package.json without lockfile — best-effort: ${pinned} pinned dep(s) scanned, ${ranges} range(s) skipped (run "npm install" or "yarn install" for full coverage)`,
|
|
144
158
|
});
|
|
145
|
-
if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile
|
|
159
|
+
if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile — best-effort (${pinned} pinned, ${ranges} ranges skipped)`);
|
|
146
160
|
}
|
|
147
161
|
|
|
148
162
|
// Yarn-Berry lockfile detected but unsupported → warning, skip.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/npm.codec.js — codec npm.
|
|
3
|
+
*
|
|
4
|
+
* Enveloppe les parsers lib/codecs/npm/parse.js + le collecteur lib/codecs/npm/collect.js
|
|
5
|
+
* (package-lock v1/2/3, yarn.lock v1) et le scanner natif retire.js.
|
|
6
|
+
* Aucune logique nouvelle : extraction derrière l'interface codec.
|
|
7
|
+
*
|
|
8
|
+
* npm.collect ramasse package-lock ET yarn.lock — chaque dep porte son
|
|
9
|
+
* `ecosystemType` ("npm" | "yarn"). Le codec yarn (yarn.codec.js) n'existe que
|
|
10
|
+
* pour fournir son label/recette au report ; il ne re-scanne pas.
|
|
11
|
+
*/
|
|
12
|
+
const { collectNpmDeps, hasJsManifests } = require("./npm/collect");
|
|
13
|
+
const { coordKeyFor } = require("../dep-record");
|
|
14
|
+
|
|
15
|
+
// Scanner natif : retire.js (JS vendored sans lockfile). scan(deps,opts) → {matches,meta}.
|
|
16
|
+
const retireScanner = {
|
|
17
|
+
id: "retire",
|
|
18
|
+
kind: "vendored", // résultats → chapitre vendored-JS (séparé des CVE)
|
|
19
|
+
async scan(_deps, opts = {}) {
|
|
20
|
+
const { scanWithRetire } = require("../retire");
|
|
21
|
+
const matches = await scanWithRetire(opts.src, { verbose: opts.verbose, force: !!opts.retireRefresh, offline: !!opts.offline });
|
|
22
|
+
return { matches, meta: {} };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const base = {
|
|
27
|
+
osvEcosystem: "npm",
|
|
28
|
+
manifestNames: ["package.json", "package-lock.json", "yarn.lock"],
|
|
29
|
+
detect(dir) { return hasJsManifests(dir); },
|
|
30
|
+
coordKey(d) { return coordKeyFor("npm", "", d.name || d.artifactId); },
|
|
31
|
+
formatCoord(d) { return d.name || d.artifactId; },
|
|
32
|
+
osvPackageName(d) { return d.name || d.artifactId; },
|
|
33
|
+
async checkRegistry(deps, opts = {}) {
|
|
34
|
+
const { checkNpmRegistryDeps } = require("./npm/registry");
|
|
35
|
+
const r = await checkNpmRegistryDeps(deps, opts);
|
|
36
|
+
return { outdated: r.outdated || [], deprecated: r.deprecated || [] };
|
|
37
|
+
},
|
|
38
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
39
|
+
nativeScanners: [retireScanner],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
...base,
|
|
44
|
+
id: "npm",
|
|
45
|
+
label: "npm",
|
|
46
|
+
recipe: require("./recipes").npm,
|
|
47
|
+
// collect ramasse TOUTES les deps JS (package-lock + yarn.lock).
|
|
48
|
+
async collect(dir, opts = {}) {
|
|
49
|
+
const deps = collectNpmDeps(dir, opts);
|
|
50
|
+
return { deps, warnings: deps.warnings || [] };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/nuget/parse.js — parse .NET/NuGet manifests.
|
|
3
|
+
*
|
|
4
|
+
* packages.lock.json — JSON, { dependencies: { "<tfm>": { "<id>": { type, resolved } } } }
|
|
5
|
+
* type "Direct"|"Transitive"; resolved = concrete version.
|
|
6
|
+
* *.csproj — XML, <PackageReference Include Version>. Version may be an
|
|
7
|
+
* attribute, a child element, or absent (Central Package Management).
|
|
8
|
+
* Directory.Packages.props — XML CPM, <PackageVersion Include Version> → name→version table.
|
|
9
|
+
* packages.config — XML legacy, <package id version targetFramework />.
|
|
10
|
+
*
|
|
11
|
+
* NuGet ids are case-insensitive (lowercased for the key; original case kept for display).
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const xml2js = require("xml2js");
|
|
15
|
+
|
|
16
|
+
// Concrete = starts with a digit and only [\w.+-] — rejects floating "1.*",
|
|
17
|
+
// range "[1.0,2.0)", and wildcard/comma/bracket specifiers.
|
|
18
|
+
function isConcrete(v) { const s = String(v || ""); return /^\d/.test(s) && /^[\w.+-]+$/.test(s); }
|
|
19
|
+
|
|
20
|
+
async function parsePackagesLockJson(filePath) {
|
|
21
|
+
const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
22
|
+
const deps = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const fw of Object.values(json.dependencies || {})) {
|
|
25
|
+
for (const [name, meta] of Object.entries(fw || {})) {
|
|
26
|
+
const version = meta.resolved || null;
|
|
27
|
+
if (!version) continue;
|
|
28
|
+
const key = `${name.toLowerCase()}@${version}`;
|
|
29
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
30
|
+
const scope = (meta.type === "Transitive") ? "transitive" : "prod";
|
|
31
|
+
deps.push({ name, version, scope, isDev: false });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { manifestPath: filePath, manifestType: "packages.lock.json", deps };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function parseDirectoryPackagesProps(filePath) {
|
|
38
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
39
|
+
const map = {};
|
|
40
|
+
for (const ig of xml.Project?.ItemGroup || []) {
|
|
41
|
+
for (const pv of ig.PackageVersion || []) {
|
|
42
|
+
const id = pv.$?.Include; const v = pv.$?.Version;
|
|
43
|
+
if (id && v) map[id.toLowerCase()] = v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function parseCsproj(filePath, cpm = {}) {
|
|
50
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
51
|
+
const deps = [];
|
|
52
|
+
let skipped = 0;
|
|
53
|
+
for (const ig of xml.Project?.ItemGroup || []) {
|
|
54
|
+
for (const pr of ig.PackageReference || []) {
|
|
55
|
+
const name = pr.$?.Include; if (!name) continue;
|
|
56
|
+
// Version: attribute, child element, or (CPM) resolved from Directory.Packages.props.
|
|
57
|
+
const version = pr.$?.Version || (Array.isArray(pr.Version) ? pr.Version[0] : null) || cpm[name.toLowerCase()] || null;
|
|
58
|
+
if (version && isConcrete(version)) deps.push({ name, version, scope: "prod", isDev: false });
|
|
59
|
+
else skipped++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { manifestPath: filePath, manifestType: "csproj", deps, skipped };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function parsePackagesConfig(filePath) {
|
|
66
|
+
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
67
|
+
const deps = [];
|
|
68
|
+
for (const p of xml.packages?.package || []) {
|
|
69
|
+
const name = p.$?.id; const version = p.$?.version;
|
|
70
|
+
if (name && version && isConcrete(version)) deps.push({ name, version, scope: "prod", isDev: false });
|
|
71
|
+
}
|
|
72
|
+
return { manifestPath: filePath, manifestType: "packages.config", deps };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { isConcrete, parsePackagesLockJson, parseDirectoryPackagesProps, parseCsproj, parsePackagesConfig };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/nuget/registry.js — query the NuGet registration index for a package's
|
|
3
|
+
* latest stable version and per-version deprecation.
|
|
4
|
+
*
|
|
5
|
+
* API: https://api.nuget.org/v3/registration5-gz-semver2/{lowerid}/index.json
|
|
6
|
+
* → { items: [ { items: [ { catalogEntry: { version, deprecation } } ] } ] }
|
|
7
|
+
*
|
|
8
|
+
* Cached in ~/.fad-checker/nuget-cache.json for 24h.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
|
|
14
|
+
|
|
15
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
16
|
+
const CACHE_PATH = path.join(CACHE_DIR, "nuget-cache.json");
|
|
17
|
+
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
18
|
+
const REG = "https://api.nuget.org/v3/registration5-gz-semver2";
|
|
19
|
+
|
|
20
|
+
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
|
|
21
|
+
function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
|
|
22
|
+
function isStable(v) { return /^\d+(\.\d+)*$/.test(String(v || "")); }
|
|
23
|
+
function cmp(a, b) {
|
|
24
|
+
const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
25
|
+
const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
26
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Walk a registration index (inline items) → { outdated, deprecated }.
|
|
31
|
+
function nugetRegistrationToFindings(reg, { version }) {
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const page of reg.items || []) for (const leaf of page.items || []) if (leaf.catalogEntry) entries.push(leaf.catalogEntry);
|
|
34
|
+
const out = { outdated: null, deprecated: null };
|
|
35
|
+
const stable = entries.map(e => e.version).filter(isStable);
|
|
36
|
+
if (stable.length) { const latest = stable.sort(cmp).at(-1); if (latest && cmp(latest, version) > 0) out.outdated = { latest }; }
|
|
37
|
+
const mine = entries.find(e => String(e.version) === String(version));
|
|
38
|
+
if (mine?.deprecation) out.deprecated = { reason: (mine.deprecation.reasons || []).join(", ") || "deprecated", replacement: mine.deprecation.alternatePackage?.id || null };
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchRegistration(name, { offline }) {
|
|
43
|
+
if (offline) return null;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${REG}/${name.toLowerCase()}/index.json`, { headers: { "User-Agent": "fad-checker-nuget" } });
|
|
46
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
47
|
+
return await res.json();
|
|
48
|
+
} catch (e) { return { error: e.message }; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
|
|
52
|
+
async function checkNugetRegistryDeps(deps, opts = {}) {
|
|
53
|
+
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
54
|
+
const targets = [...deps.values()].filter(d => d.ecosystem === "nuget" && d.version);
|
|
55
|
+
const result = { deprecated: [], outdated: [] };
|
|
56
|
+
if (!targets.length) return result;
|
|
57
|
+
const cache = loadCache();
|
|
58
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
59
|
+
if (!fresh && !offline) cache.entries = {};
|
|
60
|
+
const limit = pLimit(concurrency);
|
|
61
|
+
await Promise.all(targets.map(t => limit(async () => {
|
|
62
|
+
const key = `${t.name.toLowerCase()}@${t.version}`;
|
|
63
|
+
let ex = cache.entries[key];
|
|
64
|
+
if (!ex) {
|
|
65
|
+
const reg = await fetchRegistration(t.name, { offline });
|
|
66
|
+
if (reg && !reg.error) {
|
|
67
|
+
const f = nugetRegistrationToFindings(reg, { version: t.version });
|
|
68
|
+
ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null };
|
|
69
|
+
cache.entries[key] = ex;
|
|
70
|
+
} else {
|
|
71
|
+
ex = { deprecated: null, latest: null };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (ex.deprecated) {
|
|
75
|
+
result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.deprecated.replacement, reason: ex.deprecated.reason, source: "nuget" });
|
|
76
|
+
if (verbose) process.stdout.write(` deprecated: ${t.name}@${t.version}\n`);
|
|
77
|
+
}
|
|
78
|
+
if (allLibs && ex.latest) {
|
|
79
|
+
result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
|
|
80
|
+
if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
|
|
81
|
+
}
|
|
82
|
+
})));
|
|
83
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
84
|
+
saveCache(cache);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { nugetRegistrationToFindings, checkNugetRegistryDeps };
|