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 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
- const descriptor = serializeDeps(resolved, { generator: `fad-checker ${pkgVersion}` });
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
- ui.warn(`${missingParents.length} missing parent POM(s) Snyk will fail if these are private:`);
695
- for (const id of missingParents.slice(0, 10)) ui.info(chalk.yellow(id));
696
- if (missingParents.length > 10) ui.info(chalk.dim(`…and ${missingParents.length - 10} more`));
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
- const willBom = allBoms.length > 0;
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 mgmt = await resolveBomManagedVersions(allBoms, { repos: mavenRepos, offline, verbose });
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} import BOM(s)`);
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 6.0.3 doesn't read as fabricated.
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 versionLine = (vs && vs.via === "bom" && vs.bom)
417
- ? `<div class="defined-in"><span class="defined-label">version managed by:</span> <code>${esc(vs.bom)}</code> (BOM)</div>`
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,
@@ -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
- const key = `${g}:${a}:${v}`;
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, merged.properties, builtins),
233
- artifactId: resolveProps(d.artifactId, merged.properties, builtins),
234
- version: resolveProps(d.version, merged.properties, builtins),
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, opts, new Set(seen));
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);
@@ -121,27 +121,48 @@ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
121
121
  }
122
122
 
123
123
  /**
124
- * The direct dependencies declared by ONE module, with concrete versions resolved
125
- * (own ${properties} → module's managed map → the global resolved version). These
126
- * seed the per-module transitive walk.
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
- for (const node of entry.dependencies || []) {
134
- const d = flattenSafe(node);
135
- if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
136
- if (d.scope === "test" && !opts.includeTestDeps) continue;
137
- if (d.scope === "system" || d.scope === "import") continue;
138
- // Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
139
- const gid = resolveDepVersion(d.groupId, props);
140
- const aid = resolveDepVersion(d.artifactId, props);
141
- let v = resolveDepVersion(d.rawVersion, props);
142
- if (!isConcrete(v)) v = moduleMgmt.get(`${gid}:${aid}`) || resolvedDeps.get(`${gid}:${aid}`)?.version || null;
143
- if (!isConcrete(v)) continue;
144
- out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
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.2",
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",
@@ -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é.*