fad-checker 2.4.2 → 2.4.4
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 +25 -0
- package/fad-checker.js +79 -9
- package/lib/attribution.js +82 -0
- package/lib/cve-match.js +10 -0
- package/lib/cve-report.js +5 -3
- package/lib/dep-record.js +9 -0
- package/lib/deps-descriptor.js +24 -0
- package/lib/maven-bom.js +77 -3
- package/lib/transitive.js +20 -6
- package/lib/version-overlay.js +38 -17
- package/package.json +1 -1
- package/REVIEW-2026-07-02.md +0 -190
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
8
|
### Fixed
|
|
9
|
+
- **External `<parent>` POMs (spring-boot-starter-parent) now backfill their managed
|
|
10
|
+
versions.** A versionless dep whose version is inherited from an external `<parent>`
|
|
11
|
+
(e.g. `spring-boot-starter-actuator` under `spring-boot-starter-parent`, whose own
|
|
12
|
+
parent `spring-boot-dependencies` holds the version table) was left unresolved and
|
|
13
|
+
**dropped from the CVE/OSV/EOL/outdated scans** — the mainline backfill (`lib/maven-bom.js`)
|
|
14
|
+
only handled `<scope>import</scope>` BOMs, never the `<parent>` case, so this failed
|
|
15
|
+
even online (the `--transitive` overlay resolved the parent but couldn't backfill the
|
|
16
|
+
primary version). `collectExternalParents()` now feeds external parents through the same
|
|
17
|
+
`effectivePom` → `backfillVersions` path as import BOMs (import BOMs win on precedence),
|
|
18
|
+
stamped `versionSource={via:"parent",…}` → report "version managed by … (parent POM)".
|
|
19
|
+
It runs in the **mainline** flow (not just `--transitive`), so the warmed cache always
|
|
20
|
+
captures the parent POMs for **offline/air-gapped** reuse. **Child property overrides are
|
|
21
|
+
honored** (Maven semantics): a project that redefines `<log4j2.version>2.17.1</log4j2.version>`
|
|
22
|
+
to patch a CVE resolves the managed coord to `2.17.1`, not the framework default
|
|
23
|
+
(`collectPropertyOverrides()` → `effectivePom`'s new `propertyOverrides`; import-BOM-managed
|
|
24
|
+
coords are correctly left un-overridden). The "missing parent POM — potentially private"
|
|
25
|
+
warning now **partitions** parents fad resolves from Maven Central/cache (public) from the
|
|
26
|
+
truly-unresolvable ones (private), instead of flagging every external parent as suspect.
|
|
27
|
+
- **Anonymized descriptor closes the versionless-Spring-Boot round-trip in one exchange.**
|
|
28
|
+
The `fad-deps/1` descriptor now carries a `maven` hints block (`externalParents[]` +
|
|
29
|
+
`importBoms[]` coords + version `propertyOverrides{}`) so a no-source-tree online
|
|
30
|
+
`--import-anonymized` run can resolve the versionless deps' versions and warm their
|
|
31
|
+
`coord+version`-keyed CVE caches — previously that needed a second air-gapped exchange.
|
|
32
|
+
Only public coords + version strings travel (a private parent listed is a harmless online
|
|
33
|
+
no-op); the source tree never leaves the enclave.
|
|
9
34
|
- **CVE-index recall: real-world pre-2023 records were dropped (incl. Log4Shell).**
|
|
10
35
|
`isMavenRelevant()` only accepted CVE 5.x records carrying machine-readable Maven
|
|
11
36
|
metadata (`packageName`/`collectionURL`/`versionType:"maven"`) or an EXACT-match
|
package/fad-checker.js
CHANGED
|
@@ -526,11 +526,24 @@ async function timedPhase(label, fn) {
|
|
|
526
526
|
let imported;
|
|
527
527
|
try { imported = deserializeDeps(descriptor); }
|
|
528
528
|
catch (e) { console.error(chalk.red(`❌ invalid descriptor: ${e.message}`)); process.exit(1); }
|
|
529
|
-
const { resolved, activeIds, runMaven, runNpm } = imported;
|
|
529
|
+
const { resolved, activeIds, runMaven, runNpm, externalParents = [], importBoms = [], propertyOverrides = {} } = imported;
|
|
530
530
|
ui.section("Anonymized descriptor");
|
|
531
531
|
ui.ok(`imported ${chalk.bold(resolved.size)} dep(s) across ${activeIds.join(", ") || "—"}`);
|
|
532
532
|
if (options.offline) ui.warn("--offline: caches won't warm; only useful to re-render from an already-warm cache");
|
|
533
533
|
if (!resolved.size) { ui.warn("descriptor has no dependencies — nothing to scan"); process.exit(0); }
|
|
534
|
+
// Replay the external-parent / import-BOM backfill from the descriptor's carried hints.
|
|
535
|
+
// Workflow B has no source tree here, so the mainline store-based backfill can't run —
|
|
536
|
+
// without this, versionless deps (spring-boot-starter-*) stay unresolved and their CVE
|
|
537
|
+
// caches never warm, forcing a SECOND air-gapped exchange. Online only (needs the POMs).
|
|
538
|
+
if (runMaven && !options.offline && (externalParents.length || importBoms.length)) {
|
|
539
|
+
const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
|
|
540
|
+
const base = { repos: mavenRepos, offline: options.offline, verbose, effCache: new Map() };
|
|
541
|
+
const mgmt = await resolveBomManagedVersions(importBoms, base);
|
|
542
|
+
const parentMgmt = await resolveBomManagedVersions(externalParents, { ...base, via: "parent", propertyOverrides });
|
|
543
|
+
for (const [k, v] of parentMgmt) if (!mgmt.has(k)) mgmt.set(k, v);
|
|
544
|
+
const filled = backfillVersions(resolved, mgmt);
|
|
545
|
+
if (filled) ui.ok(`backfilled ${chalk.bold(filled)} version(s) from ${externalParents.length} parent(s) + ${importBoms.length} BOM(s) in the descriptor`);
|
|
546
|
+
}
|
|
534
547
|
// Warm retire signatures (online) so --export-cache carries them for offline JS scanning.
|
|
535
548
|
if (runNpm && !options.offline && options.retire !== false) {
|
|
536
549
|
const { warmRetireSignatures } = require("./lib/retire");
|
|
@@ -625,8 +638,14 @@ async function timedPhase(label, fn) {
|
|
|
625
638
|
// --- Anonymized phase 1: export a descriptor and exit (no network, no report) ---
|
|
626
639
|
if (options.exportAnonymized) {
|
|
627
640
|
const { serializeDeps } = require("./lib/deps-descriptor");
|
|
641
|
+
const { collectExternalParents, collectImportBoms, collectPropertyOverrides } = require("./lib/maven-bom");
|
|
628
642
|
const pkgVersion = require("./package.json").version;
|
|
629
|
-
|
|
643
|
+
// Carry the Maven resolution hints so a no-source-tree online warm run (Phase 2) can
|
|
644
|
+
// resolve the versionless deps' versions and warm their CVE caches in ONE exchange.
|
|
645
|
+
const externalParents = mavenCtx?.store ? collectExternalParents(mavenCtx.store) : [];
|
|
646
|
+
const importBoms = mavenCtx?.propsByPom ? collectImportBoms(mavenCtx.propsByPom) : [];
|
|
647
|
+
const propertyOverrides = mavenCtx?.store ? collectPropertyOverrides(mavenCtx.store) : {};
|
|
648
|
+
const descriptor = serializeDeps(resolved, { generator: `fad-checker ${pkgVersion}`, externalParents, importBoms, propertyOverrides });
|
|
630
649
|
try { fs.writeFileSync(options.exportAnonymized, JSON.stringify(descriptor, null, 2) + "\n"); }
|
|
631
650
|
catch (e) { console.error(chalk.red(`❌ could not write --export-anonymized file: ${e.message}`)); process.exit(1); }
|
|
632
651
|
const ecoSummary = Object.entries(descriptor.summary.byEcosystem).map(([k, v]) => `${k}:${v}`).join(", ");
|
|
@@ -691,9 +710,29 @@ async function timedPhase(label, fn) {
|
|
|
691
710
|
return !(allPomMetadata.byId[id] || allPomMetadata.byId[`${parts[0]}:${parts[1]}`]);
|
|
692
711
|
});
|
|
693
712
|
if (missingParents.length) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
713
|
+
// A parent absent from the source tree is EXTERNAL, not necessarily private. fad
|
|
714
|
+
// resolves a PUBLIC one (spring-boot-starter-parent, …) from Maven Central / warmed
|
|
715
|
+
// cache and backfills its managed versions — so only classify as "likely private"
|
|
716
|
+
// the ones it genuinely can't resolve. effectivePom is cache-first + offline-aware,
|
|
717
|
+
// and warms the cache the BOM/parent backfill reuses later in the report flow.
|
|
718
|
+
const { effectivePom } = require("./lib/transitive");
|
|
719
|
+
const resolvable = [], unresolved = [];
|
|
720
|
+
await Promise.all(missingParents.map(async id => {
|
|
721
|
+
const [g, a, v] = id.split(":");
|
|
722
|
+
let eff = null;
|
|
723
|
+
if (g && a && v) { try { eff = await effectivePom(g, a, v, { repos: mavenRepos, offline: options.offline }); } catch { eff = null; } }
|
|
724
|
+
(eff && eff.depMgmt && eff.depMgmt.length ? resolvable : unresolved).push(id);
|
|
725
|
+
}));
|
|
726
|
+
if (resolvable.length) {
|
|
727
|
+
ui.ok(`${resolvable.length} external parent POM(s) resolved from Maven Central${options.offline ? " (cache)" : ""} — managed versions backfilled`);
|
|
728
|
+
for (const id of resolvable.slice(0, 10)) ui.info(chalk.green(id));
|
|
729
|
+
if (resolvable.length > 10) ui.info(chalk.dim(`…and ${resolvable.length - 10} more`));
|
|
730
|
+
}
|
|
731
|
+
if (unresolved.length) {
|
|
732
|
+
ui.warn(`${unresolved.length} parent POM(s) not resolvable — likely private${options.offline ? " (or not in the warmed cache)" : ""}; Snyk will fail on these:`);
|
|
733
|
+
for (const id of unresolved.slice(0, 10)) ui.info(chalk.yellow(id));
|
|
734
|
+
if (unresolved.length > 10) ui.info(chalk.dim(`…and ${unresolved.length - 10} more`));
|
|
735
|
+
}
|
|
697
736
|
} else {
|
|
698
737
|
ui.ok("no missing Maven parent POMs");
|
|
699
738
|
}
|
|
@@ -828,7 +867,14 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
828
867
|
.map(b => ({ groupId: b.group, artifactId: b.name, version: b.version }))
|
|
829
868
|
.filter(b => b.groupId && b.artifactId && b.version);
|
|
830
869
|
const allBoms = importBoms.concat(gradleBoms);
|
|
831
|
-
|
|
870
|
+
// External <parent> POMs (e.g. spring-boot-starter-parent) manage versionless declared
|
|
871
|
+
// deps via their own inherited depMgmt (spring-boot-dependencies). core.js only follows
|
|
872
|
+
// LOCAL parents, so feed the external ones through the SAME backfill as import BOMs. This
|
|
873
|
+
// runs in the MAINLINE flow (not just the --transitive overlay) so the warmed cache always
|
|
874
|
+
// captures the parent POMs for offline reuse.
|
|
875
|
+
const externalParents = (runMaven && mavenStore)
|
|
876
|
+
? require("./lib/maven-bom").collectExternalParents(mavenStore) : [];
|
|
877
|
+
const willBom = allBoms.length > 0 || externalParents.length > 0;
|
|
832
878
|
const willOsv = !!options.osv;
|
|
833
879
|
// Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
|
|
834
880
|
// OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
|
|
@@ -850,12 +896,24 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
850
896
|
const progress = new ui.Progress(totalSteps);
|
|
851
897
|
|
|
852
898
|
if (willBom) {
|
|
853
|
-
const st = progress.start("BOM version resolution (Maven Central)");
|
|
899
|
+
const st = progress.start("BOM / parent version resolution (Maven Central)");
|
|
854
900
|
try {
|
|
855
901
|
const { resolveBomManagedVersions, backfillVersions } = require("./lib/maven-bom");
|
|
856
|
-
const
|
|
902
|
+
const effCache = new Map();
|
|
903
|
+
const base = { repos: mavenRepos, offline, verbose, effCache };
|
|
904
|
+
// Import BOMs first: a local <scope>import</scope> declaration wins over the
|
|
905
|
+
// versions inherited from an external parent (Maven precedence). Import BOMs
|
|
906
|
+
// resolve in their own context — the project's property overrides do NOT reach
|
|
907
|
+
// them (Maven), so they get no propertyOverrides.
|
|
908
|
+
const mgmt = await resolveBomManagedVersions(allBoms, base);
|
|
909
|
+
// External parents second: fill only coords the BOMs didn't already manage, and
|
|
910
|
+
// honor the project's own <properties> overrides (e.g. a patched <log4j2.version>)
|
|
911
|
+
// so the version reflects the classpath, not the framework default.
|
|
912
|
+
const propertyOverrides = require("./lib/maven-bom").collectPropertyOverrides(mavenStore);
|
|
913
|
+
const parentMgmt = await resolveBomManagedVersions(externalParents, { ...base, via: "parent", propertyOverrides });
|
|
914
|
+
for (const [k, v] of parentMgmt) if (!mgmt.has(k)) mgmt.set(k, v);
|
|
857
915
|
const filled = backfillVersions(resolved, mgmt);
|
|
858
|
-
st.done(`${filled} dep version(s) from ${allBoms.length}
|
|
916
|
+
st.done(`${filled} dep version(s) from ${allBoms.length} BOM(s) + ${externalParents.length} parent(s)`);
|
|
859
917
|
} catch (err) { st.fail(err.message); }
|
|
860
918
|
}
|
|
861
919
|
|
|
@@ -1145,6 +1203,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1145
1203
|
}
|
|
1146
1204
|
}
|
|
1147
1205
|
|
|
1206
|
+
// 6a-bis. Attribute every match to the manifest/module that actually resolves the
|
|
1207
|
+
// version it matched on. A depRecord is coord-wide (versions[] and manifestPaths[]
|
|
1208
|
+
// with no link between them); a match carries ONE version. Must run after ALL match
|
|
1209
|
+
// sources are merged and before anything reads scope/paths (exec summary, charts,
|
|
1210
|
+
// chapters, exports, gate) — otherwise, on a root spanning several independent
|
|
1211
|
+
// projects, every version is reported against every manifest holding the coord.
|
|
1212
|
+
{
|
|
1213
|
+
const { attributeMatchOrigins } = require("./lib/attribution");
|
|
1214
|
+
const reattributed = attributeMatchOrigins(cveMatches);
|
|
1215
|
+
if (reattributed && verbose) console.log(` re-attributed ${reattributed} match(es) to their resolving manifest/module`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1148
1218
|
// 6b. Supply-chain risk lane (pure + offline): flag KNOWN-MALICIOUS advisories
|
|
1149
1219
|
// (OSV MAL-… already in the match set) always, and detect suspected TYPOSQUATS
|
|
1150
1220
|
// (opt-in --typosquat, heuristic — names one edit from a popular package).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/attribution.js — attribute each MATCH to the manifest/module that actually
|
|
3
|
+
* resolves the version it was matched on.
|
|
4
|
+
*
|
|
5
|
+
* A depRecord is COORD-WIDE: the collectors merge every occurrence of a g:a across the
|
|
6
|
+
* whole scan root into ONE record, so `versions[]` and `manifestPaths[]` are both
|
|
7
|
+
* coord-wide sets with no link between them, and `scope` is a single merged value. A
|
|
8
|
+
* MATCH, by contrast, carries exactly ONE version (matchOne clones the record per
|
|
9
|
+
* version). Left alone that clone inherits the coord-wide manifest list and the merged
|
|
10
|
+
* scope — so every version is reported against every manifest holding the coord.
|
|
11
|
+
*
|
|
12
|
+
* Invisible on a single reactor; wrong on the real-world shape fad is pointed at: an
|
|
13
|
+
* audit root holding SEVERAL INDEPENDENT projects (each pom with its own external
|
|
14
|
+
* parent, no shared reactor). Project A pins jackson-databind 2.15.3, project B pins
|
|
15
|
+
* 2.17.0 → both projects get reported as holding BOTH versions, and each version's CVEs
|
|
16
|
+
* land on both. Same class of error for a version the per-module overlay recovered: it
|
|
17
|
+
* is a TRANSITIVE of one module, not the direct declaration the record describes —
|
|
18
|
+
* unattributed it tops the exec summary's "direct production dependencies" pointing at
|
|
19
|
+
* the very pom that pins the fixed version.
|
|
20
|
+
*
|
|
21
|
+
* Two provenance sources, checked per match:
|
|
22
|
+
* - `maskedVersions[]` (lib/version-overlay) — the version is a transitive of ONE
|
|
23
|
+
* module: re-stamp scope/via/depth and file it under that module.
|
|
24
|
+
* - `versionPaths{}` (lib/dep-record, maintained by the collectors) — the version is
|
|
25
|
+
* DECLARED in a known subset of manifests: narrow the match to that subset.
|
|
26
|
+
* A version with neither (e.g. a global-pass transitive) is left untouched.
|
|
27
|
+
*
|
|
28
|
+
* Runs ONCE, after every match source is merged (CVE index, OSV, snyk) and before
|
|
29
|
+
* anything reads scope/paths (exec summary, charts, chapters, exports, gate).
|
|
30
|
+
* Pure w.r.t. the scan set: replaces `m.dep` with a clone, never mutating the shared
|
|
31
|
+
* record — other matches still reference it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** manifestPaths and pomPaths must stay the SAME array (dep-record.js invariant). */
|
|
35
|
+
function withPaths(dep, paths) {
|
|
36
|
+
const p = paths.slice();
|
|
37
|
+
return { ...dep, manifestPaths: p, pomPaths: p };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameList(a, b) {
|
|
41
|
+
const x = a || [], y = b || [];
|
|
42
|
+
return x.length === y.length && x.every((v, i) => v === y[i]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param matches the merged match set (mutated: each m.dep may be replaced by a clone)
|
|
47
|
+
* @returns number of matches re-attributed
|
|
48
|
+
*/
|
|
49
|
+
function attributeMatchOrigins(matches) {
|
|
50
|
+
let fixed = 0;
|
|
51
|
+
for (const m of matches || []) {
|
|
52
|
+
const dep = m && m.dep;
|
|
53
|
+
if (!dep || !dep.version) continue;
|
|
54
|
+
const ver = String(dep.version);
|
|
55
|
+
|
|
56
|
+
// 1. Recovered by the per-module overlay → a transitive of ONE module.
|
|
57
|
+
const masked = dep.scope !== "transitive" && Array.isArray(dep.maskedVersions)
|
|
58
|
+
? dep.maskedVersions.find(x => String(x.version) === ver)
|
|
59
|
+
: null;
|
|
60
|
+
if (masked) {
|
|
61
|
+
m.dep = {
|
|
62
|
+
...withPaths(dep, masked.module ? [masked.module] : []),
|
|
63
|
+
scope: "transitive",
|
|
64
|
+
via: masked.via || [],
|
|
65
|
+
viaPaths: masked.viaPaths || (masked.via ? [masked.via] : []),
|
|
66
|
+
depth: masked.depth,
|
|
67
|
+
};
|
|
68
|
+
fixed++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Declared → narrow to the manifest(s) that declare THIS version.
|
|
73
|
+
const declared = dep.versionPaths && dep.versionPaths[ver];
|
|
74
|
+
if (Array.isArray(declared) && declared.length && !sameList(declared, dep.manifestPaths)) {
|
|
75
|
+
m.dep = withPaths(dep, declared);
|
|
76
|
+
fixed++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return fixed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { attributeMatchOrigins };
|
package/lib/cve-match.js
CHANGED
|
@@ -83,6 +83,16 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
83
83
|
// A dep is "dev" overall only if every occurrence is test/provided.
|
|
84
84
|
if (!isDev) existing.isDev = false;
|
|
85
85
|
}
|
|
86
|
+
// Remember WHICH pom declares THIS version. The record is coord-wide, so
|
|
87
|
+
// this is the only link back from a scanned version to its manifest —
|
|
88
|
+
// without it a match on one version is reported against every pom that
|
|
89
|
+
// declares the coord at any version (lib/attribution.js consumes this).
|
|
90
|
+
if (concrete) {
|
|
91
|
+
const rec = out.get(key);
|
|
92
|
+
if (!rec.versionPaths) rec.versionPaths = {};
|
|
93
|
+
const paths = rec.versionPaths[concrete] || (rec.versionPaths[concrete] = []);
|
|
94
|
+
if (!paths.includes(pomPath)) paths.push(pomPath);
|
|
95
|
+
}
|
|
86
96
|
}
|
|
87
97
|
};
|
|
88
98
|
|
package/lib/cve-report.js
CHANGED
|
@@ -411,10 +411,12 @@ function renderDefinedIn(dep, srcRoot) {
|
|
|
411
411
|
definedLine = `<div class="defined-in"><span class="defined-label">defined in:</span> ${shown}${more}</div>`;
|
|
412
412
|
}
|
|
413
413
|
// A versionless declared dep (typical Spring Boot / Gradle) gets its version from an import
|
|
414
|
-
// BOM, not the manifest it's declared in. Disclose that so
|
|
414
|
+
// BOM or an external <parent> chain, not the manifest it's declared in. Disclose that so
|
|
415
|
+
// e.g. spring-boot-starter-actuator:2.7.18 doesn't read as fabricated.
|
|
415
416
|
const vs = dep.versionSource;
|
|
416
|
-
const
|
|
417
|
-
|
|
417
|
+
const vsKind = vs && vs.via === "parent" ? "parent POM" : "BOM";
|
|
418
|
+
const versionLine = (vs && (vs.via === "bom" || vs.via === "parent") && vs.bom)
|
|
419
|
+
? `<div class="defined-in"><span class="defined-label">version managed by:</span> <code>${esc(vs.bom)}</code> (${vsKind})</div>`
|
|
418
420
|
: "";
|
|
419
421
|
return definedLine + versionLine;
|
|
420
422
|
}
|
package/lib/dep-record.js
CHANGED
|
@@ -55,6 +55,15 @@ function makeDepRecord(input) {
|
|
|
55
55
|
name,
|
|
56
56
|
version: version || null,
|
|
57
57
|
versions: concrete ? [concrete] : [],
|
|
58
|
+
// Per-version → the manifest(s) DECLARING that version. `versions[]` and
|
|
59
|
+
// `manifestPaths[]` are both coord-wide sets with no link between them, so
|
|
60
|
+
// neither says which manifest declared which version. A match carries ONE
|
|
61
|
+
// version, so without this link it renders against EVERY manifest holding the
|
|
62
|
+
// coord: on a scan root spanning several independent projects (A pins
|
|
63
|
+
// jackson-databind 2.15.3, B pins 2.17.0) both projects are reported as holding
|
|
64
|
+
// BOTH versions and each version's CVEs land on both. Maintained on merge by the
|
|
65
|
+
// collectors; consumed by lib/attribution.js.
|
|
66
|
+
versionPaths: concrete && manifestPath ? { [concrete]: [manifestPath] } : {},
|
|
58
67
|
coordKey,
|
|
59
68
|
provenance,
|
|
60
69
|
hashes,
|
package/lib/deps-descriptor.js
CHANGED
|
@@ -35,6 +35,13 @@ const ANON_NOTE = "Anonymized: public coordinates only — no paths, URLs, or ho
|
|
|
35
35
|
* generatedAt — ISO timestamp (override for deterministic tests)
|
|
36
36
|
* note — human-readable banner (defaults to the anonymization notice)
|
|
37
37
|
*/
|
|
38
|
+
// Sanitize a parent/BOM coordinate to the three public fields only (never carry paths,
|
|
39
|
+
// registry URLs or other record cruft into the descriptor).
|
|
40
|
+
function cleanCoord(c) {
|
|
41
|
+
return (c && c.groupId && c.artifactId && c.version)
|
|
42
|
+
? { groupId: String(c.groupId), artifactId: String(c.artifactId), version: String(c.version) } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
function serializeDeps(resolvedMap, opts = {}) {
|
|
39
46
|
const { generator = "fad-checker", generatedAt = new Date().toISOString(), note = ANON_NOTE } = opts;
|
|
40
47
|
const deps = [];
|
|
@@ -59,12 +66,22 @@ function serializeDeps(resolvedMap, opts = {}) {
|
|
|
59
66
|
// Stable order so two runs over the same tree produce identical files (easier
|
|
60
67
|
// to diff/review and to detect tampering during transfer).
|
|
61
68
|
deps.sort((a, b) => (a.ecosystem + "\0" + a.namespace + "\0" + a.name).localeCompare(b.ecosystem + "\0" + b.namespace + "\0" + b.name));
|
|
69
|
+
// Maven resolution hints: the EXTERNAL parent + import-BOM coords that manage the
|
|
70
|
+
// versionless deps, plus the project's version-property overrides. These let a Phase-2
|
|
71
|
+
// online run (no source tree) resolve `spring-boot-starter-actuator`'s version and warm
|
|
72
|
+
// its CVE cache in ONE round-trip — the alternative is two exchanges (see docs/USAGE.md).
|
|
73
|
+
// Public coords + version strings only; the auditor reviews before transfer like the rest.
|
|
74
|
+
const externalParents = (opts.externalParents || []).map(cleanCoord).filter(Boolean);
|
|
75
|
+
const importBoms = (opts.importBoms || []).map(cleanCoord).filter(Boolean);
|
|
76
|
+
const propertyOverrides = (opts.propertyOverrides && typeof opts.propertyOverrides === "object") ? { ...opts.propertyOverrides } : {};
|
|
77
|
+
const hasMavenHints = externalParents.length || importBoms.length || Object.keys(propertyOverrides).length;
|
|
62
78
|
return {
|
|
63
79
|
schema: SCHEMA,
|
|
64
80
|
generator,
|
|
65
81
|
generatedAt,
|
|
66
82
|
note,
|
|
67
83
|
summary: { total: deps.length, byEcosystem },
|
|
84
|
+
...(hasMavenHints ? { maven: { externalParents, importBoms, propertyOverrides } } : {}),
|
|
68
85
|
deps,
|
|
69
86
|
};
|
|
70
87
|
}
|
|
@@ -102,11 +119,18 @@ function deserializeDeps(descriptor) {
|
|
|
102
119
|
ecosystems.add(e.ecosystem);
|
|
103
120
|
}
|
|
104
121
|
const activeIds = [...ecosystems];
|
|
122
|
+
// Maven resolution hints (absent on legacy descriptors → empty). The import flow replays
|
|
123
|
+
// the external-parent / import-BOM backfill from these so a no-source-tree online warm run
|
|
124
|
+
// resolves the versionless deps' versions.
|
|
125
|
+
const mvn = (descriptor.maven && typeof descriptor.maven === "object") ? descriptor.maven : {};
|
|
105
126
|
return {
|
|
106
127
|
resolved,
|
|
107
128
|
activeIds,
|
|
108
129
|
runMaven: ecosystems.has("maven"),
|
|
109
130
|
runNpm: ecosystems.has("npm") || ecosystems.has("yarn"),
|
|
131
|
+
externalParents: Array.isArray(mvn.externalParents) ? mvn.externalParents : [],
|
|
132
|
+
importBoms: Array.isArray(mvn.importBoms) ? mvn.importBoms : [],
|
|
133
|
+
propertyOverrides: (mvn.propertyOverrides && typeof mvn.propertyOverrides === "object") ? mvn.propertyOverrides : {},
|
|
110
134
|
};
|
|
111
135
|
}
|
|
112
136
|
|
package/lib/maven-bom.js
CHANGED
|
@@ -15,6 +15,76 @@
|
|
|
15
15
|
* skips it offline). Pure except for the injected/real effectivePom fetch.
|
|
16
16
|
*/
|
|
17
17
|
const transitive = require("./transitive");
|
|
18
|
+
const core = require("./core");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Walk each parsed pom's LOCAL parent chain and collect the distinct EXTERNAL parent
|
|
22
|
+
* coordinates — the parent POMs referenced by a `<parent>` element but NOT present in
|
|
23
|
+
* the source tree (e.g. `spring-boot-starter-parent`, whose own parent
|
|
24
|
+
* `spring-boot-dependencies` holds the managed-version table). Maven inherits their
|
|
25
|
+
* `<dependencyManagement>` into the child, which is how a versionless
|
|
26
|
+
* `spring-boot-starter-actuator` gets its version — but core.js only follows LOCAL
|
|
27
|
+
* parents, so without this those versions stay unresolved and drop out of the scan.
|
|
28
|
+
*
|
|
29
|
+
* Deduped by g:a:v; unresolved (`${…}`) or incomplete coords are skipped. The result
|
|
30
|
+
* is fed to resolveBomManagedVersions/backfillVersions exactly like an import BOM.
|
|
31
|
+
* @param store the metadata store (byPath[pom].parentInfo + byId), as built by core.js
|
|
32
|
+
* @returns [{ groupId, artifactId, version }]
|
|
33
|
+
*/
|
|
34
|
+
function collectExternalParents(store) {
|
|
35
|
+
if (!store || !store.byPath) return [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const pomPath of Object.keys(store.byPath)) {
|
|
39
|
+
let cur = pomPath;
|
|
40
|
+
const localSeen = new Set();
|
|
41
|
+
while (cur && !localSeen.has(cur)) {
|
|
42
|
+
localSeen.add(cur);
|
|
43
|
+
const meta = store.byPath[cur];
|
|
44
|
+
if (!meta) break;
|
|
45
|
+
const parentPath = core.resolveParentPath(cur, meta.parentInfo, store);
|
|
46
|
+
if (parentPath) { cur = parentPath; continue; } // local parent → keep climbing
|
|
47
|
+
// No local parent POM: if a <parent> is declared, it's EXTERNAL.
|
|
48
|
+
const p = meta.parentInfo;
|
|
49
|
+
if (p && p.groupId && p.artifactId && p.version && !/\$\{/.test(String(p.version))) {
|
|
50
|
+
const k = `${p.groupId}:${p.artifactId}:${p.version}`;
|
|
51
|
+
if (!seen.has(k)) { seen.add(k); out.push({ groupId: p.groupId, artifactId: p.artifactId, version: p.version }); }
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Collect the version-property OVERRIDES a project declares in its own `<properties>`
|
|
61
|
+
* blocks — the Maven mechanism for patching a coord managed by an external parent
|
|
62
|
+
* (e.g. `<log4j2.version>2.17.1</log4j2.version>` overrides spring-boot-dependencies'
|
|
63
|
+
* default). Fed to effectivePom as `propertyOverrides` so the parent's managed table
|
|
64
|
+
* resolves to the version actually on the classpath, not the framework default.
|
|
65
|
+
*
|
|
66
|
+
* Source is each pom's OWN declared props (store.byPath[pom].properties, raw xml2js
|
|
67
|
+
* `{name:[value]}`) — NOT the merged/inherited map — so we never mistake an inherited
|
|
68
|
+
* builtin for an override. `project.*`/`pom.*` model expressions and unresolved `${…}`
|
|
69
|
+
* values are excluded. Merged across modules (last wins); real-world overrides live in
|
|
70
|
+
* the root and are consistent, so the common case is exact.
|
|
71
|
+
* @returns { [propName]: value }
|
|
72
|
+
*/
|
|
73
|
+
function collectPropertyOverrides(store) {
|
|
74
|
+
const out = {};
|
|
75
|
+
if (!store || !store.byPath) return out;
|
|
76
|
+
for (const meta of Object.values(store.byPath)) {
|
|
77
|
+
const raw = meta && meta.properties;
|
|
78
|
+
if (!raw || typeof raw !== "object") continue;
|
|
79
|
+
for (const [k, val] of Object.entries(raw)) {
|
|
80
|
+
if (k.startsWith("project.") || k.startsWith("pom.")) continue;
|
|
81
|
+
const v = Array.isArray(val) ? val[0] : val;
|
|
82
|
+
if (typeof v !== "string" || /\$\{/.test(v)) continue;
|
|
83
|
+
out[k] = v;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
18
88
|
|
|
19
89
|
/**
|
|
20
90
|
* Extract the distinct external import-BOM coordinates from the parsed poms' merged
|
|
@@ -57,6 +127,10 @@ function collectImportBoms(propsByPom) {
|
|
|
57
127
|
*/
|
|
58
128
|
async function resolveBomManagedVersions(boms, opts = {}) {
|
|
59
129
|
const effectivePom = opts.effectivePom || transitive.effectivePom;
|
|
130
|
+
// `via` labels where the managed version came from: "bom" (import BOM, the default)
|
|
131
|
+
// or "parent" (an external <parent> chain). Only stamped when non-default so the
|
|
132
|
+
// import-BOM entry shape stays byte-identical to before.
|
|
133
|
+
const via = opts.via && opts.via !== "bom" ? opts.via : null;
|
|
60
134
|
const map = new Map();
|
|
61
135
|
for (const bom of boms) {
|
|
62
136
|
let eff = null;
|
|
@@ -68,7 +142,7 @@ async function resolveBomManagedVersions(boms, opts = {}) {
|
|
|
68
142
|
if (!d.groupId || !d.artifactId) continue;
|
|
69
143
|
const k = `${d.groupId}:${d.artifactId}`;
|
|
70
144
|
if (map.has(k)) continue;
|
|
71
|
-
if (d.version && !/\$\{/.test(String(d.version))) map.set(k, { version: String(d.version), bom: bomCoord });
|
|
145
|
+
if (d.version && !/\$\{/.test(String(d.version))) map.set(k, { version: String(d.version), bom: bomCoord, ...(via ? { via } : {}) });
|
|
72
146
|
}
|
|
73
147
|
}
|
|
74
148
|
return map;
|
|
@@ -94,7 +168,7 @@ function backfillVersions(resolvedDeps, mgmtMap) {
|
|
|
94
168
|
dep.version = v;
|
|
95
169
|
if (!Array.isArray(dep.versions)) dep.versions = [];
|
|
96
170
|
if (!dep.versions.includes(v)) dep.versions.push(v);
|
|
97
|
-
dep.versionSource = { via: "bom", bom: entry.bom };
|
|
171
|
+
dep.versionSource = { via: entry.via || "bom", bom: entry.bom };
|
|
98
172
|
filled++;
|
|
99
173
|
}
|
|
100
174
|
return filled;
|
|
@@ -113,4 +187,4 @@ async function resolveAndBackfill(propsByPom, resolvedDeps, opts = {}) {
|
|
|
113
187
|
return { boms: boms.length, filled, mgmtSize: map.size };
|
|
114
188
|
}
|
|
115
189
|
|
|
116
|
-
module.exports = { collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };
|
|
190
|
+
module.exports = { collectExternalParents, collectPropertyOverrides, collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };
|
package/lib/transitive.js
CHANGED
|
@@ -181,7 +181,14 @@ function resolveProps(value, props, builtins, depth = 0) {
|
|
|
181
181
|
* BOM imports inside depMgmt are recursively expanded.
|
|
182
182
|
*/
|
|
183
183
|
async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
|
|
184
|
-
|
|
184
|
+
// A downstream project inheriting this POM as its <parent> can override a version
|
|
185
|
+
// property in its own <properties> (the Spring Boot way to patch a managed coord).
|
|
186
|
+
// opts.propertyOverrides models that: they win over this chain's own property values
|
|
187
|
+
// when resolving depMgmt. They're part of the cache key so an override-free resolution
|
|
188
|
+
// (import-BOM path) and an overridden one (parent path) don't collide in effCache.
|
|
189
|
+
const ovKeys = opts.propertyOverrides ? Object.keys(opts.propertyOverrides) : [];
|
|
190
|
+
const ovSuffix = ovKeys.length ? "|ov:" + ovKeys.sort().map(k => `${k}=${opts.propertyOverrides[k]}`).join(",") : "";
|
|
191
|
+
const key = `${g}:${a}:${v}${ovSuffix}`;
|
|
185
192
|
if (seen.has(key)) return null;
|
|
186
193
|
// Opt-in cross-call memo (used by the per-module overlay, which resolves the
|
|
187
194
|
// same shared parents/BOMs once per module). Immutable POMs → the effective
|
|
@@ -227,21 +234,28 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
|
|
|
227
234
|
"pom.artifactId": merged.artifactId,
|
|
228
235
|
"pom.version": merged.version,
|
|
229
236
|
};
|
|
237
|
+
// Child overrides win over this chain's own property values (Maven: a child's
|
|
238
|
+
// <properties> override an inherited parent's). Applies to the PARENT chain only —
|
|
239
|
+
// import-BOM managed versions resolve in the BOM's own context (stripped below).
|
|
240
|
+
const effProps = ovKeys.length ? { ...merged.properties, ...opts.propertyOverrides } : merged.properties;
|
|
230
241
|
const resolveDep = d => ({
|
|
231
242
|
...d,
|
|
232
|
-
groupId: resolveProps(d.groupId,
|
|
233
|
-
artifactId: resolveProps(d.artifactId,
|
|
234
|
-
version: resolveProps(d.version,
|
|
243
|
+
groupId: resolveProps(d.groupId, effProps, builtins),
|
|
244
|
+
artifactId: resolveProps(d.artifactId, effProps, builtins),
|
|
245
|
+
version: resolveProps(d.version, effProps, builtins),
|
|
235
246
|
});
|
|
236
247
|
merged.depMgmt = merged.depMgmt.map(resolveDep);
|
|
237
248
|
merged.deps = merged.deps.map(resolveDep);
|
|
238
249
|
|
|
239
250
|
// Expand BOM imports inside depMgmt: any entry with scope=import + type=pom
|
|
240
|
-
// is replaced by the depMgmt entries from that imported POM.
|
|
251
|
+
// is replaced by the depMgmt entries from that imported POM. A <scope>import</scope>
|
|
252
|
+
// BOM resolves its managed versions in ITS OWN property context — the importing
|
|
253
|
+
// project's overrides do NOT reach it — so drop propertyOverrides across this boundary.
|
|
254
|
+
const importOpts = ovKeys.length ? { ...opts, propertyOverrides: undefined } : opts;
|
|
241
255
|
const expanded = [];
|
|
242
256
|
for (const dm of merged.depMgmt) {
|
|
243
257
|
if (dm.scope === "import") {
|
|
244
|
-
const imported = await effectivePom(dm.groupId, dm.artifactId, dm.version,
|
|
258
|
+
const imported = await effectivePom(dm.groupId, dm.artifactId, dm.version, importOpts, new Set(seen));
|
|
245
259
|
if (imported) expanded.push(...imported.depMgmt);
|
|
246
260
|
} else {
|
|
247
261
|
expanded.push(dm);
|
package/lib/version-overlay.js
CHANGED
|
@@ -121,27 +121,48 @@ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* The direct dependencies
|
|
125
|
-
*
|
|
126
|
-
*
|
|
124
|
+
* The direct dependencies of ONE module, with concrete versions resolved (own
|
|
125
|
+
* ${properties} → module's managed map → the global resolved version). These seed
|
|
126
|
+
* the per-module transitive walk.
|
|
127
|
+
*
|
|
128
|
+
* Includes the <dependencies> INHERITED from the local parent chain. Maven inherits
|
|
129
|
+
* them into the child, where they stay DIRECT (depth-0) deps and therefore beat any
|
|
130
|
+
* transitive of the same coord — but core.js#getAllInheritedProps deliberately does
|
|
131
|
+
* NOT merge parent <dependencies> into propsByPom[child] (collectResolvedDeps merges
|
|
132
|
+
* them globally by g:a instead). Reading only the module's own <dependencies> would
|
|
133
|
+
* hide a parent-declared remediation (e.g. commons-compress:1.27.1 overriding the
|
|
134
|
+
* vulnerable 1.24.0 that minio pulls in) and let the overlay "recover" the very
|
|
135
|
+
* version that declaration overrides — fabricating CVEs against a version no
|
|
136
|
+
* classpath holds.
|
|
137
|
+
*
|
|
138
|
+
* Chain is child-first, so the closest declaration of a g:a wins, like Maven. `props`
|
|
139
|
+
* is the child's merged map (parent props as base, child overrides on top), which is
|
|
140
|
+
* the right context for a parent-declared dep too.
|
|
127
141
|
*/
|
|
128
|
-
function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
|
|
142
|
+
function buildModuleDirects(pomPath, store, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
|
|
129
143
|
const entry = propsByPom[pomPath];
|
|
130
144
|
if (!entry) return [];
|
|
131
145
|
const props = entry.properties || {};
|
|
132
146
|
const out = [];
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const chain = store ? localChain(pomPath, store).chain : [pomPath];
|
|
149
|
+
for (const pom of chain) {
|
|
150
|
+
for (const node of (propsByPom[pom] || {}).dependencies || []) {
|
|
151
|
+
const d = flattenSafe(node);
|
|
152
|
+
if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
|
|
153
|
+
if (d.scope === "test" && !opts.includeTestDeps) continue;
|
|
154
|
+
if (d.scope === "system" || d.scope === "import") continue;
|
|
155
|
+
// Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
|
|
156
|
+
const gid = resolveDepVersion(d.groupId, props);
|
|
157
|
+
const aid = resolveDepVersion(d.artifactId, props);
|
|
158
|
+
const key = `${gid}:${aid}`;
|
|
159
|
+
if (seen.has(key)) continue;
|
|
160
|
+
let v = resolveDepVersion(d.rawVersion, props);
|
|
161
|
+
if (!isConcrete(v)) v = moduleMgmt.get(key) || resolvedDeps.get(key)?.version || null;
|
|
162
|
+
if (!isConcrete(v)) continue;
|
|
163
|
+
seen.add(key);
|
|
164
|
+
out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
|
|
165
|
+
}
|
|
145
166
|
}
|
|
146
167
|
return out;
|
|
147
168
|
}
|
|
@@ -163,7 +184,7 @@ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}
|
|
|
163
184
|
|
|
164
185
|
for (const pomPath of Object.keys(propsByPom)) {
|
|
165
186
|
const moduleMgmt = await buildModuleManagement(pomPath, store, propsByPom, tOpts);
|
|
166
|
-
const directs = buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, tOpts);
|
|
187
|
+
const directs = buildModuleDirects(pomPath, store, propsByPom, moduleMgmt, resolvedDeps, tOpts);
|
|
167
188
|
if (!directs.length) continue;
|
|
168
189
|
modules++;
|
|
169
190
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.4",
|
|
4
4
|
"description": "Scan ALL Maven, Gradle, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sca",
|
package/REVIEW-2026-07-02.md
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
# Revue de fiabilité — fad-checker v2.4.1 — 2026-07-02
|
|
2
|
-
|
|
3
|
-
> Revue approfondie axée **bugs de correction** (faux négatifs/positifs) et **mises à jour à faire**,
|
|
4
|
-
> avec focus demandé sur **Go, Python (PyPI) et C# (NuGet)**. Chaque bug listé ci-dessous a été
|
|
5
|
-
> **confirmé par reproduction** (test ou exécution réelle), pas seulement par lecture de code.
|
|
6
|
-
> Les correctifs ont été appliqués dans la foulée (commits séparés par sujet) avec un test de
|
|
7
|
-
> régression chacun. Suite de tests : **569 → 587, toutes vertes**.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## 1. Verdict global
|
|
12
|
-
|
|
13
|
-
L'architecture est saine (codecs isolés, matching multi-sources avec dédup, gate pure, caches
|
|
14
|
-
offline-aware) et la couverture de test est réelle. **Mais la revue a trouvé un défaut critique
|
|
15
|
-
en production** : l'index CVE local ne contenait pas Log4Shell — un audit air-gapped d'un projet
|
|
16
|
-
Java livrait un rapport propre sur `log4j-core:2.14.0`. Ce défaut était détectable : le test
|
|
17
|
-
`cli-safety` échouait déjà (568/569) au début de cette revue. Les trois écosystèmes sur lesquels
|
|
18
|
-
tu avais des doutes (Go/PyPI/NuGet) avaient chacun **au moins un trou de collecte réel** en
|
|
19
|
-
configuration entreprise courante (CPM .NET, pip-compile hashé, `replace` Go). Tous corrigés.
|
|
20
|
-
|
|
21
|
-
**Après correctifs : l'outil est fiable pour un usage d'audit professionnel**, avec les limites
|
|
22
|
-
résiduelles documentées en §4 (aucune n'est silencieuse : elles sur-rapportent ou sont hors
|
|
23
|
-
périmètre annoncé).
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
## 2. Bugs confirmés et corrigés
|
|
28
|
-
|
|
29
|
-
| # | Gravité | Composant | Bug | Effet en audit |
|
|
30
|
-
|---|---------|-----------|-----|----------------|
|
|
31
|
-
| B1 | **CRITIQUE** | `lib/cve-download.js` | Index CVE : les enregistrements pré-2023 sans métadonnées Maven machine-réadables étaient exclus — **Log4Shell absent de l'index** | Faux négatif majeur en mode offline/air-gapped (l'argument de vente n°1 de l'outil) |
|
|
32
|
-
| B2 | **HAUTE** | `lib/osv.js` | Cache OSV : TTL 12 h appliqué **même en `--offline`** | Machine air-gapped scannée >12 h après warm → « 0 vulns OSV » silencieux, tous écosystèmes |
|
|
33
|
-
| B3 | **HAUTE** | `lib/codecs/nuget.codec.js` | `Directory.Packages.props` cherché uniquement dans le dossier du `.csproj` (jamais en remontant) | Solution .NET en Central Package Management (le standard moderne) → **0 dep collectée** |
|
|
34
|
-
| B4 | **HAUTE** | `lib/codecs/pypi/parse.js` | `pkg==1.0 \` (continuation `pip-compile --generate-hashes`) et ` --hash=…` inline rejetés par `isPinned()` | Fichier requirements hash-pinné (pratique des shops sécurisés…) → **0 dep collectée** |
|
|
35
|
-
| B5 | MOYENNE+ | `lib/codecs/go/parse.js` | Directives `replace` ignorées | Un `replace` qui downgrade/redirige un module → version effective non scannée (FN) ; version remplacée scannée à tort (FP) |
|
|
36
|
-
| B6 | MOYENNE | `lib/codecs/go.codec.js` | `go.sum` jamais consulté dès que `go.mod` a des `require` | Module `go < 1.17` (liste seulement les directs) → **transitifs invisibles** |
|
|
37
|
-
| B7 | MOYENNE | `lib/codecs/nuget/registry.js` | Index de registration **paginé** (paquets populaires : Newtonsoft.Json, Serilog…) : pages `@id` jamais fetchées | Deprecation + outdated silencieusement vides pour les paquets les plus courants |
|
|
38
|
-
| B8 | MOYENNE | `pypi.codec.js` / `nuget.codec.js` | Même paquet pinné à des versions différentes dans 2 fichiers/projets → seule une version scannée (`out.set` écrase) | FN si la version la plus vieille est la vulnérable — violait la convention Maven « every distinct version is scanned » |
|
|
39
|
-
| B9 | MOYENNE | `lib/maven-version.js` | Bornes placeholder (`lessThan:"log4j-core*"`, `"*"`, `"unspecified"`) comparées comme des versions Maven | Comparaison garbage → ranges jamais matchés (FN) ; fail-open possible sur `{version:"*", lessThan:"*"}` |
|
|
40
|
-
| B10 | BASSE+ | `lib/codecs/nuget/parse.js` | Pin exact NuGet `[1.2.3]` rejeté comme « range » ; `VersionOverride` (CPM) ignoré | Deps skippées ou scannées à la mauvaise version |
|
|
41
|
-
| B11 | BASSE | `lib/cve-download.js` | `fixVersion` = **premier** `lessThan` rencontré | Advisory multi-branche (struts 2.3.32 / 2.5.10.1) → suggestion de « fix » plus vieille que la version courante |
|
|
42
|
-
| B12 | BASSE | `lib/codecs/pypi/parse.js` | `uv.lock` : le paquet racine du projet (`source.virtual`/`editable`) inventorié comme dep | Bruit ; collision possible avec un paquet PyPI homonyme |
|
|
43
|
-
| B13 | BASSE | registres go/pypi/nuget | Run offline ré-écrivait `meta.fetchedAt` du cache | Cache périmé considéré frais au run online suivant → refresh sauté |
|
|
44
|
-
|
|
45
|
-
### B1 en détail (le plus important)
|
|
46
|
-
|
|
47
|
-
Le vrai enregistrement cvelistV5 de **CVE-2021-44228** n'a **ni** `packageName`, **ni**
|
|
48
|
-
`collectionURL`, **ni** `versionType:"maven"` ; son vendor est `"Apache Software Foundation"`
|
|
49
|
-
(entité légale, pas le token `apache` attendu en match exact), son produit `"Apache Log4j2"`
|
|
50
|
-
(le heuristique produit était ancré `^log4j`), sa borne `lessThan` est le placeholder
|
|
51
|
-
`"log4j-core*"` et ses fenêtres affectées sont encodées en `versions[].changes[]` (timeline
|
|
52
|
-
CVE 5.x) — quatre caractéristiques que le pipeline ignorait toutes. La fixture de test
|
|
53
|
-
existante (`cve-with-packagename.json`) était une version **synthétique idéalisée** du même CVE
|
|
54
|
-
avec un `packageName` propre : le test passait, le réel échouait.
|
|
55
|
-
|
|
56
|
-
**Correctif** (4 volets) :
|
|
57
|
-
1. `isMavenRelevant()` tokenise le vendor (`"apache software foundation"` → `apache` ✓) et
|
|
58
|
-
teste le produit aussi après suppression du mot vendor (`"apache log4j2"` → `log4j2`) ;
|
|
59
|
-
2. la map curée `data/cpe-coord-map.json` est consultée au build de l'index et **backfille
|
|
60
|
-
`packageName`** sur les enregistrements product-only → match **tier-1 exact** (entrée
|
|
61
|
-
`apache:log4j2` ajoutée) ;
|
|
62
|
-
3. `changes[]` est expansé en fenêtres affected simples — `[2.0-beta9,2.3.1) ∪ [2.4,2.12.2) ∪
|
|
63
|
-
[2.13.0,2.15.0)` — donc **2.14.0 matche et les backports corrigés 2.12.2/2.3.1 ne matchent
|
|
64
|
-
pas** ;
|
|
65
|
-
4. `isVersionAffected()` ignore les bornes non-versions (fail-closed préservé : un spec fait
|
|
66
|
-
uniquement de placeholders ne matche jamais).
|
|
67
|
-
|
|
68
|
-
**Mesure** : index reconstruit **6 589 → 15 236 CVE (+131 %)** ; `log4j-core` passe de 4 entrées
|
|
69
|
-
(toutes 2025-2026) à 12 (dont 44228/45046/44832/45105, 2019-17571…). Le scan de la fixture
|
|
70
|
-
polyglot remonte désormais 3 CRITICAL au lieu de 0, et `--fail-on high` sort en exit 1.
|
|
71
|
-
Test de régression sur **l'enregistrement réel** committé en fixture.
|
|
72
|
-
|
|
73
|
-
> ⚠️ **Action requise sur tes machines d'audit** : l'index en cache (`~/.fad-checker/cve-data/`)
|
|
74
|
-
> reste amputé tant qu'il n'est pas reconstruit avec le code corrigé (fait sur cette machine :
|
|
75
|
-
> rebuild du 2026-07-02). **Re-exporte aussi les archives `--export-cache`** utilisées pour les
|
|
76
|
-
> audits air-gapped — celles générées avant ce fix embarquent l'index défectueux.
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## 3. Fiabilité par écosystème (état après correctifs)
|
|
81
|
-
|
|
82
|
-
| Écosystème | Verdict | Notes |
|
|
83
|
-
|---|---|---|
|
|
84
|
-
| **Maven / Gradle** | ✅ Solide | Résolution transitive offline + overlay par module : différenciant réel. Rappel CVE-index legacy dépend désormais de la map curée (§4.9) — OSV reste le filet principal online |
|
|
85
|
-
| **Go** | ✅ Bon | `replace` appliqués, go.sum mergé pré-1.17, encodage casse proxy correct. Reste : stdlib non scannée (§4.1) |
|
|
86
|
-
| **PyPI** | ✅ Bon | PEP 503 propre partout, hash-pins parsés, multi-versions scannées. Matching de versions délégué à OSV côté serveur (PEP 440 correct par construction) |
|
|
87
|
-
| **NuGet** | ✅ Bon | CPM walk-up, `VersionOverride`, pins exacts, registrations paginées. Reste : `Directory.Build.props` (§4.5) |
|
|
88
|
-
| **npm/yarn/pnpm** | ✅ (non ré-audité en profondeur cette passe) | Lane la plus mature (retire.js + registry + OSV) |
|
|
89
|
-
| **Composer / Ruby** | ⚠️ Non ré-audités en profondeur cette passe | Même architecture que pypi/nuget — les patterns corrigés (multi-versions, fetchedAt offline) méritent une passe de vérification |
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
|
-
## 4. Limites restantes (non corrigées — connues et assumées)
|
|
94
|
-
|
|
95
|
-
Aucune n'est un faux négatif *silencieux* ; elles sur-rapportent, dégradent une lane secondaire,
|
|
96
|
-
ou sont hors périmètre annoncé. Par priorité décroissante :
|
|
97
|
-
|
|
98
|
-
1. **Go stdlib non scannée** : la directive `go 1.x`/`toolchain` n'émet pas de dep `stdlib`
|
|
99
|
-
(OSV la couvre, ecosystem `Go`, package `stdlib`). Un CVE net/http Go n'apparaîtra pas.
|
|
100
|
-
*Complément recommandé : `govulncheck` sur les projets Go, ou émettre un record `stdlib`.*
|
|
101
|
-
2. **OSV NuGet et la casse** : OSV matche les noms de paquets de façon sensible à la casse ;
|
|
102
|
-
un lockfile écrivant `newtonsoft.json` (rare — la casse canonique est la norme) pourrait
|
|
103
|
-
rater l'advisory. Canonicalisation via le registry envisageable.
|
|
104
|
-
3. **uv.lock : pas de classification dev** — tout est marqué prod (sur-rapporte, direction
|
|
105
|
-
sûre pour un audit). Poetry : les groupes custom non-dev sont classés dev (sous-gate
|
|
106
|
-
potentiel pour `--fail-on` sur ces groupes).
|
|
107
|
-
4. **PEP 735 `[dependency-groups]`** (pyproject) non parsé (fallback best-effort uniquement).
|
|
108
|
-
5. **NuGet** : `Directory.Build.props` (items partagés), `GlobalPackageReference`, attribut
|
|
109
|
-
`Update`, et `developmentDependency` de packages.config non pris en compte.
|
|
110
|
-
6. **CVSS v4** : score non calculé depuis le vecteur (v3.0/3.1 seulement) ; la sévérité retombe
|
|
111
|
-
sur `database_specific.severity` (GHSA la fournit presque toujours) — impact faible.
|
|
112
|
-
7. **Comparateurs `cmp()` naïfs** dans les registries go/pypi/nuget (lane « outdated »
|
|
113
|
-
uniquement — jamais utilisés pour le matching de vulnérabilités) : pré-releases mal
|
|
114
|
-
ordonnées possibles dans la colonne outdated.
|
|
115
|
-
8. **Tier-2 `byProduct`** : la clé reste le produit brut du CNA (`"apache log4j2"`), donc ce
|
|
116
|
-
tier ne matche que si produit == artifactId. Le vrai chemin pour les CVE legacy est le
|
|
117
|
-
backfill par map curée (B1) : **la qualité du rappel legacy est proportionnelle à la
|
|
118
|
-
curation de `data/cpe-coord-map.json`** — l'enrichir en continu (candidats : jenkins,
|
|
119
|
-
elasticsearch, kafka, activemq, camel, cxf, solr, weblogic/websphere…).
|
|
120
|
-
9. **Test `cli-safety` dépendant du cache HOME** : il scanne avec le vrai `~/.fad-checker` —
|
|
121
|
-
c'est CE test qui a détecté l'index amputé (précieux), mais il échouera sur une machine au
|
|
122
|
-
cache froid. Assumé ou à isoler (fixture d'index dédiée) — au choix.
|
|
123
|
-
10. **`known-obsolete.json` / `popular-packages.json`** : listes curées à la main — prévoir un
|
|
124
|
-
rafraîchissement périodique (les cibles typosquat vieillissent vite).
|
|
125
|
-
|
|
126
|
-
---
|
|
127
|
-
|
|
128
|
-
## 5. Mises à jour à faire (état au 2026-07-02)
|
|
129
|
-
|
|
130
|
-
### Dépendances de l'outil (`npm outdated`)
|
|
131
|
-
| Paquet | Actuel | Dispo | Action |
|
|
132
|
-
|---|---|---|---|
|
|
133
|
-
| retire | 5.4.2 | **5.4.3** | patch sûr, à prendre (scanner JS vendored) |
|
|
134
|
-
| commander | 14.0.1 | 14.0.3 / 15.0.0 | prendre 14.0.3 ; major 15 à évaluer |
|
|
135
|
-
| js-yaml | 4.1.1 | 4.3.0 / 5.2.1 | prendre 4.3.0 ; major 5 à évaluer |
|
|
136
|
-
| smol-toml | 1.6.1 | 1.7.0 | mineur sûr |
|
|
137
|
-
| rimraf | 6.0.1 | 6.1.3 | mineur sûr |
|
|
138
|
-
| chalk / p-limit | 4.1.2 / 3.1.0 | 5.x / 7.x | **ESM-only** → rester en 4.x/3.x tant que le build CJS+bun est la cible (choix actuel correct) |
|
|
139
|
-
|
|
140
|
-
Aucune CVE connue sur les versions pinnées (vérifié via la lane npm du repo lui-même).
|
|
141
|
-
|
|
142
|
-
### APIs externes
|
|
143
|
-
- **endoflife.date** : l'ancien endpoint `/api/<slug>.json` répond encore (HTTP 200, vérifié
|
|
144
|
-
aujourd'hui) mais la v1 (`/api/v1/products/<slug>`) est la cible long-terme → prévoir la
|
|
145
|
-
migration du fetch dans `lib/outdated.js` (champ de réponse différent).
|
|
146
|
-
- NVD 2.0, OSV v1, EPSS, KEV, deps.dev v3, CIRCL, proxys de registries : inchangés, rien à faire.
|
|
147
|
-
- **CVEProject cvelistV5** : le format `.zip.zip` imbriqué est toujours servi (vérifié via le
|
|
148
|
-
rebuild d'aujourd'hui — 362 782 fichiers scannés).
|
|
149
|
-
|
|
150
|
-
### Caches d'audit
|
|
151
|
-
- **Reconstruire l'index CVE** sur chaque machine (`--force` du download, ou attendre
|
|
152
|
-
l'expiration 24 h online). Fait sur cette machine.
|
|
153
|
-
- **Régénérer les archives `--export-cache`** destinées aux postes air-gapped (elles
|
|
154
|
-
contiennent l'index pré-fix).
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## 6. Avis sur la pertinence de l'outil
|
|
159
|
-
|
|
160
|
-
**L'outil a une vraie raison d'être.** Sa niche — audit multi-écosystème **entièrement
|
|
161
|
-
offline/air-gapped** avec résolution transitive Maven réelle, exports normalisés
|
|
162
|
-
(SBOM/VEX/SARIF), gate CI et rapport livrable client — n'est couverte par aucun outil unique du
|
|
163
|
-
marché : OSV-Scanner et Trivy dégradent fortement hors-ligne (64/202 findings vs 181/202 mesurés
|
|
164
|
-
sur le projet de référence), Dependency-Check est Java-centrique et lent, Snyk exige le cloud.
|
|
165
|
-
Pour des audits chez des clients à contraintes réseau (banque, défense, industriel), c'est un
|
|
166
|
-
différenciant réel. Les plus : provenance/checksums des rapports (reproductibilité d'audit),
|
|
167
|
-
chapitre méthodologie, triage VEX/ignore, priorisation KEV/EPSS — c'est le bon jeu de features
|
|
168
|
-
pour du livrable d'audit professionnel.
|
|
169
|
-
|
|
170
|
-
**Deux réserves à garder en tête** :
|
|
171
|
-
1. **La fiabilité repose sur des données warmées** — cette revue le prouve : un index mal
|
|
172
|
-
construit a produit des rapports faussement propres pendant ~2 semaines sans aucun signal
|
|
173
|
-
visible en ligne (OSV masquait le trou). Recommandation structurelle : ajouter un
|
|
174
|
-
**canari d'auto-validation** au build de l'index (ex. vérifier que CVE-2021-44228,
|
|
175
|
-
CVE-2017-5638 et 2-3 autres sentinelles sont présentes et matchables, sinon échec bruyant
|
|
176
|
-
du build). C'est le correctif systémique qui manque encore.
|
|
177
|
-
2. **Le coût de maintenance est réel** : 10 codecs, ~15 sources de données, des formats de
|
|
178
|
-
lockfiles qui évoluent (uv, pnpm v9, PEP 735…). L'outil est bien architecturé pour ça
|
|
179
|
-
(ajouter un codec = un dossier), mais il faut budgéter une passe de vérification
|
|
180
|
-
trimestrielle du type de celle-ci.
|
|
181
|
-
|
|
182
|
-
En l'état, après cette passe : **adapté à un usage d'audit professionnel**, à condition de
|
|
183
|
-
reconstruire les caches (cf. §5) et d'ajouter le canari de l'index en prochaine itération.
|
|
184
|
-
|
|
185
|
-
---
|
|
186
|
-
|
|
187
|
-
*Revue et correctifs : session Claude (Fable 5) du 2026-07-02. 13 bugs corrigés, 18 tests de
|
|
188
|
-
régression ajoutés, suite 587/587 verte. Le scanner de certificats (chapitre 2.4) présent en
|
|
189
|
-
non-commité dans l'arbre de travail n'a pas été audité en profondeur dans cette passe (ses 19
|
|
190
|
-
tests passent) et reste non-commité.*
|