fad-checker 2.2.1 → 2.2.3

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/fad-checker.js CHANGED
@@ -236,7 +236,10 @@ program
236
236
  .option("--ignore-test", "skip test-scoped dependencies in report")
237
237
  .option("--cve-refresh", "force re-download of CVE database")
238
238
  .option("--cve-offline", "use cached CVE index only (no download)")
239
+ .option("--osv-db", "import + match the full local OSV database (Maven) — offline-complete recall, independent of the per-dep OSV cache")
240
+ .option("--osv-db-refresh", "force re-download of the local OSV database")
239
241
  .option("--snyk", "run snyk on cleaned POMs and merge into report (requires --target)")
242
+ .option("--typosquat", "flag npm/PyPI deps whose name is one edit from a popular package (heuristic typosquat/slopsquat detection)")
240
243
  .option("--no-retire", "skip retire.js vendored-JS scan")
241
244
  .option("--no-vendored-js-inventory", "don't list ALL identified vendored JS libs (chapter 1D) — keep only the vulnerable ones (chapter 2)")
242
245
  .option("--retire-refresh", "ignore retire cache and re-scan")
@@ -597,6 +600,18 @@ async function timedPhase(label, fn) {
597
600
  }
598
601
  }
599
602
 
603
+ // Mirror every non-Maven lockfile/manifest (npm/yarn/pnpm, composer, pypi, nuget,
604
+ // go, ruby) into the cleaned tree so `snyk test --all-projects` scans the WHOLE
605
+ // polyglot project, not just the cleaned POMs. Maven POMs are the rewrite above.
606
+ let copiedManifests = 0;
607
+ if (!readOnly) {
608
+ try {
609
+ const { copyEcosystemManifests } = require("./lib/manifest-copy");
610
+ const r = await copyEcosystemManifests(options.src, options.target, { excludePath, defaultExcludes });
611
+ copiedManifests = r.copied;
612
+ } catch (err) { console.error(chalk.red(" ✗ manifest copy failed:"), err.message); }
613
+ }
614
+
600
615
  // ---------- Maven POM analysis summary (parents missing / excluded) ----------
601
616
  let privateLibIds = [];
602
617
  if (runMaven && mavenCtx) {
@@ -674,11 +689,15 @@ async function timedPhase(label, fn) {
674
689
  else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
675
690
  }
676
691
 
692
+ if (!readOnly && copiedManifests) {
693
+ ui.ok(`${chalk.bold(copiedManifests)} non-Maven lockfile/manifest(s) mirrored → ${chalk.white(options.target)} ${chalk.dim("(so snyk --all-projects scans every ecosystem)")}`);
694
+ }
695
+
677
696
  // ---------- Scan flow (CVE / EOL / Obsolete) ----------
678
697
  // The scan always runs — it feeds the terminal summary, the file outputs and the
679
698
  // CI gate. Which files get written is decided by the --report-* family inside
680
699
  // (HTML + .doc by default; --no-report writes nothing).
681
- await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
700
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
682
701
  if (!readOnly) {
683
702
  ui.section("Next step");
684
703
  ui.info(`run Snyk on the cleaned tree:`);
@@ -687,7 +706,7 @@ async function timedPhase(label, fn) {
687
706
  })();
688
707
 
689
708
  async function runReportFlow(resolved, ecoFlags = {}) {
690
- const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
709
+ const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
691
710
  const registriesFor = eco => regMap[eco] || [];
692
711
  const { expandWithTransitives } = require("./lib/cve-match");
693
712
  const { writeReports, computeStats } = require("./lib/cve-report");
@@ -701,13 +720,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
701
720
  const npmWarnings = collectWarnings || [];
702
721
  let scanWarnings = [];
703
722
  const directCount = resolved.size;
704
-
705
- // Scan-completeness signals: BOMs and unresolved-version deps mean fad-checker
706
- // has gone as far as it can without running Maven/Snyk itself.
707
- if (runMaven) {
708
- const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
709
- scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
710
- }
723
+ // NOTE: scan-completeness (unresolved-versions) is computed LATER — after the BOM
724
+ // version-resolution step backfills external-BOM-managed versions so it reflects
725
+ // what's *genuinely* still unresolved, not what a Maven Central BOM fetch will fix.
711
726
 
712
727
  // ---- Vulnerability database update (global step progress) ----
713
728
  ui.section("Vulnerability database update");
@@ -726,7 +741,21 @@ async function runReportFlow(resolved, ecoFlags = {}) {
726
741
  const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
727
742
  const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
728
743
  const willTransitive = !!(options.transitive && runMaven);
744
+ // Per-module version mediation overlay: recover transitive versions the global
745
+ // transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
746
+ // the global pass runs, and needs the parsed store + per-module props.
747
+ const willOverlay = willTransitive && !!mavenPropsByPom && !!mavenStore;
748
+ // External import BOMs (e.g. spring-boot-dependencies): resolve their managed
749
+ // versions to backfill declared deps that pin no version of their own (the usual
750
+ // Spring Boot setup). Cached via poms-cache; offline-aware (uses warmed POMs,
751
+ // never the network — same as transitive resolution).
752
+ const importBoms = (runMaven && mavenPropsByPom)
753
+ ? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
754
+ const willBom = importBoms.length > 0;
729
755
  const willOsv = !!options.osv;
756
+ // Local OSV DB import (Maven): offline-complete OSV recall, independent of the per-dep
757
+ // OSV.dev cache. Opt-in (downloads ~9 MB once); then matches online or offline.
758
+ const willOsvDb = !!options.osvDb && runMaven;
730
759
  const willOutdated = !!options.allLibs;
731
760
  const willNvd = !!options.nvd;
732
761
  const willEpss = !!options.epss;
@@ -737,9 +766,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
737
766
  const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
738
767
  // License detection piggybacks on the registry passes (same fetched metadata),
739
768
  // so it adds no progress step of its own.
740
- const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
769
+ const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willOsvDb, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
741
770
  const progress = new ui.Progress(totalSteps);
742
771
 
772
+ if (willBom) {
773
+ const st = progress.start("BOM version resolution (Maven Central)");
774
+ try {
775
+ const { resolveAndBackfill } = require("./lib/maven-bom");
776
+ const r = await resolveAndBackfill(mavenPropsByPom, resolved, { repos: mavenRepos, offline, verbose });
777
+ st.done(`${r.filled} dep version(s) from ${r.boms} import BOM(s)`);
778
+ } catch (err) { st.fail(err.message); }
779
+ }
780
+
743
781
  if (willTransitive) {
744
782
  const st = progress.start("Transitive resolution (Maven Central)");
745
783
  await expandWithTransitives(resolved, {
@@ -752,6 +790,32 @@ async function runReportFlow(resolved, ecoFlags = {}) {
752
790
  st.done(`+${resolved.size - directCount} transitive (total ${resolved.size})`);
753
791
  }
754
792
 
793
+ if (willOverlay) {
794
+ const st = progress.start("Per-module version mediation (masked transitives)");
795
+ try {
796
+ const { expandPerModuleOverlay } = require("./lib/version-overlay");
797
+ const ov = await expandPerModuleOverlay(resolved, mavenStore, mavenPropsByPom, {
798
+ verbose,
799
+ offline,
800
+ maxDepth: parseInt(options.transitiveDepth, 10) || 6,
801
+ includeTestDeps: !options.ignoreTest,
802
+ repos: mavenRepos,
803
+ });
804
+ st.done(`+${ov.appended} masked version(s) recovered across ${ov.modules} module(s)`);
805
+ if (verbose && ov.recovered.length) {
806
+ for (const r of ov.recovered) console.log(` ↳ ${r.coord}: +${r.version} (had ${r.had}) via ${r.module}`);
807
+ }
808
+ } catch (err) { st.fail(err.message); }
809
+ }
810
+
811
+ // Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
812
+ // still without a concrete version (external BOM truly unreachable, or offline)
813
+ // are flagged, not the ones we just resolved from spring-boot-dependencies & co.
814
+ if (runMaven) {
815
+ const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
816
+ scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
817
+ }
818
+
755
819
  // 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
756
820
  let cveMatches = [];
757
821
  let cveDataDate = null;
@@ -854,6 +918,25 @@ async function runReportFlow(resolved, ecoFlags = {}) {
854
918
  }
855
919
  }
856
920
 
921
+ // 4b'. Local OSV database (Maven) — offline-COMPLETE recall. The per-dep OSV.dev
922
+ // queries above only cover deps cached online; the imported full OSV DB matches every
923
+ // dep offline, deterministically, regardless of cache warmth (the OSV-Scanner model).
924
+ if (willOsvDb) {
925
+ const st = progress.start("OSV database (local, Maven)");
926
+ try {
927
+ const { ensureOsvDb, matchOsvDbDeps } = require("./lib/osv-db");
928
+ const index = await ensureOsvDb({ offline, refresh: !!options.osvDbRefresh, verbose, ecosystem: "maven" });
929
+ if (!index) {
930
+ st.done(offline ? "no local OSV DB (run once online with --osv-db)" : "unavailable");
931
+ } else {
932
+ const dbMatches = matchOsvDbDeps(resolved, index);
933
+ const before = cveMatches.length;
934
+ cveMatches = mergeBySource(cveMatches, dbMatches);
935
+ st.done(`${index.count} advisories · ${dbMatches.length} matched · +${cveMatches.length - before} new`);
936
+ }
937
+ } catch (err) { st.fail(err.message); }
938
+ }
939
+
857
940
  // 4c. NVD enrichment — canonical description + full CVSS for matched CVEs.
858
941
  if (willNvd) {
859
942
  const st = progress.start("NVD enrichment");
@@ -961,6 +1044,22 @@ async function runReportFlow(resolved, ecoFlags = {}) {
961
1044
  }
962
1045
  }
963
1046
 
1047
+ // 6b. Supply-chain risk lane (pure + offline): flag KNOWN-MALICIOUS advisories
1048
+ // (OSV MAL-… already in the match set) always, and detect suspected TYPOSQUATS
1049
+ // (opt-in --typosquat, heuristic — names one edit from a popular package).
1050
+ const { flagMalicious, detectTyposquats } = require("./lib/malware");
1051
+ const maliciousCount = flagMalicious(cveMatches);
1052
+ const typosquats = options.typosquat ? detectTyposquats(resolved) : [];
1053
+ if (maliciousCount || typosquats.length) {
1054
+ ui.section("Supply-chain risk");
1055
+ if (maliciousCount) ui.warn(chalk.red.bold(`${maliciousCount} KNOWN-MALICIOUS package advisory(ies) (MAL-…) — treat the build as compromised`));
1056
+ if (typosquats.length) {
1057
+ ui.warn(`${typosquats.length} suspected typosquat(s) — heuristic, verify each is intentional:`);
1058
+ for (const t of typosquats.slice(0, 15)) console.log(" " + chalk.yellow(t.name) + chalk.dim(` (${t.ecosystem}) ≈ ${t.resembles}`));
1059
+ if (typosquats.length > 15) console.log(chalk.dim(` …and ${typosquats.length - 15} more (see JSON export)`));
1060
+ }
1061
+ }
1062
+
964
1063
  // Split prod vs dev based on the dep's isDev flag (set at collection time
965
1064
  // from Maven scope=test/provided and npm dev/devOptional/optional). Keep the
966
1065
  // full per-bucket list (including cpeFiltered) so the HTML report can render
@@ -1078,7 +1177,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1078
1177
  ui.warn(`${scanWarnings.length} scan-completeness note(s) — a real Maven/Snyk run may surface more:`);
1079
1178
  for (const w of scanWarnings) {
1080
1179
  ui.info(chalk.dim(`[${w.type}] ${w.message}`));
1081
- for (const it of (w.items || []).slice(0, 4)) console.log(" " + chalk.dim(`· ${it}`));
1180
+ // Items may be plain strings or { id, manifestPaths } objects (the
1181
+ // unresolved-versions warning carries the defining manifest paths).
1182
+ for (const it of (w.items || []).slice(0, 4)) {
1183
+ const id = typeof it === "string" ? it : it.id;
1184
+ console.log(" " + chalk.dim(`· ${id}`));
1185
+ }
1082
1186
  if ((w.items || []).length > 4) console.log(" " + chalk.dim(`· …and ${w.items.length - 4} more`));
1083
1187
  }
1084
1188
  }
@@ -1190,7 +1294,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1190
1294
  try {
1191
1295
  const { writeFindings } = require("./lib/json-export");
1192
1296
  await ensureDir(out.json);
1193
- writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version }, out.json);
1297
+ writeFindings({ cveMatches, retireMatches, vendoredJsInventory, eolResults, obsoleteResults, outdatedResults, licenseResults, resolvedDeps: resolved, projectInfo, toolVersion: pkg.version, typosquats }, out.json);
1194
1298
  wrote.push(["Findings JSON", out.json]);
1195
1299
  } catch (err) { ui.warn(`JSON export failed: ${err.message}`); }
1196
1300
  }
@@ -1217,9 +1321,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1217
1321
  const { evaluateGate } = require("./lib/gate");
1218
1322
  // Embedded-binary findings are real production risk → gate on them too.
1219
1323
  const gate = evaluateGate([...prodActive, ...embeddedActive], options.failOn);
1220
- if (gate.failed) {
1324
+ // A known-malicious package always blocks, regardless of the --fail-on level.
1325
+ const maliciousActive = [...prodActive, ...embeddedActive].filter(m => m.malicious);
1326
+ if (gate.failed || maliciousActive.length) {
1221
1327
  ui.section("Gate");
1222
- console.log(chalk.red(`✗ --fail-on ${options.failOn}: ${gate.reason}`));
1328
+ if (maliciousActive.length) console.log(chalk.red.bold(`✗ ${maliciousActive.length} known-malicious package(s) detected — always blocks`));
1329
+ if (gate.failed) console.log(chalk.red(`✗ --fail-on ${options.failOn}: ${gate.reason}`));
1223
1330
  process.exitCode = 1;
1224
1331
  } else if (verbose) {
1225
1332
  ui.info(chalk.dim(`--fail-on ${options.failOn}: no blocking finding`));
package/lib/core.js CHANGED
@@ -312,11 +312,33 @@ async function rewritePoms(pomPath, allPomMetadata, allPropsByPom, opts) {
312
312
  if (!keep.has(k)) delete props[k];
313
313
  }
314
314
 
315
+ // Interpolate ${…} in coordinates against THIS pom's resolved props (incl. the
316
+ // project.* built-ins via getAllInheritedProps' localVars). A reactor sibling dep
317
+ // declared <groupId>${project.groupId}</groupId> must resolve to the real groupId for
318
+ // the byId/local-module lookup + the "missing/private" classification below — otherwise
319
+ // it's wrongly reported as a private lib (e.g. "${project.groupId}:cnaps-core"). The
320
+ // dep NODE itself is left raw, so the rewritten cleaned POM keeps ${…} for Snyk's mvn.
321
+ const pomProps = (allPropsByPom && allPropsByPom[pomPath] && allPropsByPom[pomPath].properties) || {};
322
+ const interp = (s) => {
323
+ if (s == null) return s;
324
+ let out = String(s);
325
+ for (let i = 0; i < 10 && out.includes("${"); i++) {
326
+ const next = out.replace(/\$\{\s*([\w.\-]+)\s*\}/g, (m, k) => {
327
+ const val = pomProps[k];
328
+ if (val == null) return m;
329
+ return Array.isArray(val) ? String(val[0]) : String(val);
330
+ });
331
+ if (next === out) break;
332
+ out = next;
333
+ }
334
+ return out;
335
+ };
336
+
315
337
  const cleanDeps = list =>
316
338
  list?.filter(dep => {
317
- const g = coord(dep.groupId?.[0]);
318
- const a = coord(dep.artifactId?.[0]);
319
- const v = coord(dep.version?.[0]);
339
+ const g = interp(coord(dep.groupId?.[0]));
340
+ const a = interp(coord(dep.artifactId?.[0]));
341
+ const v = interp(coord(dep.version?.[0]));
320
342
  if (!g || !a) return false;
321
343
  if (deps2Exclude) {
322
344
  // Versionless deps inside dependencyManagement-merged result are
package/lib/cve-match.js CHANGED
@@ -49,11 +49,18 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
49
49
  const collectFrom = (depList, pomPath, props) => {
50
50
  if (!depList) return;
51
51
  for (const dep of depList) {
52
- const g = coord(dep.groupId?.[0]);
53
- const a = coord(dep.artifactId?.[0]);
52
+ let g = coord(dep.groupId?.[0]);
53
+ let a = coord(dep.artifactId?.[0]);
54
54
  let v = coord(dep.version?.[0]);
55
55
  const scope = dep.scope?.[0] || "compile";
56
56
  if (!g || !a) continue;
57
+ // Interpolate ${…} in the COORDINATE too, not just the version. Reactor sibling
58
+ // deps routinely use ${project.groupId}/${project.artifactId} (Maven built-ins,
59
+ // already in `props` via core.js localVars) — without this they'd enter the scan
60
+ // set as a literal "${project.groupId}:…" coord. Resolve BEFORE the exclude test
61
+ // so `-e` matches the real groupId.
62
+ g = resolveDepVersion(g, props);
63
+ a = resolveDepVersion(a, props);
57
64
  if (ignoreTest && scope === "test") continue;
58
65
  if (deps2Exclude && deps2Exclude.test(g)) continue;
59
66
  if (v) v = resolveDepVersion(v, props);
@@ -180,12 +187,17 @@ function matchOne(dep, entries, confidence) {
180
187
  // the same g:a), not just the representative highest — otherwise a CVE that
181
188
  // only affects a lower-versioned profile variant would be missed.
182
189
  const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
190
+ // Only CONCRETE, resolved versions can be matched. A dep whose version is null
191
+ // or an unresolved `${property}` (typically pinned by an external BOM like
192
+ // spring-boot-dependencies) carries NO version we can compare — matching it
193
+ // against every "affected" range would flag it as vulnerable to ALL the
194
+ // coordinate's CVEs (a flood of false CRITICALs with version "?"). Skip it;
195
+ // it's already reported in chapter 0 as an "unresolved-versions" warning.
196
+ const concrete = versions.filter(v => v && !/\$\{/.test(String(v)));
197
+ if (!concrete.length) return matches;
183
198
  for (const e of entries) {
184
- for (const ver of versions) {
185
- const affected = (e.ranges || []).some(r => {
186
- if (!ver) return r.status === "affected"; // unknown version → assume affected
187
- return isVersionAffected(ver, r);
188
- });
199
+ for (const ver of concrete) {
200
+ const affected = (e.ranges || []).some(r => isVersionAffected(ver, r));
189
201
  if (affected) {
190
202
  const vdep = ver === dep.version ? dep : { ...dep, version: ver };
191
203
  matches.push({ dep: vdep, cve: { ...e }, confidence });
@@ -49,6 +49,7 @@ function cveFinding(m) {
49
49
  fixVersion: c.fixVersion || null,
50
50
  source: m.source || null,
51
51
  confidence: m.confidence || null,
52
+ malicious: !!m.malicious,
52
53
  cpeFiltered: !!m.cpeFiltered,
53
54
  suppressed: !!m.suppressed,
54
55
  suppressedReason: m.suppressedReason || null,
@@ -67,6 +68,7 @@ function buildFindings(payload = {}) {
67
68
  const {
68
69
  cveMatches = [], retireMatches = [], vendoredJsInventory = [], eolResults = [], obsoleteResults = [],
69
70
  outdatedResults = [], licenseResults = null, resolvedDeps, projectInfo = {}, toolVersion = "0",
71
+ typosquats = [],
70
72
  } = payload;
71
73
 
72
74
  const { buildInventory } = require("./unmanaged");
@@ -101,6 +103,8 @@ function buildFindings(payload = {}) {
101
103
  embedded: embedded.length,
102
104
  vendoredJs: vendoredJsInventory.length,
103
105
  suppressed: cveMatches.filter(m => m.suppressed).length,
106
+ malicious: cveMatches.filter(m => m.malicious && !m.suppressed).length,
107
+ typosquat: typosquats.length,
104
108
  },
105
109
  cve: cveMatches.map(cveFinding),
106
110
  vendored: retireMatches.map(cveFinding),
@@ -111,6 +115,7 @@ function buildFindings(payload = {}) {
111
115
  unmanaged,
112
116
  embedded,
113
117
  vendoredJs: vendoredJsInventory,
118
+ typosquat: typosquats,
114
119
  };
115
120
  }
116
121
 
package/lib/malware.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * lib/malware.js — supply-chain risk lane: known-malicious advisories + typosquat
3
+ * heuristic. Pure + fully offline (no extra network; data/popular-packages.json is bundled).
4
+ *
5
+ * Two distinct signals:
6
+ * 1. flagMalicious(matches) — PRECISE. Marks any match whose advisory is a known
7
+ * malicious-package report (OSV `MAL-…` id/alias, or a `malicious` database flag)
8
+ * as malicious. These already flow into the match set via OSV / the local OSV DB;
9
+ * this just recognises and elevates them (gate hard-fails on them, like KEV).
10
+ * 2. detectTyposquats(resolvedDeps) — PROACTIVE / heuristic. Flags an npm or PyPI
11
+ * dependency whose name is edit-distance 1 from a well-known popular package (but
12
+ * isn't it) — the classic typosquat / "slopsquat" pattern (reqeusts, lodahs, …),
13
+ * which no vuln DB catches until after the fact. Conservative (distance 1, length
14
+ * ≥ 5, non-scoped) to bound false positives; opt-in via --typosquat.
15
+ */
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ /**
20
+ * Damerau-Levenshtein (optimal string alignment) distance, bounded by `max`.
21
+ * Counts an ADJACENT TRANSPOSITION as 1 — the dominant typosquat pattern (lodahs↔lodash,
22
+ * reqeusts↔requests) that plain Levenshtein would score as 2. Returns max+1 when the
23
+ * length gap alone already exceeds the budget. Package names are short, so the full
24
+ * matrix is cheap; the length prefilter handles the obvious skips.
25
+ */
26
+ function editDistance(a, b, max) {
27
+ if (a === b) return 0;
28
+ const la = a.length, lb = b.length;
29
+ if (Math.abs(la - lb) > max) return max + 1;
30
+ const d = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
31
+ for (let i = 0; i <= la; i++) d[i][0] = i;
32
+ for (let j = 0; j <= lb; j++) d[0][j] = j;
33
+ for (let i = 1; i <= la; i++) {
34
+ for (let j = 1; j <= lb; j++) {
35
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
36
+ let v = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
37
+ if (i > 1 && j > 1 && a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && a.charCodeAt(i - 2) === b.charCodeAt(j - 1)) {
38
+ v = Math.min(v, d[i - 2][j - 2] + 1); // adjacent transposition
39
+ }
40
+ d[i][j] = v;
41
+ }
42
+ }
43
+ return d[la][lb] <= max ? d[la][lb] : max + 1;
44
+ }
45
+
46
+ let _popular = null;
47
+ function loadPopular(file = path.join(__dirname, "..", "data", "popular-packages.json")) {
48
+ if (_popular) return _popular;
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(file, "utf8"));
51
+ // PyPI names normalised per PEP 503 (lowercase, fold -/_/.) — same rule the matcher applies.
52
+ _popular = { npm: new Set(j.npm || []), pypi: new Set((j.pypi || []).map(s => s.toLowerCase().replace(/[-_.]+/g, "-"))) };
53
+ } catch { _popular = { npm: new Set(), pypi: new Set() }; }
54
+ return _popular;
55
+ }
56
+
57
+ /** Mark matches that are known-malicious advisories. Mutates + returns the count. */
58
+ function flagMalicious(matches) {
59
+ let n = 0;
60
+ for (const m of matches || []) {
61
+ const id = String(m.cve?.id || "");
62
+ const aliases = m.cve?.aliases || [];
63
+ const isMal = id.startsWith("MAL-") || aliases.some(a => String(a).startsWith("MAL-")) || m.cve?.malicious === true;
64
+ if (isMal) { m.malicious = true; if (m.cve) m.cve.malicious = true; n++; }
65
+ }
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * Heuristic typosquat detection for npm/PyPI deps. Returns
71
+ * [{ ecosystem, coord, name, resembles, distance }]. PyPI names are normalised
72
+ * (lowercase, - / _ folded) per PEP 503 before comparison.
73
+ */
74
+ function detectTyposquats(resolvedDeps, opts = {}) {
75
+ const popular = opts.popular || loadPopular();
76
+ const out = [];
77
+ const norm = (eco, name) => eco === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name;
78
+ for (const dep of resolvedDeps.values()) {
79
+ const eco = dep.ecosystem;
80
+ if (eco !== "npm" && eco !== "pypi") continue;
81
+ if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
82
+ const rawName = dep.name || dep.artifactId || "";
83
+ if (!rawName || rawName.startsWith("@")) continue; // skip scoped npm (namespace-protected)
84
+ const name = norm(eco, rawName);
85
+ const set = eco === "npm" ? popular.npm : popular.pypi;
86
+ if (!set.size || set.has(eco === "pypi" ? name : rawName)) continue; // a popular pkg itself is not a squat
87
+ if (name.length < 5) continue; // too short → noisy
88
+ let best = null;
89
+ for (const pop of set) {
90
+ const target = eco === "pypi" ? pop : pop; // pypi set already normalised at load
91
+ if (Math.abs(target.length - name.length) > 1) continue;
92
+ const d = editDistance(name, target, 1);
93
+ if (d === 1) { best = target; break; }
94
+ }
95
+ if (best) out.push({ ecosystem: eco, coord: dep.coordKey || rawName, name: rawName, resembles: best, distance: 1 });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ module.exports = { flagMalicious, detectTyposquats, editDistance, loadPopular };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * lib/manifest-copy.js — when writing a cleaned tree (`-t`), copy every NON-Maven
3
+ * ecosystem manifest / lockfile from the source into the target at the same relative
4
+ * path, so `snyk test --all-projects` on the cleaned tree scans every ecosystem — not
5
+ * just the cleaned Maven POMs (which are written separately by core.rewritePoms).
6
+ *
7
+ * Maven is excluded (its cleaned POMs are the whole point of the rewrite); the binary
8
+ * codec is excluded (its files aren't dependency manifests). node_modules / vendor and
9
+ * friends are pruned so we copy the project's own lockfiles, not vendored ones.
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { makeDirFilter } = require("./path-filter");
14
+ const { allCodecs } = require("./codecs");
15
+
16
+ // Ecosystems whose manifests/lockfiles Snyk reads off disk.
17
+ const COPY_ECOSYSTEMS = new Set(["npm", "nuget", "composer", "pypi", "go", "ruby"]);
18
+ // Companion files that aren't a codec's primary manifest but Snyk/the build need.
19
+ const COMPANION_FILES = ["Directory.Packages.props", "Directory.Build.props", "nuget.config", "Gemfile", "Pipfile", "go.work", "go.work.sum"];
20
+ // Directories never worth copying manifests from (installed/vendored trees).
21
+ const SKIP_DIRS = new Set([
22
+ "node_modules", "bower_components", "jspm_packages", "vendor",
23
+ ".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
24
+ "target", "dist", "build", "out", "bin", "obj", ".next", ".nuxt", "coverage",
25
+ ]);
26
+
27
+ /** Pure: the exact filenames + extensions that count as a copyable manifest. */
28
+ function manifestMatchers() {
29
+ const exact = new Set(COMPANION_FILES);
30
+ const exts = [];
31
+ for (const c of allCodecs()) {
32
+ if (!COPY_ECOSYSTEMS.has(c.id)) continue;
33
+ for (const n of c.manifestNames || []) {
34
+ if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
35
+ else exact.add(n);
36
+ }
37
+ }
38
+ return { exact, exts };
39
+ }
40
+
41
+ /** Pure: is this basename a manifest we copy? */
42
+ function isManifestName(name, matchers = manifestMatchers()) {
43
+ return matchers.exact.has(name) || matchers.exts.some(e => name.endsWith(e));
44
+ }
45
+
46
+ /**
47
+ * Copy every matching manifest from srcRoot into targetRoot at the same relative path.
48
+ * @returns { copied: number, files: string[] } (files are relative paths)
49
+ */
50
+ async function copyEcosystemManifests(srcRoot, targetRoot, opts = {}) {
51
+ const matchers = manifestMatchers();
52
+ const skipDir = makeDirFilter({ srcRoot, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
53
+ const files = [];
54
+ const walk = async (dir) => {
55
+ let entries;
56
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
57
+ for (const e of entries) {
58
+ const abs = path.join(dir, e.name);
59
+ if (e.isDirectory()) { if (!skipDir(abs, e.name)) await walk(abs); continue; }
60
+ if (!e.isFile()) continue;
61
+ if (!isManifestName(e.name, matchers)) continue;
62
+ const rel = path.relative(srcRoot, abs);
63
+ const dest = path.join(targetRoot, rel);
64
+ try {
65
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
66
+ await fs.promises.copyFile(abs, dest);
67
+ files.push(rel);
68
+ } catch { /* best effort per file */ }
69
+ }
70
+ };
71
+ await walk(srcRoot);
72
+ return { copied: files.length, files };
73
+ }
74
+
75
+ module.exports = { manifestMatchers, isManifestName, copyEcosystemManifests, COPY_ECOSYSTEMS };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * lib/maven-bom.js — resolve EXTERNAL import-scope BOMs (e.g. spring-boot-dependencies)
3
+ * from Maven Central and backfill the versions of declared deps that the BOM manages.
4
+ *
5
+ * core.js follows LOCAL `<scope>import</scope>` BOMs (their POM is in the source tree)
6
+ * but cannot enumerate an external BOM's managed-version table without fetching it. So
7
+ * in a typical Spring Boot project every `spring-boot-starter-*` (declared without a
8
+ * version, version pinned by the imported BOM) ends up unresolved — flooding chapter 0
9
+ * and dropping out of the CVE/EOL/outdated scans.
10
+ *
11
+ * This module fetches each external import BOM via transitive.js#effectivePom (which
12
+ * already merges the parent chain, resolves `${properties}` and recursively expands
13
+ * nested import BOMs), builds a g:a → version map, and fills it into the versionless
14
+ * declared deps. Network + cached (poms-cache, immutable) + offline-aware (the caller
15
+ * skips it offline). Pure except for the injected/real effectivePom fetch.
16
+ */
17
+ const transitive = require("./transitive");
18
+
19
+ /**
20
+ * Extract the distinct external import-BOM coordinates from the parsed poms' merged
21
+ * dependencyManagement. `version` is resolved against each pom's property map; entries
22
+ * that stay unresolved (`${…}`) or lack a g/a are skipped.
23
+ * @param propsByPom map pomPath → { properties, dependencyManagement } (xml2js-shaped)
24
+ * @returns [{ groupId, artifactId, version }] deduped by g:a:v
25
+ */
26
+ function collectImportBoms(propsByPom) {
27
+ const seen = new Set();
28
+ const out = [];
29
+ for (const pom of Object.keys(propsByPom || {})) {
30
+ const entry = propsByPom[pom];
31
+ if (!entry) continue;
32
+ const props = entry.properties || {};
33
+ for (const d of (entry.dependencyManagement || [])) {
34
+ if (d?.scope?.[0] !== "import") continue;
35
+ const g = transitive.resolveProps(d.groupId?.[0], props);
36
+ const a = transitive.resolveProps(d.artifactId?.[0], props);
37
+ const v = transitive.resolveProps(d.version?.[0], props);
38
+ if (!g || !a || !v || /\$\{/.test(String(v))) continue;
39
+ const k = `${g}:${a}:${v}`;
40
+ if (seen.has(k)) continue;
41
+ seen.add(k);
42
+ out.push({ groupId: g, artifactId: a, version: v });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Resolve each import BOM to its managed-version table and merge them into one map.
50
+ * First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
51
+ */
52
+ async function resolveBomManagedVersions(boms, opts = {}) {
53
+ const effectivePom = opts.effectivePom || transitive.effectivePom;
54
+ const map = new Map();
55
+ for (const bom of boms) {
56
+ let eff = null;
57
+ try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
58
+ catch { eff = null; }
59
+ if (!eff || !eff.depMgmt) continue;
60
+ for (const d of eff.depMgmt) {
61
+ if (!d.groupId || !d.artifactId) continue;
62
+ const k = `${d.groupId}:${d.artifactId}`;
63
+ if (map.has(k)) continue;
64
+ if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
65
+ }
66
+ }
67
+ return map;
68
+ }
69
+
70
+ /**
71
+ * Fill the version of every Maven dep that has no concrete version (null or `${…}`)
72
+ * from the BOM-managed map. Mutates the resolvedDeps Map entries in place.
73
+ * @returns number of deps filled
74
+ */
75
+ function backfillVersions(resolvedDeps, mgmtMap) {
76
+ let filled = 0;
77
+ for (const dep of resolvedDeps.values()) {
78
+ if (dep.ecosystem !== "maven") continue;
79
+ if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
80
+ if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
81
+ const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
82
+ if (!v) continue;
83
+ dep.version = v;
84
+ if (!Array.isArray(dep.versions)) dep.versions = [];
85
+ if (!dep.versions.includes(v)) dep.versions.push(v);
86
+ filled++;
87
+ }
88
+ return filled;
89
+ }
90
+
91
+ /**
92
+ * One-shot: collect external import BOMs from the poms, resolve their managed versions
93
+ * online, and backfill the versionless declared deps.
94
+ * @returns { boms, filled, mgmtSize }
95
+ */
96
+ async function resolveAndBackfill(propsByPom, resolvedDeps, opts = {}) {
97
+ const boms = collectImportBoms(propsByPom);
98
+ if (!boms.length) return { boms: 0, filled: 0, mgmtSize: 0 };
99
+ const map = await resolveBomManagedVersions(boms, opts);
100
+ const filled = backfillVersions(resolvedDeps, map);
101
+ return { boms: boms.length, filled, mgmtSize: map.size };
102
+ }
103
+
104
+ module.exports = { collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };