fullshield 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -645,6 +645,173 @@ var init_config = __esm({
645
645
  }
646
646
  });
647
647
 
648
+ // src/modules/deps/lockfile-modern.ts
649
+ function addEdge(edges, from, to) {
650
+ if (from.length === 0 || to.length === 0 || from === to) return;
651
+ const list = edges.get(from) ?? /* @__PURE__ */ new Set();
652
+ list.add(to);
653
+ edges.set(from, list);
654
+ }
655
+ function graph(packages, edges, directProd, directDev, edgesKnown) {
656
+ return { packages, edges, directProd, directDev, edgesKnown };
657
+ }
658
+ function parseYarnBerry(contents) {
659
+ const packages = [];
660
+ const edges = /* @__PURE__ */ new Map();
661
+ const directProd = /* @__PURE__ */ new Set();
662
+ const directDev = /* @__PURE__ */ new Set();
663
+ let current;
664
+ let inDeps = false;
665
+ let devBlock = false;
666
+ for (const raw of contents.split(/\r?\n/)) {
667
+ if (!raw.startsWith(" ") && raw.trimEnd().endsWith(":")) {
668
+ const key = raw.slice(0, -1).trim().replace(/^"|"$/g, "");
669
+ current = key.includes("@workspace:.") ? "" : nameFromBerryKey(key);
670
+ inDeps = false;
671
+ devBlock = false;
672
+ continue;
673
+ }
674
+ const version = /^\s+version:\s+(.+)$/.exec(raw)?.[1]?.replace(/["']/g, "").trim();
675
+ if (version !== void 0 && current !== void 0 && current.length > 0 && !inDeps) {
676
+ packages.push({ name: current, version: versionFromProtocol(version), path: `yarn:${current}` });
677
+ continue;
678
+ }
679
+ if (/^\s+dependencies:\s*$/.test(raw)) {
680
+ inDeps = true;
681
+ devBlock = false;
682
+ continue;
683
+ }
684
+ if (/^\s+devDependencies:\s*$/.test(raw)) {
685
+ inDeps = true;
686
+ devBlock = true;
687
+ continue;
688
+ }
689
+ if (!inDeps || current === void 0) continue;
690
+ const dep = /^\s{4}"?([^":]+)"?:\s+(.+)$/.exec(raw);
691
+ if (dep?.[1] === void 0) continue;
692
+ const depName = dep[1];
693
+ if (current.length === 0) {
694
+ if (devBlock) directDev.add(depName);
695
+ else directProd.add(depName);
696
+ } else {
697
+ addEdge(edges, current, depName);
698
+ }
699
+ }
700
+ return graph(packages, edges, directProd, directDev, true);
701
+ }
702
+ function nameFromBerryKey(key) {
703
+ const first = key.split(",")[0]?.trim() ?? key;
704
+ if (first.startsWith("@")) {
705
+ const match = /^(@[^@/]+\/[^@/]+)@/.exec(first);
706
+ return match?.[1];
707
+ }
708
+ const at = first.indexOf("@");
709
+ return at <= 0 ? first : first.slice(0, at);
710
+ }
711
+ function versionFromProtocol(raw) {
712
+ const patch = /^patch:(.+)#/.exec(raw);
713
+ const body = patch?.[1] ?? raw;
714
+ const npm = /^(?:npm:)?(@?[^@]+)@(.+)$/.exec(body);
715
+ if (npm?.[2] !== void 0) return npm[2];
716
+ return body;
717
+ }
718
+ function parsePnpmV9(contents) {
719
+ const packages = [];
720
+ const edges = /* @__PURE__ */ new Map();
721
+ const directProd = /* @__PURE__ */ new Set();
722
+ const directDev = /* @__PURE__ */ new Set();
723
+ let block = "none";
724
+ let snapshot;
725
+ let inSnapshotDeps = false;
726
+ for (const raw of contents.split(/\r?\n/)) {
727
+ if (raw === "packages:") {
728
+ block = "packages";
729
+ snapshot = void 0;
730
+ continue;
731
+ }
732
+ if (raw === "snapshots:") {
733
+ block = "snapshots";
734
+ snapshot = void 0;
735
+ continue;
736
+ }
737
+ if (raw === "importers:" || raw.startsWith("lockfileVersion:")) {
738
+ block = "none";
739
+ continue;
740
+ }
741
+ if (block !== "snapshots" && /^\s{4}dependencies:\s*$/.test(raw)) {
742
+ block = "importer-deps";
743
+ continue;
744
+ }
745
+ if (/^\s{4}devDependencies:\s*$/.test(raw)) {
746
+ block = "importer-dev";
747
+ continue;
748
+ }
749
+ if (block === "packages") {
750
+ const header = /^\s{2}['"]?(@?[^@'\s]+)@([^:'"]+)['"]?:/.exec(raw);
751
+ if (header?.[1] !== void 0 && header[2] !== void 0) {
752
+ packages.push({ name: header[1], version: header[2], path: `pnpm:${header[1]}` });
753
+ }
754
+ continue;
755
+ }
756
+ if (block === "snapshots") {
757
+ const header = /^\s{2}['"]?(@?[^@'\s]+)@([^:'"]+)['"]?:/.exec(raw);
758
+ if (header?.[1] !== void 0) {
759
+ snapshot = header[1];
760
+ inSnapshotDeps = false;
761
+ continue;
762
+ }
763
+ if (/^\s+dependencies:\s*$/.test(raw)) {
764
+ inSnapshotDeps = true;
765
+ continue;
766
+ }
767
+ if (inSnapshotDeps && snapshot !== void 0) {
768
+ const dep = /^\s{6}['"]?([^'":]+)['"]?:/.exec(raw);
769
+ if (dep?.[1] !== void 0) addEdge(edges, snapshot, dep[1]);
770
+ }
771
+ continue;
772
+ }
773
+ if (block === "importer-deps" || block === "importer-dev") {
774
+ const dep = /^\s{6}['"]?([^'":]+)['"]?:/.exec(raw);
775
+ if (dep?.[1] !== void 0 && !raw.trim().startsWith("specifier") && !raw.trim().startsWith("version")) {
776
+ if (block === "importer-dev") directDev.add(dep[1]);
777
+ else directProd.add(dep[1]);
778
+ }
779
+ }
780
+ }
781
+ return graph(packages, edges, directProd, directDev, true);
782
+ }
783
+ function parseBunLock(contents) {
784
+ const stripped = contents.replace(/^\s*\/\/.*$/gm, "");
785
+ const packages = [];
786
+ const edges = /* @__PURE__ */ new Map();
787
+ const directProd = /* @__PURE__ */ new Set();
788
+ const directDev = /* @__PURE__ */ new Set();
789
+ try {
790
+ const json = JSON.parse(stripped);
791
+ for (const [name, value] of Object.entries(json.packages ?? {})) {
792
+ if (!Array.isArray(value)) continue;
793
+ const entry = Array.from(value);
794
+ const spec = typeof entry[0] === "string" ? entry[0] : "";
795
+ const version = spec.includes("@") ? spec.slice(spec.lastIndexOf("@") + 1) : spec;
796
+ packages.push({ name, version, path: `bun:${name}` });
797
+ const meta = entry[1];
798
+ if (meta !== null && typeof meta === "object" && "dependencies" in meta) {
799
+ const deps = meta.dependencies;
800
+ if (deps !== null && typeof deps === "object") {
801
+ for (const dep of Object.keys(deps)) addEdge(edges, name, dep);
802
+ }
803
+ }
804
+ }
805
+ } catch {
806
+ return graph([], /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set(), /* @__PURE__ */ new Set(), false);
807
+ }
808
+ return graph(packages, edges, directProd, directDev, packages.length > 0);
809
+ }
810
+ var init_lockfile_modern = __esm({
811
+ "src/modules/deps/lockfile-modern.ts"() {
812
+ }
813
+ });
814
+
648
815
  // src/modules/deps/lockfile.ts
649
816
  function basename2(filename) {
650
817
  const parts = filename.replace(/\\/g, "/").split("/");
@@ -674,7 +841,7 @@ function objectKeys(value) {
674
841
  }
675
842
  return Object.keys(value);
676
843
  }
677
- function addEdge(edges, from, to) {
844
+ function addEdge2(edges, from, to) {
678
845
  const set = edges.get(from) ?? /* @__PURE__ */ new Set();
679
846
  set.add(to);
680
847
  edges.set(from, set);
@@ -754,7 +921,7 @@ function parseYarnGraph(contents) {
754
921
  const depBerry = /^\s{4}(?:"([^"]+)"|(@?[\w./-]+)):/.exec(raw);
755
922
  const depName = depClassic?.[1] ?? depClassic?.[2] ?? depBerry?.[1] ?? depBerry?.[2];
756
923
  if (depName !== void 0) {
757
- addEdge(edges, currentName, depName);
924
+ addEdge2(edges, currentName, depName);
758
925
  continue;
759
926
  }
760
927
  if (/^\s{2}\S/.test(raw)) inDeps = false;
@@ -848,7 +1015,7 @@ function parsePnpmGraph(contents) {
848
1015
  const dep = /^ (?:'([^']+)'|"([^"]+)"|([^:]+)):/.exec(raw);
849
1016
  const depName = dep?.[1] ?? dep?.[2] ?? dep?.[3];
850
1017
  if (depName !== void 0) {
851
- addEdge(edges, currentPkg, depName);
1018
+ addEdge2(edges, currentPkg, depName);
852
1019
  continue;
853
1020
  }
854
1021
  if (/^ \S/.test(raw)) inPkgDeps = false;
@@ -883,7 +1050,7 @@ function graphFromPackagesTable(packages) {
883
1050
  const name = key === "" ? "" : nameFromPackagesKey(key);
884
1051
  if (name === void 0 || name.length === 0) continue;
885
1052
  for (const dep of [...objectKeys(value.dependencies), ...objectKeys(value.optionalDependencies)]) {
886
- addEdge(edges, name, dep);
1053
+ addEdge2(edges, name, dep);
887
1054
  }
888
1055
  }
889
1056
  return { edges, directProd, directDev };
@@ -893,7 +1060,7 @@ function graphFromDependenciesTree(deps, edges) {
893
1060
  const child = value.dependencies;
894
1061
  if (child === void 0 || typeof child !== "object" || child === null) continue;
895
1062
  const nested = child;
896
- for (const childName of Object.keys(nested)) addEdge(edges, name, childName);
1063
+ for (const childName of Object.keys(nested)) addEdge2(edges, name, childName);
897
1064
  graphFromDependenciesTree(nested, edges);
898
1065
  }
899
1066
  }
@@ -906,8 +1073,16 @@ function parseLockfileGraph(filename, contents) {
906
1073
  directDev: /* @__PURE__ */ new Set(),
907
1074
  edgesKnown: false
908
1075
  };
909
- if (name === "yarn.lock") return parseYarnGraph(contents);
910
- if (name === "pnpm-lock.yaml") return parsePnpmGraph(contents);
1076
+ const modern = process.env.FULLSHIELD_EXPERIMENTAL_LOCKFILES === "1";
1077
+ if (name === "yarn.lock") {
1078
+ if (modern && contents.includes("__metadata")) return parseYarnBerry(contents);
1079
+ return parseYarnGraph(contents);
1080
+ }
1081
+ if (name === "pnpm-lock.yaml") {
1082
+ if (modern && /lockfileVersion:\s*['"]?9/.test(contents)) return parsePnpmV9(contents);
1083
+ return parsePnpmGraph(contents);
1084
+ }
1085
+ if (modern && name === "bun.lock") return parseBunLock(contents);
911
1086
  if (name !== "package-lock.json") return empty;
912
1087
  try {
913
1088
  const json = JSON.parse(contents);
@@ -934,6 +1109,7 @@ function parseLockfileGraph(filename, contents) {
934
1109
  var PNPM_KEY;
935
1110
  var init_lockfile = __esm({
936
1111
  "src/modules/deps/lockfile.ts"() {
1112
+ init_lockfile_modern();
937
1113
  PNPM_KEY = /^ {2}['"]?(?:\/)?(@?[^'"@\s/]+(?:\/[^'"@\s/]+)?)@([^'"(\s:]+)(?:\([^)]*\))?['"]?:\s*$/;
938
1114
  }
939
1115
  });
@@ -1092,6 +1268,91 @@ var init_pins = __esm({
1092
1268
  }
1093
1269
  });
1094
1270
 
1271
+ // src/modules/deps/evidence.ts
1272
+ function packageNameFromSpec(spec) {
1273
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return void 0;
1274
+ if (spec.startsWith("@")) {
1275
+ const slash2 = spec.indexOf("/");
1276
+ if (slash2 < 0) return void 0;
1277
+ const rest = spec.slice(slash2 + 1);
1278
+ const cut = rest.indexOf("/");
1279
+ return cut === -1 ? spec : spec.slice(0, slash2 + 1 + cut);
1280
+ }
1281
+ const slash = spec.indexOf("/");
1282
+ return slash === -1 ? spec : spec.slice(0, slash);
1283
+ }
1284
+ function collectImportSites(sources) {
1285
+ const byPkg = /* @__PURE__ */ new Map();
1286
+ const files = [...sources.keys()].sort();
1287
+ for (const file of files) {
1288
+ const text = sources.get(file) ?? "";
1289
+ const lines = text.split(/\r?\n/);
1290
+ for (let i = 0; i < lines.length; i += 1) {
1291
+ const line = lines[i] ?? "";
1292
+ IMPORT_RE.lastIndex = 0;
1293
+ let match;
1294
+ while ((match = IMPORT_RE.exec(line)) !== null) {
1295
+ const spec = match[1] ?? match[2] ?? match[3];
1296
+ if (spec === void 0) continue;
1297
+ const pkg = packageNameFromSpec(spec);
1298
+ if (pkg === void 0) continue;
1299
+ const list = byPkg.get(pkg) ?? [];
1300
+ list.push({ file, line: i + 1, specifier: spec, pkg });
1301
+ byPkg.set(pkg, list);
1302
+ }
1303
+ }
1304
+ }
1305
+ for (const [pkg, list] of byPkg) {
1306
+ list.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
1307
+ byPkg.set(pkg, list.slice(0, 5));
1308
+ }
1309
+ return byPkg;
1310
+ }
1311
+ function evidenceForFinding(finding6, sites, productionFiles, edgesKnown) {
1312
+ if (finding6.ruleId !== "DEPS-VULN-001" && finding6.ruleId !== "DEPS-MALICIOUS-001") return void 0;
1313
+ const snippet = finding6.snippet ?? "";
1314
+ const at = snippet.startsWith("@") ? snippet.indexOf("@", 1) : snippet.indexOf("@");
1315
+ const pkg = at <= 0 ? snippet : snippet.slice(0, at);
1316
+ if (pkg.length === 0) return void 0;
1317
+ const imports = (sites.get(pkg) ?? []).map((site) => ({
1318
+ file: site.file,
1319
+ line: site.line,
1320
+ specifier: site.specifier
1321
+ }));
1322
+ const first = imports[0];
1323
+ const path = finding6.dependencyPath;
1324
+ let reason;
1325
+ switch (finding6.reachability) {
1326
+ case "not-imported":
1327
+ reason = `No production file imports ${pkg}. Checked ${productionFiles.length} production file(s)${productionFiles.length > 0 ? `: ${productionFiles.slice(0, 8).join(", ")}` : ""}.`;
1328
+ return { imports: [], reason };
1329
+ case "reachability-unknown":
1330
+ reason = edgesKnown ? `Reachability for ${pkg} is unknown.` : `Lockfile edges are missing, so ${pkg} is reachability-unknown.`;
1331
+ return { imports, ...first !== void 0 ? {} : {}, reason };
1332
+ case "reachable-via-dependency":
1333
+ reason = path !== void 0 && path.length > 0 ? `Required through ${path.join(" > ")}.` : `Required by an imported package.`;
1334
+ return { imports, ...path !== void 0 ? { dependencyPath: path } : {}, reason };
1335
+ case "imported-not-called":
1336
+ reason = first !== void 0 ? `Imported from ${first.file}:${first.line} and not called.` : `Imported and not called.`;
1337
+ return { imports, reason };
1338
+ case "symbol-reachable":
1339
+ reason = first !== void 0 ? `A named vulnerable export is called from ${first.file}:${first.line}.` : `A named vulnerable export is called.`;
1340
+ return { imports, reason };
1341
+ case "symbol-not-called":
1342
+ reason = `A named vulnerable export is not called.`;
1343
+ return { imports, reason };
1344
+ default:
1345
+ reason = first !== void 0 ? `Imported from ${first.file}:${first.line}.` : `Imported by production code.`;
1346
+ return { imports, ...path !== void 0 ? { dependencyPath: path } : {}, reason };
1347
+ }
1348
+ }
1349
+ var IMPORT_RE;
1350
+ var init_evidence = __esm({
1351
+ "src/modules/deps/evidence.ts"() {
1352
+ IMPORT_RE = /(?:require\(\s*['"]([^'"]+)['"]\s*\)|from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\))/g;
1353
+ }
1354
+ });
1355
+
1095
1356
  // src/modules/deps/symbol-reachability.ts
1096
1357
  function affectedFunctionNames(vuln) {
1097
1358
  const names = /* @__PURE__ */ new Set();
@@ -1200,7 +1461,7 @@ function packageNameFromSnippet(snippet) {
1200
1461
  const at = snippet.indexOf("@");
1201
1462
  return at <= 0 ? snippet : snippet.slice(0, at);
1202
1463
  }
1203
- function packageNameFromSpec(spec) {
1464
+ function packageNameFromSpec2(spec) {
1204
1465
  if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return void 0;
1205
1466
  if (spec.startsWith("@")) {
1206
1467
  const parts = spec.split("/");
@@ -1236,7 +1497,7 @@ function indexPackageUsage(sources) {
1236
1497
  const local = m[1];
1237
1498
  const spec = m[2];
1238
1499
  if (local === void 0 || spec === void 0) continue;
1239
- const pkg = packageNameFromSpec(spec);
1500
+ const pkg = packageNameFromSpec2(spec);
1240
1501
  if (pkg === void 0) continue;
1241
1502
  bump(pkg, localIsCalled(source, local));
1242
1503
  }
@@ -1246,7 +1507,7 @@ function indexPackageUsage(sources) {
1246
1507
  while ((named = namedRe.exec(source)) !== null) {
1247
1508
  const spec = named[2];
1248
1509
  if (spec === void 0) continue;
1249
- const pkg = packageNameFromSpec(spec);
1510
+ const pkg = packageNameFromSpec2(spec);
1250
1511
  if (pkg === void 0) continue;
1251
1512
  const locals = (named[1] ?? "").split(",").map((s) => s.trim().split(/\s+as\s+/).pop() ?? "");
1252
1513
  bump(
@@ -1305,34 +1566,55 @@ function reachableFrom(target, roots, edges) {
1305
1566
  if (roots.has(target)) return true;
1306
1567
  return shortestDependencyPath(target, roots, edges) !== void 0;
1307
1568
  }
1308
- function importedDirectNames(usage, graph) {
1309
- const directs = /* @__PURE__ */ new Set([...graph.directProd, ...graph.directDev]);
1569
+ function importedDirectNames(usage, graph2) {
1570
+ const directs = /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]);
1310
1571
  const out = /* @__PURE__ */ new Set();
1311
- for (const [name, hit] of usage) {
1312
- if (!hit.imported) continue;
1572
+ for (const [name, hit2] of usage) {
1573
+ if (!hit2.imported) continue;
1313
1574
  if (directs.size === 0 || directs.has(name)) out.add(name);
1314
1575
  }
1315
1576
  return out;
1316
1577
  }
1317
- function classifyReachability(pkgName, productionUsage, graph) {
1318
- const hit = productionUsage.get(pkgName);
1319
- if (hit?.imported === true) {
1320
- return { reachability: hit.called ? "reachable" : "imported-not-called" };
1578
+ function classifyReachability(pkgName, productionUsage, graph2) {
1579
+ const hit2 = productionUsage.get(pkgName);
1580
+ if (hit2?.imported === true) {
1581
+ return { reachability: hit2.called ? "reachable" : "imported-not-called" };
1321
1582
  }
1322
- if (graph !== void 0 && graph.edgesKnown) {
1323
- const roots = importedDirectNames(productionUsage, graph);
1324
- const paths = collectDependencyPaths(pkgName, roots, graph.edges);
1583
+ if (graph2 !== void 0 && graph2.edgesKnown) {
1584
+ const roots = importedDirectNames(productionUsage, graph2);
1585
+ const paths = collectDependencyPaths(pkgName, roots, graph2.edges);
1325
1586
  const path = paths[0];
1326
1587
  if (path !== void 0 && path.length >= 2) {
1327
1588
  return { reachability: "reachable-via-dependency", path, paths };
1328
1589
  }
1329
1590
  return { reachability: "not-imported" };
1330
1591
  }
1331
- if (graph !== void 0 && !graph.edgesKnown) {
1592
+ if (graph2 !== void 0 && !graph2.edgesKnown) {
1332
1593
  return { reachability: "reachability-unknown" };
1333
1594
  }
1334
1595
  return { reachability: "not-imported" };
1335
1596
  }
1597
+ function unusedDirectPackages(ctx) {
1598
+ if (!ctx.graph.edgesKnown) return [];
1599
+ const imported = importedDirectNames(ctx.productionUsage, ctx.graph);
1600
+ return [...ctx.graph.directProd].filter((name) => {
1601
+ if (ctx.productionUsage.get(name)?.imported === true) return false;
1602
+ return !reachableFrom(name, imported, ctx.graph.edges);
1603
+ }).sort();
1604
+ }
1605
+ function unusedDirectPlan(findings, ctx) {
1606
+ const packages = unusedDirectPackages(ctx);
1607
+ if (packages.length === 0) return void 0;
1608
+ const names = new Set(packages);
1609
+ let advisories = 0;
1610
+ for (const finding6 of findings) {
1611
+ if (finding6.ruleId !== "DEPS-VULN-001" && finding6.ruleId !== "DEPS-MALICIOUS-001") continue;
1612
+ if (finding6.snippet === void 0) continue;
1613
+ const pkg = packageNameFromSnippet(finding6.snippet);
1614
+ if (pkg !== void 0 && names.has(pkg)) advisories += 1;
1615
+ }
1616
+ return { packages, advisories };
1617
+ }
1336
1618
  function reachabilitySteps(reachability) {
1337
1619
  if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
1338
1620
  return 0;
@@ -1566,6 +1848,186 @@ var init_semver = __esm({
1566
1848
  }
1567
1849
  });
1568
1850
 
1851
+ // src/modules/deps/upgrade-plan.ts
1852
+ function sentenceFor(bumps, unresolved) {
1853
+ const parts = [];
1854
+ if (bumps.length === 1) {
1855
+ const bump = bumps[0];
1856
+ if (bump !== void 0) {
1857
+ const noun = bump.findings === 1 ? "finding" : "findings";
1858
+ parts.push(`1 bump (${bump.name} ${bump.from} \u2192 ${bump.to}) resolves ${bump.findings} ${noun}.`);
1859
+ }
1860
+ } else if (bumps.length > 1) {
1861
+ const listed = bumps.map((bump) => `${bump.name} ${bump.from} \u2192 ${bump.to}`).join(", ");
1862
+ const total = bumps.reduce((sum, bump) => sum + bump.findings, 0);
1863
+ const noun = total === 1 ? "finding" : "findings";
1864
+ parts.push(`${bumps.length} bumps (${listed}) resolve ${total} ${noun}.`);
1865
+ }
1866
+ if (unresolved.length > 0) {
1867
+ parts.push(`No fixed version in the OSV cache for ${unresolved.join(", ")}.`);
1868
+ }
1869
+ return parts.join(" ");
1870
+ }
1871
+ function chooseUpgradePlan(advisories) {
1872
+ const byName = /* @__PURE__ */ new Map();
1873
+ for (const advisory of advisories) {
1874
+ const list = byName.get(advisory.name);
1875
+ if (list === void 0) byName.set(advisory.name, [advisory]);
1876
+ else list.push(advisory);
1877
+ }
1878
+ const unresolved = [];
1879
+ const candidates = [];
1880
+ for (const [name, list] of byName) {
1881
+ const from = list[0]?.from ?? "0.0.0";
1882
+ if (list.some((advisory) => advisory.fixed === void 0)) {
1883
+ unresolved.push(name);
1884
+ continue;
1885
+ }
1886
+ let to = list[0]?.fixed;
1887
+ for (const advisory of list) {
1888
+ if (advisory.fixed !== void 0 && (to === void 0 || compareSemver(advisory.fixed, to) > 0)) {
1889
+ to = advisory.fixed;
1890
+ }
1891
+ }
1892
+ if (to === void 0 || compareSemver(to, from) <= 0) continue;
1893
+ candidates.push({
1894
+ name,
1895
+ from,
1896
+ to,
1897
+ ids: list.map((advisory) => advisory.id)
1898
+ });
1899
+ }
1900
+ unresolved.sort();
1901
+ const chosen = smallestMaxCover(candidates);
1902
+ if (chosen.length === 0 && unresolved.length === 0) return void 0;
1903
+ const bumps = chosen.map((candidate) => ({
1904
+ name: candidate.name,
1905
+ from: candidate.from,
1906
+ to: candidate.to,
1907
+ safe: sameMajor(candidate.from, candidate.to),
1908
+ findings: candidate.ids.length
1909
+ }));
1910
+ return { bumps, unresolved, sentence: sentenceFor(bumps, unresolved) };
1911
+ }
1912
+ function smallestMaxCover(candidates) {
1913
+ const limit = Math.min(candidates.length, 12);
1914
+ let best = [];
1915
+ let bestCount = -1;
1916
+ const walk5 = (index, picked, covered) => {
1917
+ if (index === limit) {
1918
+ if (covered.size > bestCount || covered.size === bestCount && betterTie(picked, best)) {
1919
+ best = picked;
1920
+ bestCount = covered.size;
1921
+ }
1922
+ return;
1923
+ }
1924
+ const next = candidates[index];
1925
+ if (next === void 0) return;
1926
+ walk5(index + 1, picked, covered);
1927
+ const grown = new Set(covered);
1928
+ for (const id of next.ids) grown.add(id);
1929
+ walk5(index + 1, [...picked, next], grown);
1930
+ };
1931
+ walk5(0, [], /* @__PURE__ */ new Set());
1932
+ return [...best].sort((a, b) => a.name.localeCompare(b.name));
1933
+ }
1934
+ function betterTie(picked, best) {
1935
+ if (picked.length !== best.length) return picked.length < best.length;
1936
+ const left = picked.map((item) => item.name).sort().join("\0");
1937
+ const right = best.map((item) => item.name).sort().join("\0");
1938
+ return left < right;
1939
+ }
1940
+ var init_upgrade_plan = __esm({
1941
+ "src/modules/deps/upgrade-plan.ts"() {
1942
+ init_semver();
1943
+ }
1944
+ });
1945
+
1946
+ // src/supply/campaign.ts
1947
+ function hit(ruleId, title, message, file, severity) {
1948
+ return {
1949
+ id: makeFindingId(ruleId, file, message),
1950
+ ruleId,
1951
+ module: "deps",
1952
+ title,
1953
+ severity,
1954
+ confidence: "high",
1955
+ file,
1956
+ message,
1957
+ remediation: "Do not install this package. Remove it, delete node_modules, and reinstall from a clean lockfile."
1958
+ };
1959
+ }
1960
+ function campaignFindings(files) {
1961
+ const findings = [];
1962
+ for (const source of files) {
1963
+ const hasChain = RPC_RE.test(source.text);
1964
+ const hasNet = NET_RE.test(source.text) || EVAL_RE.test(source.text);
1965
+ if (hasChain && hasNet) {
1966
+ findings.push(
1967
+ hit(
1968
+ "SUPPLY-CHAINC2-001",
1969
+ "Package reads a blockchain RPC and then fetches or evaluates the result",
1970
+ "static: RPC method combined with fetch or eval",
1971
+ source.file,
1972
+ "high"
1973
+ )
1974
+ );
1975
+ }
1976
+ const steals = STEAL_RE.test(source.text);
1977
+ const callsNet = NET_RE.test(source.text);
1978
+ if (steals && callsNet) {
1979
+ findings.push(
1980
+ hit(
1981
+ "SUPPLY-STEALER-001",
1982
+ "Package reads a local credential store and makes a network call",
1983
+ "static: browser, chat, or wallet path combined with a network call",
1984
+ source.file,
1985
+ "critical"
1986
+ )
1987
+ );
1988
+ }
1989
+ const gated2 = PLATFORM_RE.test(source.text);
1990
+ const downloads = DOWNLOAD_RE.test(source.text);
1991
+ const spawns = SPAWN_RE.test(source.text);
1992
+ if (gated2 && downloads && spawns) {
1993
+ findings.push(
1994
+ hit(
1995
+ "SUPPLY-PLATFORMGATE-001",
1996
+ "Package downloads and launches a file only on one platform",
1997
+ "static: platform check combined with a download and a process spawn",
1998
+ source.file,
1999
+ "high"
2000
+ )
2001
+ );
2002
+ }
2003
+ }
2004
+ return findings;
2005
+ }
2006
+ function securityPlaceholderFinding(name, locked, latest) {
2007
+ if (latest !== "0.0.1-security") return void 0;
2008
+ if (locked === "0.0.1-security") return void 0;
2009
+ return hit(
2010
+ "SUPPLY-SECHOLDER-001",
2011
+ `Locked ${name}@${locked} was replaced on the registry by the security placeholder`,
2012
+ `${name} latest is 0.0.1-security while the lockfile pins ${locked}`,
2013
+ "package-lock.json",
2014
+ "critical"
2015
+ );
2016
+ }
2017
+ var RPC_RE, NET_RE, EVAL_RE, STEAL_RE, PLATFORM_RE, DOWNLOAD_RE, SPAWN_RE;
2018
+ var init_campaign = __esm({
2019
+ "src/supply/campaign.ts"() {
2020
+ init_finding();
2021
+ RPC_RE = /getSignaturesForAddress|eth_getLogs/;
2022
+ NET_RE = /https\.request|http\.request|fetch\s*\(|axios\./;
2023
+ EVAL_RE = /\beval\s*\(/;
2024
+ STEAL_RE = /Login Data|leveldb|discord[\\/]Local Storage|wallet-extension|Ethereum\/keystore/i;
2025
+ PLATFORM_RE = /process\.platform\s*===\s*['"]win32['"]|os\.platform\s*\(\s*\)/;
2026
+ DOWNLOAD_RE = /https?:\/\/example\.invalid|createWriteStream|https\.get/;
2027
+ SPAWN_RE = /child_process|spawn\s*\(|execFile\s*\(|\.exe['"]/;
2028
+ }
2029
+ });
2030
+
1569
2031
  // src/modules/deps/popular.ts
1570
2032
  var POPULAR_PACKAGES;
1571
2033
  var init_popular = __esm({
@@ -3601,7 +4063,7 @@ __export(supply_exports, {
3601
4063
  checkProvenance: () => checkProvenance,
3602
4064
  diffPackageVersions: () => diffPackageVersions,
3603
4065
  installGuard: () => installGuard,
3604
- packageNameFromSpec: () => packageNameFromSpec2,
4066
+ packageNameFromSpec: () => packageNameFromSpec3,
3605
4067
  provenanceCoverage: () => provenanceCoverage
3606
4068
  });
3607
4069
  function assertRegistryUrl(url) {
@@ -3615,7 +4077,7 @@ function assertRegistryUrl(url) {
3615
4077
  throw new Error(`supply: only ${REGISTRY_HOST} is allowed (got ${parsed.hostname || url})`);
3616
4078
  }
3617
4079
  }
3618
- function packageNameFromSpec2(spec) {
4080
+ function packageNameFromSpec3(spec) {
3619
4081
  if (spec.startsWith("@")) {
3620
4082
  const slash = spec.indexOf("/");
3621
4083
  if (slash < 0) return spec;
@@ -3684,13 +4146,13 @@ function analyzePackageDir(dir) {
3684
4146
  const readsNpmrc = sources.some((s) => s.text.includes(".npmrc") || s.text.includes(".ssh"));
3685
4147
  const hasNet = sources.some((s) => /https\.request|http\.request|fetch\s*\(|axios\./.test(s.text));
3686
4148
  if (readsNpmrc && hasNet) {
3687
- const hit = sources.find((s) => s.text.includes(".npmrc") || s.text.includes(".ssh"));
4149
+ const hit2 = sources.find((s) => s.text.includes(".npmrc") || s.text.includes(".ssh"));
3688
4150
  findings.push(
3689
4151
  finding(
3690
4152
  "SUPPLY-NPMRC-NET-001",
3691
4153
  "Install-time code reads npm/ssh credentials and uses the network",
3692
4154
  "static: credential file read combined with http(s)/fetch",
3693
- hit?.file,
4155
+ hit2?.file,
3694
4156
  "critical"
3695
4157
  )
3696
4158
  );
@@ -3821,7 +4283,7 @@ async function installGuard(spec, opts = {}) {
3821
4283
  if (!opts.experimental && process.env.FULLSHIELD_EXPERIMENTAL_SUPPLY !== "1") {
3822
4284
  return { findings: [], skipped: true };
3823
4285
  }
3824
- const name = packageNameFromSpec2(spec);
4286
+ const name = packageNameFromSpec3(spec);
3825
4287
  const url = `https://${REGISTRY_HOST}/${encodeURIComponent(name)}`;
3826
4288
  assertRegistryUrl(url);
3827
4289
  if (opts.fetchPackument) await opts.fetchPackument(url);
@@ -3857,7 +4319,7 @@ ${scripts}`;
3857
4319
  if (!TOKEN_RE.test(text)) return [];
3858
4320
  const replicates = PUBLISH_RE.test(text) || REGISTRY_PUT_RE.test(text) || GITHUB_CREATE_RE.test(text) || WORKFLOW_PATH_RE.test(text);
3859
4321
  if (!replicates) return [];
3860
- const hit = sources.find(
4322
+ const hit2 = sources.find(
3861
4323
  (s) => TOKEN_RE.test(s.text) && (PUBLISH_RE.test(s.text) || REGISTRY_PUT_RE.test(s.text) || GITHUB_CREATE_RE.test(s.text) || WORKFLOW_PATH_RE.test(s.text))
3862
4324
  ) ?? sources.find((s) => TOKEN_RE.test(s.text));
3863
4325
  return [
@@ -3865,7 +4327,7 @@ ${scripts}`;
3865
4327
  "SUPPLY-WORM-001",
3866
4328
  "Self-replicating worm indicators in dependency code",
3867
4329
  "static: npm/GitHub token read combined with publish, registry PUT, GitHub repo/workflow creation, or writing .github/workflows",
3868
- hit?.file,
4330
+ hit2?.file,
3869
4331
  "critical"
3870
4332
  )
3871
4333
  ];
@@ -3875,13 +4337,13 @@ function analyzeWorkflowTampering(dir) {
3875
4337
  const text = `${sources.map((s) => s.text).join("\n")}
3876
4338
  ${scripts}`;
3877
4339
  if (!CI_WRITE_RE.test(text)) return [];
3878
- const hit = sources.find((s) => CI_WRITE_RE.test(s.text));
4340
+ const hit2 = sources.find((s) => CI_WRITE_RE.test(s.text));
3879
4341
  return [
3880
4342
  finding(
3881
4343
  "SUPPLY-WORKFLOW-001",
3882
4344
  "Dependency writes GitHub workflow, git hooks, or CI config",
3883
4345
  "static: install script or package code writes .github/, .git/hooks/, or CI configuration",
3884
- hit?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
4346
+ hit2?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
3885
4347
  "critical"
3886
4348
  )
3887
4349
  ];
@@ -3890,9 +4352,9 @@ function provenanceCoverage(packages, known) {
3890
4352
  let withProvenance = 0;
3891
4353
  let unknown = 0;
3892
4354
  for (const pkg of packages) {
3893
- const hit = known.get(`${pkg.name}@${pkg.version}`);
3894
- if (hit === true) withProvenance += 1;
3895
- else if (hit === void 0) unknown += 1;
4355
+ const hit2 = known.get(`${pkg.name}@${pkg.version}`);
4356
+ if (hit2 === true) withProvenance += 1;
4357
+ else if (hit2 === void 0) unknown += 1;
3896
4358
  }
3897
4359
  const total = packages.length;
3898
4360
  return {
@@ -3932,8 +4394,8 @@ function parentsOf(name, edges) {
3932
4394
  }
3933
4395
  return out;
3934
4396
  }
3935
- function isDirect(name, graph) {
3936
- return graph.directProd.has(name) || graph.directDev.has(name);
4397
+ function isDirect(name, graph2) {
4398
+ return graph2.directProd.has(name) || graph2.directDev.has(name);
3937
4399
  }
3938
4400
  function packagesWithAFixedRelease(findings) {
3939
4401
  const names = /* @__PURE__ */ new Set();
@@ -3951,17 +4413,17 @@ function targetVersion(finding6) {
3951
4413
  function kindFromLockfile(lockFile) {
3952
4414
  return lockfileKind(lockFile) ?? "npm";
3953
4415
  }
3954
- function attachOverrideProposals(findings, graph, lockFile, manifestRaw) {
3955
- if (!graph.edgesKnown || manifestRaw === void 0) return findings;
4416
+ function attachOverrideProposals(findings, graph2, lockFile, manifestRaw) {
4417
+ if (!graph2.edgesKnown || manifestRaw === void 0) return findings;
3956
4418
  const withFix = packagesWithAFixedRelease(findings);
3957
4419
  const kind = kindFromLockfile(lockFile);
3958
4420
  return findings.map((finding6) => {
3959
4421
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
3960
4422
  const name = packageNameFromSnippet2(finding6.snippet);
3961
- if (name.length === 0 || isDirect(name, graph)) return finding6;
4423
+ if (name.length === 0 || isDirect(name, graph2)) return finding6;
3962
4424
  const version = targetVersion(finding6);
3963
4425
  if (version === void 0) return finding6;
3964
- const parentFixed = parentsOf(name, graph.edges).some((parent) => withFix.has(parent));
4426
+ const parentFixed = parentsOf(name, graph2.edges).some((parent) => withFix.has(parent));
3965
4427
  if (parentFixed) return finding6;
3966
4428
  const after = withManifestOverride(manifestRaw, kind, name, version);
3967
4429
  const diff = unifiedDiff("package.json", manifestRaw, after);
@@ -3982,10 +4444,10 @@ function scanInstalledPackages(cwd, packages) {
3982
4444
  const dir = packageDir(cwd, pkg);
3983
4445
  if (dir === void 0) continue;
3984
4446
  const prefix = pkg.path.startsWith("node_modules/") ? pkg.path : `node_modules/${pkg.name}`;
3985
- for (const hit of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
4447
+ for (const hit2 of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
3986
4448
  out.push({
3987
- ...hit,
3988
- file: hit.file !== void 0 && hit.file.length > 0 ? `${prefix}/${hit.file}` : prefix
4449
+ ...hit2,
4450
+ file: hit2.file !== void 0 && hit2.file.length > 0 ? `${prefix}/${hit2.file}` : prefix
3989
4451
  });
3990
4452
  }
3991
4453
  }
@@ -4066,19 +4528,19 @@ function maliciousFinding(lockFile, pkg, vuln) {
4066
4528
  references: [vuln.id, ...vuln.aliases ?? []]
4067
4529
  };
4068
4530
  }
4069
- function typosquatFinding(lockFile, hit) {
4070
- const snippet = `${hit.name}@${hit.version}`;
4531
+ function typosquatFinding(lockFile, hit2) {
4532
+ const snippet = `${hit2.name}@${hit2.version}`;
4071
4533
  return {
4072
4534
  id: makeFindingId("DEPS-TYPOSQUAT-001", lockFile, snippet),
4073
4535
  ruleId: "DEPS-TYPOSQUAT-001",
4074
4536
  module: "deps",
4075
- title: `Possible typosquat: ${hit.name}`,
4537
+ title: `Possible typosquat: ${hit2.name}`,
4076
4538
  severity: "medium",
4077
4539
  confidence: "low",
4078
4540
  file: lockFile,
4079
4541
  snippet,
4080
- message: `Package "${hit.name}" is 1 character from popular package "${hit.lookalike}" (possible typosquat).`,
4081
- remediation: `Confirm you intended to install "${hit.name}". If not, remove it and depend on "${hit.lookalike}" instead.`
4542
+ message: `Package "${hit2.name}" is 1 character from popular package "${hit2.lookalike}" (possible typosquat).`,
4543
+ remediation: `Confirm you intended to install "${hit2.name}". If not, remove it and depend on "${hit2.lookalike}" instead.`
4082
4544
  };
4083
4545
  }
4084
4546
  function noLockFinding() {
@@ -4109,6 +4571,76 @@ function deprecatedFinding(lockFile, pkg) {
4109
4571
  remediation: `Replace ${pkg.name} with a maintained alternative.`
4110
4572
  };
4111
4573
  }
4574
+ function workspacesEnabled() {
4575
+ return process.env.FULLSHIELD_EXPERIMENTAL_WORKSPACES === "1";
4576
+ }
4577
+ function workspacePatterns(manifestRaw) {
4578
+ try {
4579
+ const manifest = JSON.parse(manifestRaw);
4580
+ const field = manifest.workspaces;
4581
+ if (Array.isArray(field)) return field.filter((item) => typeof item === "string");
4582
+ if (field !== null && typeof field === "object" && Array.isArray(field.packages)) {
4583
+ return field.packages.filter((item) => typeof item === "string");
4584
+ }
4585
+ } catch {
4586
+ return [];
4587
+ }
4588
+ return [];
4589
+ }
4590
+ async function expandWorkspace(cwd, pattern) {
4591
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
4592
+ if (!normalized.includes("*")) return [normalized];
4593
+ const slash = normalized.indexOf("/*");
4594
+ if (slash < 0) return [];
4595
+ const parent = normalized.slice(0, slash);
4596
+ try {
4597
+ const entries = await readdir(join(cwd, parent), { withFileTypes: true });
4598
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => `${parent}/${entry.name}`).sort();
4599
+ } catch {
4600
+ return [];
4601
+ }
4602
+ }
4603
+ async function workspaceAdvisoryFindings(cwd, manifestRaw, osvOpts) {
4604
+ const dirs = /* @__PURE__ */ new Set();
4605
+ for (const pattern of workspacePatterns(manifestRaw)) {
4606
+ for (const dir of await expandWorkspace(cwd, pattern)) dirs.add(dir);
4607
+ }
4608
+ const findings = [];
4609
+ for (const dir of [...dirs].sort()) {
4610
+ if (dir === "" || dir === ".") continue;
4611
+ const lockName = LOCKFILE_NAMES.find((name) => existsSync(join(cwd, dir, name)));
4612
+ if (lockName === void 0) continue;
4613
+ let text;
4614
+ try {
4615
+ text = await readFile(join(cwd, dir, lockName), "utf8");
4616
+ } catch {
4617
+ continue;
4618
+ }
4619
+ const graph2 = parseLockfileGraph(lockName, text);
4620
+ const hits = await queryOsv(graph2.packages, osvOpts);
4621
+ const file = `${dir}/${lockName}`;
4622
+ for (const hit2 of hits) {
4623
+ for (const vuln of hit2.vulns) {
4624
+ findings.push(
4625
+ isMaliciousAdvisory(vuln) ? maliciousFinding(file, hit2.pkg, vuln) : vulnFinding(file, hit2.pkg, vuln)
4626
+ );
4627
+ }
4628
+ }
4629
+ }
4630
+ return findings;
4631
+ }
4632
+ async function withWorkspaceAdvisories(cwd, manifestRaw, osvOpts, findings) {
4633
+ if (!workspacesEnabled() || manifestRaw === void 0) return findings;
4634
+ return [...findings, ...await workspaceAdvisoryFindings(cwd, manifestRaw, osvOpts)];
4635
+ }
4636
+ async function readPlaceholderLatest(file) {
4637
+ try {
4638
+ const raw = JSON.parse(await readFile(file, "utf8"));
4639
+ return typeof raw.latest === "string" ? raw.latest : void 0;
4640
+ } catch {
4641
+ return void 0;
4642
+ }
4643
+ }
4112
4644
  function graphFromManifest(manifest) {
4113
4645
  const keys = (field) => {
4114
4646
  const block = manifest[field];
@@ -4125,7 +4657,7 @@ function graphFromManifest(manifest) {
4125
4657
  edgesKnown: false
4126
4658
  };
4127
4659
  }
4128
- async function reachabilityCtx(cwd, files, graph) {
4660
+ async function reachabilityCtx(cwd, files, graph2) {
4129
4661
  const prodSources = /* @__PURE__ */ new Map();
4130
4662
  for (const rel of files) {
4131
4663
  if (!/\.[cm]?[jt]sx?$/.test(rel)) continue;
@@ -4135,7 +4667,7 @@ async function reachabilityCtx(cwd, files, graph) {
4135
4667
  } catch {
4136
4668
  }
4137
4669
  }
4138
- return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
4670
+ return { productionUsage: indexPackageUsage(prodSources), graph: graph2, sources: prodSources };
4139
4671
  }
4140
4672
  var LOCKFILE_NAMES, MANIFEST_FIELDS2, MALICIOUS_REMEDIATION, depsModule;
4141
4673
  var init_deps = __esm({
@@ -4144,7 +4676,10 @@ var init_deps = __esm({
4144
4676
  init_lockfile();
4145
4677
  init_osv();
4146
4678
  init_pins();
4679
+ init_evidence();
4147
4680
  init_reachability();
4681
+ init_upgrade_plan();
4682
+ init_campaign();
4148
4683
  init_symbol_reachability();
4149
4684
  init_semver();
4150
4685
  init_typosquat();
@@ -4158,6 +4693,8 @@ var init_deps = __esm({
4158
4693
  depsModule = {
4159
4694
  name: "deps",
4160
4695
  async run(ctx) {
4696
+ depsModule.unusedDirect = void 0;
4697
+ depsModule.upgradePlan = void 0;
4161
4698
  const lockFile = LOCKFILE_NAMES.find((name) => ctx.files.includes(name));
4162
4699
  const cacheDir = ctx.config.deps.cacheDir !== void 0 ? resolve(ctx.cwd, ctx.config.deps.cacheDir) : defaultCacheDir();
4163
4700
  const osvOpts = { cacheDir, offline: ctx.config.deps.offline };
@@ -4183,21 +4720,21 @@ var init_deps = __esm({
4183
4720
  }
4184
4721
  const pins = exactPinsFromManifest(manifest);
4185
4722
  const directNames2 = await readDirectDependencyNames(ctx.cwd);
4186
- for (const hit of findTyposquats(pins, { directNames: directNames2 })) {
4187
- findings2.push(typosquatFinding("package.json", hit));
4723
+ for (const hit2 of findTyposquats(pins, { directNames: directNames2 })) {
4724
+ findings2.push(typosquatFinding("package.json", hit2));
4188
4725
  }
4189
4726
  const osvHits2 = await queryOsv(pins, osvOpts);
4190
4727
  const symbols2 = /* @__PURE__ */ new Map();
4191
- for (const hit of osvHits2) {
4192
- for (const vuln of hit.vulns) {
4193
- const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
4728
+ for (const hit2 of osvHits2) {
4729
+ for (const vuln of hit2.vulns) {
4730
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit2.pkg, vuln) : vulnFinding("package.json", hit2.pkg, vuln, depLoc(hit2.pkg.name));
4194
4731
  const names = affectedFunctionNames(vuln);
4195
4732
  if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols2.set(finding6.id, names);
4196
4733
  findings2.push(finding6);
4197
4734
  }
4198
4735
  }
4199
4736
  const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4200
- return applyReachability(findings2, noLockCtx, symbols2);
4737
+ return withWorkspaceAdvisories(ctx.cwd, manifestRaw, osvOpts, applyReachability(findings2, noLockCtx, symbols2));
4201
4738
  }
4202
4739
  let contents;
4203
4740
  try {
@@ -4205,12 +4742,12 @@ var init_deps = __esm({
4205
4742
  } catch {
4206
4743
  return [];
4207
4744
  }
4208
- const graph = parseLockfileGraph(lockFile, contents);
4209
- const packages = graph.packages;
4745
+ const graph2 = parseLockfileGraph(lockFile, contents);
4746
+ const packages = graph2.packages;
4210
4747
  const findings = [];
4211
4748
  const directNames = await readDirectDependencyNames(ctx.cwd);
4212
- for (const hit of findTyposquats(packages, { directNames })) {
4213
- findings.push(typosquatFinding(lockFile, hit));
4749
+ for (const hit2 of findTyposquats(packages, { directNames })) {
4750
+ findings.push(typosquatFinding(lockFile, hit2));
4214
4751
  }
4215
4752
  for (const pkg of packages) {
4216
4753
  if (pkg.deprecated !== void 0) {
@@ -4219,20 +4756,56 @@ var init_deps = __esm({
4219
4756
  }
4220
4757
  const osvHits = await queryOsv(packages, osvOpts);
4221
4758
  const symbols = /* @__PURE__ */ new Map();
4222
- for (const hit of osvHits) {
4223
- for (const vuln of hit.vulns) {
4224
- const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4759
+ for (const hit2 of osvHits) {
4760
+ for (const vuln of hit2.vulns) {
4761
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit2.pkg, vuln) : vulnFinding(lockFile, hit2.pkg, vuln, depLoc(hit2.pkg.name));
4225
4762
  const names = affectedFunctionNames(vuln);
4226
4763
  if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols.set(finding6.id, names);
4227
4764
  findings.push(finding6);
4228
4765
  }
4229
4766
  }
4230
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
4767
+ const reachCtx = await reachabilityCtx(ctx.cwd, ctx.files, graph2);
4768
+ let reached = applyReachability(findings, reachCtx, symbols);
4231
4769
  if (experimentalVex()) {
4232
- reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4770
+ reached = attachOverrideProposals(reached, graph2, lockFile, manifestRaw);
4233
4771
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4234
4772
  }
4235
4773
  if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4774
+ depsModule.unusedDirect = unusedDirectPlan(reached, reachCtx);
4775
+ const direct = /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]);
4776
+ const advisories = [];
4777
+ for (const hit2 of osvHits) {
4778
+ if (!direct.has(hit2.pkg.name)) continue;
4779
+ for (const vuln of hit2.vulns) {
4780
+ if (isMaliciousAdvisory(vuln)) continue;
4781
+ advisories.push({
4782
+ name: hit2.pkg.name,
4783
+ from: hit2.pkg.version,
4784
+ id: vuln.id,
4785
+ fixed: minFixedVersion(vuln.affected ?? [])
4786
+ });
4787
+ }
4788
+ }
4789
+ depsModule.upgradePlan = chooseUpgradePlan(advisories);
4790
+ if (process.env.FULLSHIELD_EXPERIMENTAL_REACH_EVIDENCE === "1") {
4791
+ const sites = collectImportSites(reachCtx.sources);
4792
+ const files = [...reachCtx.sources.keys()].sort();
4793
+ reached = reached.map((finding6) => {
4794
+ const evidence = evidenceForFinding(finding6, sites, files, graph2.edgesKnown);
4795
+ return evidence === void 0 ? finding6 : { ...finding6, reachabilityEvidence: evidence };
4796
+ });
4797
+ }
4798
+ reached = await withWorkspaceAdvisories(ctx.cwd, manifestRaw, osvOpts, reached);
4799
+ if (process.env.FULLSHIELD_EXPERIMENTAL_SUPPLY === "1") {
4800
+ reached.push(...campaignFindings([...reachCtx.sources.entries()].map(([file, text]) => ({ file, text }))));
4801
+ for (const name of [...direct].sort()) {
4802
+ const locked = packages.find((pkg) => pkg.name === name)?.version;
4803
+ if (locked === void 0) continue;
4804
+ const latest = await readPlaceholderLatest(join(cacheDir, "registry", `${name.replaceAll("/", "__")}.json`));
4805
+ const placeholder = securityPlaceholderFinding(name, locked, latest);
4806
+ if (placeholder !== void 0) reached.push(placeholder);
4807
+ }
4808
+ }
4236
4809
  return reached;
4237
4810
  }
4238
4811
  };
@@ -4614,27 +5187,174 @@ var init_mcp = __esm({
4614
5187
  }
4615
5188
  });
4616
5189
 
4617
- // src/modules/sast/frameworks.ts
4618
- function decoratorExpr(dec) {
4619
- if (dec.type !== "Decorator") return void 0;
4620
- return dec.expression;
4621
- }
4622
- function decoratorName(dec) {
4623
- const expr = decoratorExpr(dec);
4624
- if (expr === void 0) return void 0;
4625
- if (expr.type === "Identifier") return expr.name;
4626
- if (expr.type === "CallExpression") return calleeName(expr);
4627
- return void 0;
4628
- }
4629
- function nodeDecorators(node) {
4630
- const decs = node.decorators;
4631
- return Array.isArray(decs) ? decs : [];
4632
- }
4633
- function nestInputDecorator(param) {
4634
- if (!frameworksEnabled()) return void 0;
4635
- for (const dec of nodeDecorators(param)) {
4636
- const name = decoratorName(dec);
4637
- if (name !== void 0 && NEST_INPUT.test(name)) return name;
5190
+ // src/runtime/lru.ts
5191
+ var LruMap;
5192
+ var init_lru = __esm({
5193
+ "src/runtime/lru.ts"() {
5194
+ LruMap = class {
5195
+ constructor(max) {
5196
+ this.max = max;
5197
+ if (max < 1) throw new Error("LruMap max must be >= 1");
5198
+ }
5199
+ max;
5200
+ map = /* @__PURE__ */ new Map();
5201
+ get size() {
5202
+ return this.map.size;
5203
+ }
5204
+ has(key) {
5205
+ return this.map.has(key);
5206
+ }
5207
+ get(key) {
5208
+ if (!this.map.has(key)) return void 0;
5209
+ const value = this.map.get(key);
5210
+ this.map.delete(key);
5211
+ this.map.set(key, value);
5212
+ return value;
5213
+ }
5214
+ set(key, value) {
5215
+ if (this.map.has(key)) this.map.delete(key);
5216
+ this.map.set(key, value);
5217
+ while (this.map.size > this.max) {
5218
+ const oldest = this.map.keys().next().value;
5219
+ if (oldest === void 0) break;
5220
+ this.map.delete(oldest);
5221
+ }
5222
+ return this;
5223
+ }
5224
+ };
5225
+ }
5226
+ });
5227
+ function packageVersion() {
5228
+ let dir = dirname(fileURLToPath(import.meta.url));
5229
+ for (let depth = 0; depth < 6; depth += 1) {
5230
+ try {
5231
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
5232
+ const pkg = JSON.parse(raw);
5233
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
5234
+ return pkg.version;
5235
+ }
5236
+ } catch {
5237
+ }
5238
+ const parent = dirname(dir);
5239
+ if (parent === dir) break;
5240
+ dir = parent;
5241
+ }
5242
+ throw new Error("fullshield package.json not found");
5243
+ }
5244
+ var init_version = __esm({
5245
+ "src/core/version.ts"() {
5246
+ }
5247
+ });
5248
+ function parsePoolEnabled() {
5249
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
5250
+ }
5251
+ function parsePoolSize() {
5252
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
5253
+ if (raw !== void 0 && raw !== "") {
5254
+ const parsed = Number(raw);
5255
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
5256
+ }
5257
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
5258
+ }
5259
+ function workerMemoryMb() {
5260
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
5261
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
5262
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
5263
+ return 512;
5264
+ }
5265
+ function parseCacheDir() {
5266
+ return join(homedir(), ".cache", "fullshield", "parse");
5267
+ }
5268
+ function parseCacheKey(code) {
5269
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
5270
+ }
5271
+ function parseOne(code) {
5272
+ const key = parseCacheKey(code);
5273
+ const hit2 = cache.get(key);
5274
+ if (hit2 !== void 0) return Promise.resolve(hit2);
5275
+ return new Promise((resolve8, reject) => {
5276
+ const worker = new Worker(fileURLToPath(workerUrl), {
5277
+ workerData: { code },
5278
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
5279
+ });
5280
+ let settled = false;
5281
+ const finish = () => {
5282
+ void worker.terminate();
5283
+ };
5284
+ const succeed = (ast) => {
5285
+ if (settled) return;
5286
+ settled = true;
5287
+ cache.set(key, ast);
5288
+ finish();
5289
+ resolve8(ast);
5290
+ };
5291
+ const fail = (error) => {
5292
+ if (settled) return;
5293
+ settled = true;
5294
+ finish();
5295
+ reject(error);
5296
+ };
5297
+ worker.once("message", (ast) => {
5298
+ succeed(ast);
5299
+ });
5300
+ worker.once("error", (error) => {
5301
+ fail(error);
5302
+ });
5303
+ worker.once("exit", (code2) => {
5304
+ if (settled) return;
5305
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
5306
+ });
5307
+ });
5308
+ }
5309
+ async function parseWithPool(codes) {
5310
+ const size = parsePoolSize();
5311
+ const out = new Array(codes.length);
5312
+ let next = 0;
5313
+ async function run() {
5314
+ for (; ; ) {
5315
+ const index = next;
5316
+ next += 1;
5317
+ if (index >= codes.length) return;
5318
+ const code = codes[index];
5319
+ if (code === void 0) return;
5320
+ out[index] = await parseOne(code);
5321
+ }
5322
+ }
5323
+ const workers = Math.min(size, Math.max(1, codes.length));
5324
+ await Promise.all(Array.from({ length: workers }, () => run()));
5325
+ return out;
5326
+ }
5327
+ var workerUrl, cache;
5328
+ var init_parse_pool = __esm({
5329
+ "src/core/parse-pool.ts"() {
5330
+ init_lru();
5331
+ init_version();
5332
+ workerUrl = new URL("./parse-worker.mjs", import.meta.url);
5333
+ cache = new LruMap(64);
5334
+ }
5335
+ });
5336
+
5337
+ // src/modules/sast/frameworks.ts
5338
+ function decoratorExpr(dec) {
5339
+ if (dec.type !== "Decorator") return void 0;
5340
+ return dec.expression;
5341
+ }
5342
+ function decoratorName(dec) {
5343
+ const expr = decoratorExpr(dec);
5344
+ if (expr === void 0) return void 0;
5345
+ if (expr.type === "Identifier") return expr.name;
5346
+ if (expr.type === "CallExpression") return calleeName(expr);
5347
+ return void 0;
5348
+ }
5349
+ function nodeDecorators(node) {
5350
+ const decs = node.decorators;
5351
+ return Array.isArray(decs) ? decs : [];
5352
+ }
5353
+ function nestInputDecorator(param) {
5354
+ if (!frameworksEnabled()) return void 0;
5355
+ for (const dec of nodeDecorators(param)) {
5356
+ const name = decoratorName(dec);
5357
+ if (name !== void 0 && NEST_INPUT.test(name)) return name;
4638
5358
  }
4639
5359
  return void 0;
4640
5360
  }
@@ -4762,6 +5482,9 @@ var init_mcp2 = __esm({
4762
5482
  });
4763
5483
 
4764
5484
  // src/modules/sast/taint.ts
5485
+ function setCustomTaint(config) {
5486
+ customTaint = config;
5487
+ }
4765
5488
  function objectChainMentions(node, pattern) {
4766
5489
  let current = node;
4767
5490
  for (let i = 0; i < 8 && current !== void 0; i += 1) {
@@ -4893,6 +5616,61 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4893
5616
  return false;
4894
5617
  }
4895
5618
  }
5619
+ function valueFeedsFilter(node, tainted, returners, nosqlSanitized, built, filterReturners) {
5620
+ if (node === void 0) return false;
5621
+ if (isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) return false;
5622
+ if (node.type === "Identifier") {
5623
+ if (nosqlSanitized.has(node.name)) return false;
5624
+ return built.has(node.name) || tainted.has(node.name);
5625
+ }
5626
+ if (isRequestObjectNode(node)) return true;
5627
+ if (node.type === "AwaitExpression") {
5628
+ return valueFeedsFilter(node.argument, tainted, returners, nosqlSanitized, built, filterReturners);
5629
+ }
5630
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && filterReturners.has(node.callee.name)) {
5631
+ return true;
5632
+ }
5633
+ if (node.type === "ObjectExpression") {
5634
+ return node.properties.some((prop) => {
5635
+ if (prop.type === "SpreadElement") {
5636
+ return valueFeedsFilter(prop.argument, tainted, returners, nosqlSanitized, built, filterReturners);
5637
+ }
5638
+ if (prop.type !== "Property") return false;
5639
+ return valueFeedsFilter(prop.value, tainted, returners, nosqlSanitized, built, filterReturners);
5640
+ });
5641
+ }
5642
+ if (node.type === "ArrayExpression") {
5643
+ return node.elements.some((el) => {
5644
+ if (el === null) return false;
5645
+ if (el.type === "SpreadElement") {
5646
+ return valueFeedsFilter(el.argument, tainted, returners, nosqlSanitized, built, filterReturners);
5647
+ }
5648
+ return valueFeedsFilter(el, tainted, returners, nosqlSanitized, built, filterReturners);
5649
+ });
5650
+ }
5651
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
5652
+ return node.arguments.some(
5653
+ (arg) => arg.type !== "SpreadElement" && valueFeedsFilter(arg, tainted, returners, nosqlSanitized, built, filterReturners)
5654
+ );
5655
+ }
5656
+ if (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "MemberExpression") {
5657
+ return exprIsTainted(node, tainted, returners);
5658
+ }
5659
+ return false;
5660
+ }
5661
+ function initIsBuiltFilter(node, tainted, returners, nosqlSanitized, built, filterReturners) {
5662
+ if (node.type === "AwaitExpression") {
5663
+ return initIsBuiltFilter(node.argument, tainted, returners, nosqlSanitized, built, filterReturners);
5664
+ }
5665
+ if (node.type === "Identifier") return built.has(node.name) && !nosqlSanitized.has(node.name);
5666
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && filterReturners.has(node.callee.name)) {
5667
+ return true;
5668
+ }
5669
+ if (node.type === "ObjectExpression" || node.type === "ArrayExpression") {
5670
+ return valueFeedsFilter(node, tainted, returners, nosqlSanitized, built, filterReturners);
5671
+ }
5672
+ return node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign" && valueFeedsFilter(node, tainted, returners, nosqlSanitized, built, filterReturners);
5673
+ }
4896
5674
  function exprIsSanitized(node, tainted, names, isCall, returners = NO_RETURNERS) {
4897
5675
  if (node === void 0) return true;
4898
5676
  if (isCall(node)) return true;
@@ -4956,7 +5734,7 @@ function seedMcpToolParams(scope, tainted) {
4956
5734
  for (const name of boundNames(param)) tainted.add(name);
4957
5735
  }
4958
5736
  }
4959
- function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5737
+ function analyzeScope(scope, seed = [], returners = NO_RETURNERS, filterReturners = NO_RETURNERS, initialBuilt = NO_RETURNERS) {
4960
5738
  const tainted = new Set(seed);
4961
5739
  seedDestructuredRequestParams(scope, tainted);
4962
5740
  seedFrameworkParams(scope, tainted);
@@ -4964,6 +5742,8 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4964
5742
  const urlSanitized = /* @__PURE__ */ new Set();
4965
5743
  const xssSanitized = /* @__PURE__ */ new Set();
4966
5744
  const nosqlSanitized = /* @__PURE__ */ new Set();
5745
+ const sqlCleared = /* @__PURE__ */ new Set();
5746
+ const customPath = /* @__PURE__ */ new Set();
4967
5747
  let changed = true;
4968
5748
  while (changed) {
4969
5749
  changed = false;
@@ -4974,6 +5754,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4974
5754
  for (const name of boundNames(node.id)) nosqlSanitized.add(name);
4975
5755
  }
4976
5756
  if (isSchemaValidatorCall(init)) return;
5757
+ applyCustomCall(init, boundNames(node.id), tainted, sqlCleared, customPath);
4977
5758
  if (isUrlSanitizerCall(init)) {
4978
5759
  for (const name of boundNames(node.id)) urlSanitized.add(name);
4979
5760
  }
@@ -4994,6 +5775,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4994
5775
  for (const name of boundNames(node.left)) nosqlSanitized.add(name);
4995
5776
  }
4996
5777
  if (isSchemaValidatorCall(node.right)) return;
5778
+ applyCustomCall(node.right, boundNames(node.left), tainted, sqlCleared, customPath);
4997
5779
  if (isUrlSanitizerCall(node.right)) {
4998
5780
  for (const name of boundNames(node.left)) urlSanitized.add(name);
4999
5781
  }
@@ -5020,7 +5802,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5020
5802
  for (const name of boundNames(node.left)) resolved.add(name);
5021
5803
  }
5022
5804
  });
5023
- const pathSanitized = /* @__PURE__ */ new Set();
5805
+ const pathSanitized = new Set(customPath);
5024
5806
  walkScope(scope, (node) => {
5025
5807
  if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5026
5808
  if (propName(node.callee) !== "startsWith") return;
@@ -5062,17 +5844,92 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5062
5844
  for (const name of boundNames(node.left)) requestObjects.add(name);
5063
5845
  }
5064
5846
  });
5847
+ const builtFilters = new Set(initialBuilt);
5848
+ let grew = true;
5849
+ while (grew) {
5850
+ grew = false;
5851
+ const add = (names) => {
5852
+ for (const name of names) {
5853
+ if (nosqlSanitized.has(name) || builtFilters.has(name)) continue;
5854
+ builtFilters.add(name);
5855
+ grew = true;
5856
+ }
5857
+ };
5858
+ walkScope(scope, (node) => {
5859
+ if (node.type === "VariableDeclarator" && node.init !== null) {
5860
+ if (initIsBuiltFilter(node.init, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
5861
+ add(boundNames(node.id));
5862
+ }
5863
+ }
5864
+ if (node.type !== "AssignmentExpression") return;
5865
+ if (node.left.type === "Identifier" && initIsBuiltFilter(node.right, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
5866
+ add([node.left.name]);
5867
+ }
5868
+ if (node.left.type === "MemberExpression" && node.left.object.type === "Identifier" && valueFeedsFilter(node.right, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
5869
+ add([node.left.object.name]);
5870
+ }
5871
+ });
5872
+ }
5065
5873
  return {
5066
5874
  tainted,
5067
5875
  pathSanitized,
5876
+ sqlCleared,
5068
5877
  urlSanitized,
5069
5878
  xssSanitized,
5070
5879
  nosqlSanitized,
5071
5880
  hasPasswordName,
5072
5881
  requestObjects,
5882
+ builtFilters,
5883
+ filterReturners: new Set(filterReturners),
5073
5884
  returners: new Set(returners)
5074
5885
  };
5075
5886
  }
5887
+ function locallyDeclared(scope) {
5888
+ const names = /* @__PURE__ */ new Set();
5889
+ if (isFunctionNode(scope)) {
5890
+ for (const param of scope.params) {
5891
+ for (const name of boundNames(param)) names.add(name);
5892
+ }
5893
+ }
5894
+ walkScope(scope, (node) => {
5895
+ if (node.type === "VariableDeclarator") {
5896
+ for (const name of boundNames(node.id)) names.add(name);
5897
+ }
5898
+ if (node.type === "FunctionDeclaration" && node.id !== null) names.add(node.id.name);
5899
+ });
5900
+ return names;
5901
+ }
5902
+ function inheritedBuiltFilters(fn, facts) {
5903
+ const local = locallyDeclared(fn);
5904
+ const inherited = /* @__PURE__ */ new Set();
5905
+ let parent = fn.parent;
5906
+ while (parent !== void 0 && !isFunctionNode(parent)) {
5907
+ parent = parent.parent;
5908
+ }
5909
+ const outer = parent === void 0 ? void 0 : facts.get(parent);
5910
+ if (outer === void 0) return inherited;
5911
+ for (const name of outer.builtFilters) {
5912
+ if (!local.has(name)) inherited.add(name);
5913
+ }
5914
+ return inherited;
5915
+ }
5916
+ function scopeReturnsBuiltFilter(scope, facts) {
5917
+ let yes = false;
5918
+ walkScope(scope, (node) => {
5919
+ if (node.type !== "ReturnStatement" || node.argument === null) return;
5920
+ if (initIsBuiltFilter(
5921
+ node.argument,
5922
+ facts.tainted,
5923
+ facts.returners,
5924
+ facts.nosqlSanitized,
5925
+ facts.builtFilters,
5926
+ facts.filterReturners
5927
+ )) {
5928
+ yes = true;
5929
+ }
5930
+ });
5931
+ return yes;
5932
+ }
5076
5933
  function functionName(fn) {
5077
5934
  if (fn.type === "FunctionDeclaration" && fn.id !== null) return fn.id.name;
5078
5935
  if (isFunctionNode(fn) && fn.id !== null && fn.id.type === "Identifier") return fn.id.name;
@@ -5161,11 +6018,12 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5161
6018
  const seeds = /* @__PURE__ */ new Map();
5162
6019
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5163
6020
  const returners = new Set(extraReturners2);
6021
+ const filterReturners = /* @__PURE__ */ new Set();
5164
6022
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5165
6023
  let changed = false;
5166
- facts.set(ast, analyzeScope(ast, [], returners));
6024
+ facts.set(ast, analyzeScope(ast, [], returners, filterReturners));
5167
6025
  for (const fn of functions) {
5168
- facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners));
6026
+ facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners, filterReturners, inheritedBuiltFilters(fn, facts)));
5169
6027
  }
5170
6028
  for (const fn of functions) {
5171
6029
  const name = functionName(fn);
@@ -5175,6 +6033,10 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5175
6033
  returners.add(name);
5176
6034
  changed = true;
5177
6035
  }
6036
+ if (scopeReturnsBuiltFilter(fn, f) && !filterReturners.has(name)) {
6037
+ filterReturners.add(name);
6038
+ changed = true;
6039
+ }
5178
6040
  }
5179
6041
  for (const fn of functions) {
5180
6042
  const f = facts.get(fn);
@@ -5207,6 +6069,51 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5207
6069
  }
5208
6070
  return facts;
5209
6071
  }
6072
+ function sinkClasses(scope, tainted, returners) {
6073
+ const found = /* @__PURE__ */ new Set();
6074
+ walkScope(scope, (node) => {
6075
+ if (node.type !== "CallExpression") return;
6076
+ const name = calleeName(node);
6077
+ const first = node.arguments[0];
6078
+ if (name === void 0 || first === void 0 || first.type === "SpreadElement") return;
6079
+ if (!exprIsTainted(first, tainted, returners)) return;
6080
+ if (SQL_SINK_NAMES.has(name)) found.add("sqli");
6081
+ if (CMD_SINK_NAMES.has(name)) found.add("cmdi");
6082
+ if (PATH_SINK_NAMES.has(name)) found.add("path");
6083
+ });
6084
+ return [...found].sort();
6085
+ }
6086
+ function fileTaintSummary(ast) {
6087
+ attachParents(ast);
6088
+ const facts = analyzeFile(ast);
6089
+ const returnsTaint = [];
6090
+ const functions = [];
6091
+ walk(ast, (node) => {
6092
+ if (!isFunctionNode(node)) return;
6093
+ const name = functionName(node);
6094
+ const own = facts.get(node);
6095
+ if (name === void 0 || own === void 0) return;
6096
+ if (scopeReturnsTaint(node, own.tainted, own.returners)) returnsTaint.push(name);
6097
+ const paramsToReturn = [];
6098
+ const sinks = [];
6099
+ node.params.forEach((param, index) => {
6100
+ const pname = boundNames(param)[0];
6101
+ if (pname === void 0) return;
6102
+ const seeded = analyzeScope(node, [pname]);
6103
+ if (scopeReturnsTaint(node, seeded.tainted, seeded.returners)) paramsToReturn.push(index);
6104
+ for (const cls of sinkClasses(node, seeded.tainted, seeded.returners)) sinks.push({ param: index, class: cls });
6105
+ });
6106
+ functions.push({
6107
+ name,
6108
+ line: node.loc?.start.line ?? 0,
6109
+ paramsToReturn,
6110
+ sinks
6111
+ });
6112
+ });
6113
+ returnsTaint.sort();
6114
+ functions.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
6115
+ return { returnsTaint, functions };
6116
+ }
5210
6117
  function functionsThatReturnTaint(ast) {
5211
6118
  attachParents(ast);
5212
6119
  const names = /* @__PURE__ */ new Set();
@@ -5228,18 +6135,50 @@ function factsFor(node, map) {
5228
6135
  return map.get(enclosingScope(node)) ?? {
5229
6136
  tainted: /* @__PURE__ */ new Set(),
5230
6137
  pathSanitized: /* @__PURE__ */ new Set(),
6138
+ sqlCleared: /* @__PURE__ */ new Set(),
5231
6139
  urlSanitized: /* @__PURE__ */ new Set(),
5232
6140
  xssSanitized: /* @__PURE__ */ new Set(),
5233
6141
  nosqlSanitized: /* @__PURE__ */ new Set(),
5234
6142
  hasPasswordName: false,
5235
6143
  requestObjects: /* @__PURE__ */ new Set(),
6144
+ builtFilters: /* @__PURE__ */ new Set(),
6145
+ filterReturners: /* @__PURE__ */ new Set(),
5236
6146
  returners: /* @__PURE__ */ new Set()
5237
6147
  };
5238
6148
  }
6149
+ function nodeHoldsBuiltFilter(node, map) {
6150
+ const facts = factsFor(node, map);
6151
+ if (node.type === "Identifier") return facts.builtFilters.has(node.name);
6152
+ if (node.type === "AwaitExpression") return nodeHoldsBuiltFilter(node.argument, map);
6153
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
6154
+ return facts.filterReturners.has(node.callee.name);
6155
+ }
6156
+ return false;
6157
+ }
5239
6158
  function nodeIsTainted(node, map) {
5240
6159
  const facts = factsFor(node, map);
5241
6160
  return exprIsTainted(node, facts.tainted, facts.returners);
5242
6161
  }
6162
+ function nodeIsSqlCleared(node, map) {
6163
+ if (node.type === "Identifier") return factsFor(node, map).sqlCleared.has(node.name);
6164
+ return false;
6165
+ }
6166
+ function applyCustomCall(init, names, tainted, sqlCleared, customPath) {
6167
+ if (customTaint === void 0 || init === void 0 || init.type !== "CallExpression") return;
6168
+ const called2 = calleeName(init);
6169
+ if (called2 === void 0) return;
6170
+ if (customTaint.sources.has(called2)) {
6171
+ for (const name of names) tainted.add(name);
6172
+ }
6173
+ const clears = customTaint.sanitizers.get(called2);
6174
+ if (clears === void 0) return;
6175
+ if (clears.has("sqli")) {
6176
+ for (const name of names) sqlCleared.add(name);
6177
+ }
6178
+ if (clears.has("path")) {
6179
+ for (const name of names) customPath.add(name);
6180
+ }
6181
+ }
5243
6182
  function nodeIsPathSanitized(node, map) {
5244
6183
  if (node.type !== "Identifier") return false;
5245
6184
  return factsFor(node, map).pathSanitized.has(node.name);
@@ -5261,7 +6200,7 @@ function nodeIsNosqlSanitized(node, map) {
5261
6200
  if (node.type === "Identifier") return factsFor(node, map).nosqlSanitized.has(node.name);
5262
6201
  return false;
5263
6202
  }
5264
- var SCHEMA_ROOT, NO_RETURNERS, CALLBACK_METHODS, taintedThisFields, MAX_TAINT_HOPS;
6203
+ var customTaint, SCHEMA_ROOT, NO_RETURNERS, CALLBACK_METHODS, taintedThisFields, MAX_TAINT_HOPS, SQL_SINK_NAMES, CMD_SINK_NAMES, PATH_SINK_NAMES;
5265
6204
  var init_taint = __esm({
5266
6205
  "src/modules/sast/taint.ts"() {
5267
6206
  init_ast();
@@ -5283,6 +6222,157 @@ var init_taint = __esm({
5283
6222
  ]);
5284
6223
  taintedThisFields = /* @__PURE__ */ new WeakMap();
5285
6224
  MAX_TAINT_HOPS = 5;
6225
+ SQL_SINK_NAMES = /* @__PURE__ */ new Set(["query", "execute"]);
6226
+ CMD_SINK_NAMES = /* @__PURE__ */ new Set(["exec", "execSync", "spawn", "spawnSync"]);
6227
+ PATH_SINK_NAMES = /* @__PURE__ */ new Set(["readFile", "readFileSync", "writeFile", "writeFileSync"]);
6228
+ }
6229
+ });
6230
+ function taintSummariesEnabled() {
6231
+ return process.env.FULLSHIELD_TAINT_SUMMARIES === "1";
6232
+ }
6233
+ function setTaintSummaryConfig(config) {
6234
+ fileMemo.clear();
6235
+ if (config === void 0) {
6236
+ configHash = "";
6237
+ return;
6238
+ }
6239
+ configHash = createHash("sha256").update(
6240
+ JSON.stringify({
6241
+ maxFileBytes: config.sast.maxFileBytes,
6242
+ sources: config.sast.sources ?? [],
6243
+ sanitizers: config.sast.sanitizers ?? [],
6244
+ taintV2: taintV2Enabled(),
6245
+ frameworks: frameworksEnabled(),
6246
+ gaps: sastGapsEnabled(),
6247
+ classes: classesEnabled(),
6248
+ custom: process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1"
6249
+ })
6250
+ ).digest("hex");
6251
+ }
6252
+ function summaryDir() {
6253
+ return process.env.FULLSHIELD_TAINT_SUMMARY_DIR ?? join(parseCacheDir(), "taint");
6254
+ }
6255
+ function taintSummaryKey(source, extra = "") {
6256
+ return createHash("sha256").update(packageVersion()).update("\0").update(configHash).update("\0").update(extra).update("\0").update(source).digest("hex");
6257
+ }
6258
+ function summaryPath(key) {
6259
+ return join(summaryDir(), `${key}.json`);
6260
+ }
6261
+ function isStringArray(value) {
6262
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
6263
+ }
6264
+ function readJson3(key) {
6265
+ let text;
6266
+ try {
6267
+ text = readFileSync(summaryPath(key), "utf8");
6268
+ } catch {
6269
+ return void 0;
6270
+ }
6271
+ try {
6272
+ return JSON.parse(text);
6273
+ } catch {
6274
+ discard(key);
6275
+ return void 0;
6276
+ }
6277
+ }
6278
+ function discard(key) {
6279
+ rmSync(summaryPath(key), { force: true });
6280
+ }
6281
+ function writeJson(key, value) {
6282
+ const dir = summaryDir();
6283
+ mkdirSync(dir, { recursive: true });
6284
+ writeFileSync(summaryPath(key), JSON.stringify(value));
6285
+ }
6286
+ function readFileSummary(key) {
6287
+ const raw = readJson3(key);
6288
+ if (typeof raw !== "object" || raw === null) {
6289
+ if (raw !== void 0) discard(key);
6290
+ return void 0;
6291
+ }
6292
+ const row = raw;
6293
+ if (row.v !== 1 || !isStringArray(row.returnsTaint) || !Array.isArray(row.functions)) {
6294
+ discard(key);
6295
+ return void 0;
6296
+ }
6297
+ return row;
6298
+ }
6299
+ function cachedReturners(source, ast) {
6300
+ const key = taintSummaryKey(source, "file");
6301
+ const memo = fileMemo.get(key);
6302
+ if (memo !== void 0) return new Set(memo.returnsTaint);
6303
+ const cached = readFileSummary(key);
6304
+ if (cached !== void 0) {
6305
+ fileMemo.set(key, cached);
6306
+ return new Set(cached.returnsTaint);
6307
+ }
6308
+ const summary = fileTaintSummary(ast);
6309
+ const stored = { v: 1, returnsTaint: summary.returnsTaint, functions: summary.functions };
6310
+ fileMemo.set(key, stored);
6311
+ writeJson(key, stored);
6312
+ return new Set(stored.returnsTaint);
6313
+ }
6314
+ function lists(facts) {
6315
+ const names = (set) => [...set].sort();
6316
+ return {
6317
+ v: 1,
6318
+ tainted: names(facts.tainted),
6319
+ pathSanitized: names(facts.pathSanitized),
6320
+ sqlCleared: names(facts.sqlCleared),
6321
+ urlSanitized: names(facts.urlSanitized),
6322
+ xssSanitized: names(facts.xssSanitized),
6323
+ nosqlSanitized: names(facts.nosqlSanitized),
6324
+ requestObjects: names(facts.requestObjects),
6325
+ builtFilters: names(facts.builtFilters),
6326
+ filterReturners: names(facts.filterReturners),
6327
+ returners: names(facts.returners),
6328
+ hasPasswordName: facts.hasPasswordName
6329
+ };
6330
+ }
6331
+ function factsFrom(row) {
6332
+ return {
6333
+ tainted: new Set(row.tainted),
6334
+ pathSanitized: new Set(row.pathSanitized),
6335
+ sqlCleared: new Set(row.sqlCleared),
6336
+ urlSanitized: new Set(row.urlSanitized),
6337
+ xssSanitized: new Set(row.xssSanitized),
6338
+ nosqlSanitized: new Set(row.nosqlSanitized),
6339
+ requestObjects: new Set(row.requestObjects),
6340
+ builtFilters: new Set(row.builtFilters),
6341
+ filterReturners: new Set(row.filterReturners),
6342
+ returners: new Set(row.returners),
6343
+ hasPasswordName: row.hasPasswordName
6344
+ };
6345
+ }
6346
+ function readFacts(key) {
6347
+ const raw = readJson3(key);
6348
+ if (typeof raw !== "object" || raw === null) {
6349
+ if (raw !== void 0) discard(key);
6350
+ return void 0;
6351
+ }
6352
+ const row = raw;
6353
+ if (row.v !== 1 || !isStringArray(row.tainted) || !isStringArray(row.pathSanitized) || !isStringArray(row.sqlCleared) || !isStringArray(row.urlSanitized) || !isStringArray(row.xssSanitized) || !isStringArray(row.nosqlSanitized) || !isStringArray(row.requestObjects) || !isStringArray(row.builtFilters) || !isStringArray(row.filterReturners) || !isStringArray(row.returners) || typeof row.hasPasswordName !== "boolean") {
6354
+ discard(key);
6355
+ return void 0;
6356
+ }
6357
+ return factsFrom(row);
6358
+ }
6359
+ function cachedFunctionFacts(fileSource, fn, seed) {
6360
+ const key = taintSummaryKey(fileSource, `seed:${fn.loc?.start.line ?? 0}:${seed.join(",")}`);
6361
+ const cached = readFacts(key);
6362
+ if (cached !== void 0) return cached;
6363
+ const facts = analyzeFunctionWithSeed(fn, seed);
6364
+ writeJson(key, lists(facts));
6365
+ return facts;
6366
+ }
6367
+ var configHash, fileMemo;
6368
+ var init_summaries = __esm({
6369
+ "src/modules/sast/summaries.ts"() {
6370
+ init_parse_pool();
6371
+ init_version();
6372
+ init_taint();
6373
+ init_experimental();
6374
+ configHash = "";
6375
+ fileMemo = /* @__PURE__ */ new Map();
5286
6376
  }
5287
6377
  });
5288
6378
  function collectFunctions(file, ast) {
@@ -5393,22 +6483,22 @@ function collectInstances(ast, ns) {
5393
6483
  if (node.type !== "VariableDeclarator" || node.init === null) return;
5394
6484
  if (node.id.type !== "Identifier" || node.init.type !== "NewExpression") return;
5395
6485
  if (node.init.callee.type !== "Identifier") return;
5396
- const hit = ns.get(node.init.callee.name);
5397
- if (hit === void 0) return;
5398
- instances.set(node.id.name, hit.includes("#") ? hit.split("#")[0] ?? hit : hit);
6486
+ const hit2 = ns.get(node.init.callee.name);
6487
+ if (hit2 === void 0) return;
6488
+ instances.set(node.id.name, hit2.includes("#") ? hit2.split("#")[0] ?? hit2 : hit2);
5399
6489
  });
5400
6490
  return instances;
5401
6491
  }
5402
6492
  function resolveCallee(node, ns, instances, currentFile, functions) {
5403
6493
  if (node.type !== "CallExpression") return void 0;
5404
6494
  if (node.callee.type === "Identifier") {
5405
- const hit = ns.get(node.callee.name);
5406
- if (hit !== void 0) {
5407
- if (hit.includes("#")) {
5408
- const [file, name] = hit.split("#");
6495
+ const hit2 = ns.get(node.callee.name);
6496
+ if (hit2 !== void 0) {
6497
+ if (hit2.includes("#")) {
6498
+ const [file, name] = hit2.split("#");
5409
6499
  if (file !== void 0 && name !== void 0) return { file, name };
5410
6500
  }
5411
- return { file: hit, name: node.callee.name };
6501
+ return { file: hit2, name: node.callee.name };
5412
6502
  }
5413
6503
  if (functions.has(`${currentFile}#${node.callee.name}`)) {
5414
6504
  return { file: currentFile, name: node.callee.name };
@@ -5515,9 +6605,9 @@ function runInterproc(parsed) {
5515
6605
  if (fn === void 0) continue;
5516
6606
  const param = fn.params[seed.paramIndex];
5517
6607
  if (param === void 0) continue;
5518
- const facts = analyzeFunctionWithSeed(fn.node, [param]);
5519
6608
  const p = byFile.get(fn.file);
5520
6609
  if (p === void 0) continue;
6610
+ const facts = taintSummariesEnabled() ? cachedFunctionFacts(p.source, fn.node, [param]) : analyzeFunctionWithSeed(fn.node, [param]);
5521
6611
  const ns = requires.get(fn.file) ?? /* @__PURE__ */ new Map();
5522
6612
  const inst = instances.get(fn.file) ?? /* @__PURE__ */ new Map();
5523
6613
  walk(fn.node, (node) => {
@@ -5611,6 +6701,7 @@ var init_interproc = __esm({
5611
6701
  init_finding();
5612
6702
  init_ast();
5613
6703
  init_experimental();
6704
+ init_summaries();
5614
6705
  init_taint();
5615
6706
  MAX_HOPS = 5;
5616
6707
  }
@@ -5982,6 +7073,184 @@ var init_jwt = __esm({
5982
7073
  }
5983
7074
  });
5984
7075
 
7076
+ // src/modules/sast/validation.ts
7077
+ function validationEnabled() {
7078
+ return process.env.FULLSHIELD_EXPERIMENTAL_VALIDATION === "1";
7079
+ }
7080
+ function parentOf(node) {
7081
+ return node.parent;
7082
+ }
7083
+ function mentions(node, names) {
7084
+ let current = node;
7085
+ for (let i = 0; i < 12 && current !== void 0; i += 1) {
7086
+ if (current.type === "CallExpression") {
7087
+ const name = calleeName(current);
7088
+ if (name !== void 0 && names.has(name)) return true;
7089
+ current = current.callee.type === "MemberExpression" ? current.callee.object : void 0;
7090
+ continue;
7091
+ }
7092
+ if (current.type === "MemberExpression") {
7093
+ const name = propName(current);
7094
+ if (name !== void 0 && names.has(name)) return true;
7095
+ current = current.object;
7096
+ continue;
7097
+ }
7098
+ if (current.type === "Identifier" && names.has(current.name)) return true;
7099
+ break;
7100
+ }
7101
+ return false;
7102
+ }
7103
+ function rootName(node) {
7104
+ let current = node;
7105
+ for (let i = 0; i < 8 && current !== void 0; i += 1) {
7106
+ if (current.type === "Identifier") return current.name;
7107
+ if (current.type === "CallExpression") {
7108
+ current = current.callee;
7109
+ continue;
7110
+ }
7111
+ if (current.type === "MemberExpression") {
7112
+ current = current.object;
7113
+ continue;
7114
+ }
7115
+ break;
7116
+ }
7117
+ return void 0;
7118
+ }
7119
+ function propertyName(prop) {
7120
+ if (prop.type !== "Property") return void 0;
7121
+ const key = prop.key;
7122
+ if (key.type === "Identifier") return key.name;
7123
+ if (key.type === "Literal" && typeof key.value === "string") return key.value;
7124
+ return void 0;
7125
+ }
7126
+ function fieldsOfObjectSchema(node) {
7127
+ if (node.type !== "CallExpression" || calleeName(node) !== "object") return /* @__PURE__ */ new Set();
7128
+ const root = rootName(node.callee);
7129
+ if (root === void 0 || !SCHEMA_ROOT2.has(root)) return /* @__PURE__ */ new Set();
7130
+ const arg = node.arguments[0];
7131
+ if (arg === void 0 || arg.type !== "ObjectExpression") return /* @__PURE__ */ new Set();
7132
+ const fields = /* @__PURE__ */ new Set();
7133
+ for (const prop of arg.properties) {
7134
+ if (prop.type !== "Property") continue;
7135
+ const name = propertyName(prop);
7136
+ if (name === void 0) continue;
7137
+ if (mentions(prop.value, OPEN)) continue;
7138
+ if (mentions(prop.value, PRIMITIVE)) fields.add(name);
7139
+ }
7140
+ return fields;
7141
+ }
7142
+ function decoratorName2(decorator) {
7143
+ const expr = decorator.type === "Decorator" ? decorator.expression : decorator;
7144
+ if (expr === void 0) return void 0;
7145
+ if (expr.type === "CallExpression") return calleeName(expr);
7146
+ if (expr.type === "Identifier") return expr.name;
7147
+ return void 0;
7148
+ }
7149
+ function fieldsOfClass(node) {
7150
+ if (node.type !== "ClassDeclaration" && node.type !== "ClassExpression") return /* @__PURE__ */ new Set();
7151
+ if (node.body.type !== "ClassBody") return /* @__PURE__ */ new Set();
7152
+ const fields = /* @__PURE__ */ new Set();
7153
+ for (const element of node.body.body) {
7154
+ if (element.type !== "PropertyDefinition") continue;
7155
+ const key = element.key;
7156
+ const name = key.type === "Identifier" ? key.name : void 0;
7157
+ if (name === void 0) continue;
7158
+ const decorators = element.decorators ?? [];
7159
+ if (decorators.some((decorator) => PRIMITIVE.has(decoratorName2(decorator) ?? ""))) fields.add(name);
7160
+ }
7161
+ return fields;
7162
+ }
7163
+ function programOf(node) {
7164
+ let current = node;
7165
+ while (current !== void 0 && current.type !== "Program") current = parentOf(current);
7166
+ return current;
7167
+ }
7168
+ function bindings(program) {
7169
+ const map = /* @__PURE__ */ new Map();
7170
+ walk(program, (node) => {
7171
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init !== null && node.init !== void 0) {
7172
+ const fields = fieldsOfObjectSchema(node.init);
7173
+ if (fields.size > 0) map.set(node.id.name, fields);
7174
+ }
7175
+ if (node.type === "ClassDeclaration" && node.id !== null) {
7176
+ const fields = fieldsOfClass(node);
7177
+ if (fields.size > 0) map.set(node.id.name, fields);
7178
+ }
7179
+ });
7180
+ return map;
7181
+ }
7182
+ function fieldsOfExpr(expr, known) {
7183
+ if (expr.type === "Identifier") return new Set(known.get(expr.name) ?? []);
7184
+ if (expr.type === "ClassExpression" || expr.type === "ClassDeclaration") return fieldsOfClass(expr);
7185
+ const direct = fieldsOfObjectSchema(expr);
7186
+ if (expr.type !== "CallExpression") return direct;
7187
+ for (const arg of expr.arguments) {
7188
+ if (arg.type === "SpreadElement") continue;
7189
+ for (const name of fieldsOfExpr(arg, known)) direct.add(name);
7190
+ }
7191
+ return direct;
7192
+ }
7193
+ function isRouteCall2(node) {
7194
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false;
7195
+ const method = propName(node.callee);
7196
+ if (method === void 0 || !ROUTE_METHODS.has(method)) return false;
7197
+ return node.callee.object.type === "Identifier" && /^(app|router)$/i.test(node.callee.object.name);
7198
+ }
7199
+ function routeOf(node) {
7200
+ let current = node;
7201
+ while (current !== void 0) {
7202
+ const parent = parentOf(current);
7203
+ if (isFunctionNode(current) && parent !== void 0 && isRouteCall2(parent)) return parent;
7204
+ current = parent;
7205
+ }
7206
+ return void 0;
7207
+ }
7208
+ function requestField(node) {
7209
+ if (node.type !== "MemberExpression" || node.computed === true) return void 0;
7210
+ const field = node.property.type === "Identifier" ? node.property.name : void 0;
7211
+ if (field === void 0 || node.object.type !== "MemberExpression" || node.object.computed === true) return void 0;
7212
+ const bag = node.object.property.type === "Identifier" ? node.object.property.name : void 0;
7213
+ if (bag !== "body" && bag !== "query" && bag !== "params") return void 0;
7214
+ if (node.object.object.type !== "Identifier" || node.object.object.name !== "req") return void 0;
7215
+ return field;
7216
+ }
7217
+ function fieldsForRoute(route) {
7218
+ const cached = routeCache.get(route);
7219
+ if (cached !== void 0) return cached;
7220
+ const program = programOf(route);
7221
+ const known = program === void 0 ? /* @__PURE__ */ new Map() : bindingCache.get(program) ?? bindings(program);
7222
+ if (program !== void 0) bindingCache.set(program, known);
7223
+ const fields = /* @__PURE__ */ new Set();
7224
+ if (route.type === "CallExpression") {
7225
+ for (const arg of route.arguments) {
7226
+ if (arg.type === "SpreadElement" || isFunctionNode(arg)) continue;
7227
+ for (const name of fieldsOfExpr(arg, known)) fields.add(name);
7228
+ }
7229
+ }
7230
+ routeCache.set(route, fields);
7231
+ return fields;
7232
+ }
7233
+ function isValidatedRouteField(node) {
7234
+ if (!validationEnabled()) return false;
7235
+ const field = requestField(node);
7236
+ if (field === void 0) return false;
7237
+ const route = routeOf(node);
7238
+ if (route === void 0) return false;
7239
+ return fieldsForRoute(route).has(field);
7240
+ }
7241
+ var PRIMITIVE, OPEN, SCHEMA_ROOT2, ROUTE_METHODS, bindingCache, routeCache;
7242
+ var init_validation = __esm({
7243
+ "src/modules/sast/validation.ts"() {
7244
+ init_ast();
7245
+ PRIMITIVE = /* @__PURE__ */ new Set(["string", "number", "boolean", "IsString", "IsNumber", "IsInt", "IsBoolean"]);
7246
+ OPEN = /* @__PURE__ */ new Set(["any", "unknown", "object", "array", "record", "lazy", "union", "tuple"]);
7247
+ SCHEMA_ROOT2 = /* @__PURE__ */ new Set(["z", "zod", "Joi", "joi"]);
7248
+ ROUTE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
7249
+ bindingCache = /* @__PURE__ */ new WeakMap();
7250
+ routeCache = /* @__PURE__ */ new WeakMap();
7251
+ }
7252
+ });
7253
+
5985
7254
  // src/modules/sast/rules/nosqli.ts
5986
7255
  function objectKey(prop) {
5987
7256
  if (prop.type !== "Property" || prop.key === void 0) return void 0;
@@ -6043,6 +7312,7 @@ function isNosqlSafeValue(node, source, isNosqlSanitized) {
6043
7312
  if (isNosqlSanitized(node) || isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) {
6044
7313
  return true;
6045
7314
  }
7315
+ if (isValidatedRouteField(node)) return true;
6046
7316
  return isSanitizeWrap(node, source);
6047
7317
  }
6048
7318
  function objectHasUnsafeTaintedValue(node, isTainted, isSafe) {
@@ -6064,6 +7334,7 @@ var init_nosqli = __esm({
6064
7334
  "src/modules/sast/rules/nosqli.ts"() {
6065
7335
  init_ast();
6066
7336
  init_taint();
7337
+ init_validation();
6067
7338
  MONGO_SINKS = /* @__PURE__ */ new Set([
6068
7339
  "find",
6069
7340
  "findOne",
@@ -6134,6 +7405,14 @@ var init_nosqli = __esm({
6134
7405
  return;
6135
7406
  }
6136
7407
  if (middleware) return;
7408
+ if (ctx.holdsBuiltFilter(filter)) {
7409
+ ctx.report({
7410
+ node,
7411
+ message: "A Mongo filter built from request input is passed to the query.",
7412
+ confidence: "medium"
7413
+ });
7414
+ return;
7415
+ }
6137
7416
  if (objectHasUnsafeTaintedValue(filter, (n) => ctx.isTainted(n), isSafe)) {
6138
7417
  ctx.report({
6139
7418
  node,
@@ -6335,7 +7614,7 @@ var init_sqli = __esm({
6335
7614
  CallExpression(node) {
6336
7615
  if (node.type !== "CallExpression" || !isSqlSink2(node)) return;
6337
7616
  const sql = node.arguments[0];
6338
- if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql)) {
7617
+ if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql) && !ctx.isSqlCleared(sql)) {
6339
7618
  ctx.report({
6340
7619
  node,
6341
7620
  message: "Tainted input is concatenated into a SQL string passed to query/execute."
@@ -6499,27 +7778,27 @@ function numberLiteral(node) {
6499
7778
  return node.value;
6500
7779
  }
6501
7780
  function containsIdentMember(node, objectName) {
6502
- let hit = false;
7781
+ let hit2 = false;
6503
7782
  walk(node, (child) => {
6504
7783
  if (child.type === "MemberExpression" && child.object.type === "Identifier" && child.object.name === objectName) {
6505
- hit = true;
7784
+ hit2 = true;
6506
7785
  }
6507
7786
  });
6508
- return hit;
7787
+ return hit2;
6509
7788
  }
6510
7789
  function containsCallNamed(node, name) {
6511
- let hit = false;
7790
+ let hit2 = false;
6512
7791
  walk(node, (child) => {
6513
- if (child.type === "CallExpression" && calleeName(child) === name) hit = true;
7792
+ if (child.type === "CallExpression" && calleeName(child) === name) hit2 = true;
6514
7793
  });
6515
- return hit;
7794
+ return hit2;
6516
7795
  }
6517
7796
  function containsOriginalName(node) {
6518
- let hit = false;
7797
+ let hit2 = false;
6519
7798
  walk(node, (child) => {
6520
- if (child.type === "MemberExpression" && propName(child) === "originalname") hit = true;
7799
+ if (child.type === "MemberExpression" && propName(child) === "originalname") hit2 = true;
6521
7800
  });
6522
- return hit;
7801
+ return hit2;
6523
7802
  }
6524
7803
  function schemaLooksSafe(schema) {
6525
7804
  if (schema === void 0) return false;
@@ -7137,162 +8416,18 @@ function scanPromptInjection(file, source) {
7137
8416
  line: index + 1,
7138
8417
  snippet: line.trim().slice(0, 120),
7139
8418
  message: "Code contains text attempting to manipulate AI reviewers.",
7140
- remediation: "Remove comments that try to instruct an AI reviewer.",
7141
- references: ["AI-INJECT-001"],
7142
- source: "deterministic"
7143
- });
7144
- });
7145
- return findings;
7146
- }
7147
- var PHRASES;
7148
- var init_injection = __esm({
7149
- "src/ai/injection.ts"() {
7150
- init_finding();
7151
- PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
7152
- }
7153
- });
7154
-
7155
- // src/runtime/lru.ts
7156
- var LruMap;
7157
- var init_lru = __esm({
7158
- "src/runtime/lru.ts"() {
7159
- LruMap = class {
7160
- constructor(max) {
7161
- this.max = max;
7162
- if (max < 1) throw new Error("LruMap max must be >= 1");
7163
- }
7164
- max;
7165
- map = /* @__PURE__ */ new Map();
7166
- get size() {
7167
- return this.map.size;
7168
- }
7169
- has(key) {
7170
- return this.map.has(key);
7171
- }
7172
- get(key) {
7173
- if (!this.map.has(key)) return void 0;
7174
- const value = this.map.get(key);
7175
- this.map.delete(key);
7176
- this.map.set(key, value);
7177
- return value;
7178
- }
7179
- set(key, value) {
7180
- if (this.map.has(key)) this.map.delete(key);
7181
- this.map.set(key, value);
7182
- while (this.map.size > this.max) {
7183
- const oldest = this.map.keys().next().value;
7184
- if (oldest === void 0) break;
7185
- this.map.delete(oldest);
7186
- }
7187
- return this;
7188
- }
7189
- };
7190
- }
7191
- });
7192
- function packageVersion() {
7193
- let dir = dirname(fileURLToPath(import.meta.url));
7194
- for (let depth = 0; depth < 6; depth += 1) {
7195
- try {
7196
- const raw = readFileSync(join(dir, "package.json"), "utf8");
7197
- const pkg = JSON.parse(raw);
7198
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7199
- return pkg.version;
7200
- }
7201
- } catch {
7202
- }
7203
- const parent = dirname(dir);
7204
- if (parent === dir) break;
7205
- dir = parent;
7206
- }
7207
- throw new Error("fullshield package.json not found");
7208
- }
7209
- var init_version = __esm({
7210
- "src/core/version.ts"() {
7211
- }
7212
- });
7213
- function parsePoolEnabled() {
7214
- return process.env.FULLSHIELD_PARSE_POOL === "1";
7215
- }
7216
- function parsePoolSize() {
7217
- const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7218
- if (raw !== void 0 && raw !== "") {
7219
- const parsed = Number(raw);
7220
- if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7221
- }
7222
- return Math.min(2, Math.max(1, availableParallelism() - 1));
7223
- }
7224
- function workerMemoryMb() {
7225
- const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7226
- const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7227
- if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7228
- return 512;
7229
- }
7230
- function parseCacheKey(code) {
7231
- return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
7232
- }
7233
- function parseOne(code) {
7234
- const key = parseCacheKey(code);
7235
- const hit = cache.get(key);
7236
- if (hit !== void 0) return Promise.resolve(hit);
7237
- return new Promise((resolve8, reject) => {
7238
- const worker = new Worker(fileURLToPath(workerUrl), {
7239
- workerData: { code },
7240
- resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7241
- });
7242
- let settled = false;
7243
- const finish = () => {
7244
- void worker.terminate();
7245
- };
7246
- const succeed = (ast) => {
7247
- if (settled) return;
7248
- settled = true;
7249
- cache.set(key, ast);
7250
- finish();
7251
- resolve8(ast);
7252
- };
7253
- const fail = (error) => {
7254
- if (settled) return;
7255
- settled = true;
7256
- finish();
7257
- reject(error);
7258
- };
7259
- worker.once("message", (ast) => {
7260
- succeed(ast);
7261
- });
7262
- worker.once("error", (error) => {
7263
- fail(error);
7264
- });
7265
- worker.once("exit", (code2) => {
7266
- if (settled) return;
7267
- fail(new Error(`parse worker exited ${code2 ?? "null"}`));
8419
+ remediation: "Remove comments that try to instruct an AI reviewer.",
8420
+ references: ["AI-INJECT-001"],
8421
+ source: "deterministic"
7268
8422
  });
7269
8423
  });
8424
+ return findings;
7270
8425
  }
7271
- async function parseWithPool(codes) {
7272
- const size = parsePoolSize();
7273
- const out = new Array(codes.length);
7274
- let next = 0;
7275
- async function run() {
7276
- for (; ; ) {
7277
- const index = next;
7278
- next += 1;
7279
- if (index >= codes.length) return;
7280
- const code = codes[index];
7281
- if (code === void 0) return;
7282
- out[index] = await parseOne(code);
7283
- }
7284
- }
7285
- const workers = Math.min(size, Math.max(1, codes.length));
7286
- await Promise.all(Array.from({ length: workers }, () => run()));
7287
- return out;
7288
- }
7289
- var workerUrl, cache;
7290
- var init_parse_pool = __esm({
7291
- "src/core/parse-pool.ts"() {
7292
- init_lru();
7293
- init_version();
7294
- workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7295
- cache = new LruMap(64);
8426
+ var PHRASES;
8427
+ var init_injection = __esm({
8428
+ "src/ai/injection.ts"() {
8429
+ init_finding();
8430
+ PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
7296
8431
  }
7297
8432
  });
7298
8433
  async function parseSource(code) {
@@ -7337,10 +8472,12 @@ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ *
7337
8472
  });
7338
8473
  },
7339
8474
  isTainted: (node) => nodeIsTainted(node, facts),
8475
+ isSqlCleared: (node) => nodeIsSqlCleared(node, facts),
7340
8476
  isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
7341
8477
  isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
7342
8478
  isNosqlSanitized: (node) => nodeIsNosqlSanitized(node, facts),
7343
8479
  isRequestObject: (node) => nodeIsRequestObject(node, facts),
8480
+ holdsBuiltFilter: (node) => nodeHoldsBuiltFilter(node, facts),
7344
8481
  functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
7345
8482
  assignedName: (node) => assignedNameOf(node),
7346
8483
  projectCsrfProtected
@@ -7378,10 +8515,12 @@ function findingsFromTemplate(filename, source) {
7378
8515
  });
7379
8516
  },
7380
8517
  isTainted: () => false,
8518
+ isSqlCleared: () => false,
7381
8519
  isPathSanitized: () => false,
7382
8520
  isSanitizedFor: () => false,
7383
8521
  isNosqlSanitized: () => false,
7384
8522
  isRequestObject: () => false,
8523
+ holdsBuiltFilter: () => false,
7385
8524
  functionHasPasswordName: () => false,
7386
8525
  assignedName: () => void 0,
7387
8526
  projectCsrfProtected: false
@@ -7513,7 +8652,7 @@ async function runSastStreaming(ctx) {
7513
8652
  }
7514
8653
  const ast = await parseCode(source);
7515
8654
  if (ast === void 0) continue;
7516
- returning.set(rel, functionsThatReturnTaint(ast));
8655
+ returning.set(rel, taintSummariesEnabled() ? cachedReturners(source, ast) : functionsThatReturnTaint(ast));
7517
8656
  parseable.push(rel);
7518
8657
  fileSet.add(rel);
7519
8658
  }
@@ -7547,6 +8686,7 @@ var init_engine = __esm({
7547
8686
  init_finding();
7548
8687
  init_ast();
7549
8688
  init_interproc();
8689
+ init_summaries();
7550
8690
  init_taint();
7551
8691
  init_auth();
7552
8692
  init_cmdi();
@@ -7582,48 +8722,69 @@ var init_engine = __esm({
7582
8722
  sastModule = {
7583
8723
  name: "sast",
7584
8724
  async run(ctx) {
7585
- const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
7586
- if (candidates.length > sastStreamThreshold()) return runSastStreaming(ctx);
7587
- const maxBytes = ctx.config.sast.maxFileBytes;
7588
- const findings = [];
7589
- const parsed = [];
7590
- for (const rel of candidates) {
7591
- const source = await readScanSource(ctx.cwd, rel);
7592
- if (source === void 0) continue;
7593
- const why = skipReason(source, maxBytes);
7594
- if (why !== void 0) {
7595
- findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7596
- continue;
8725
+ if (process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1") {
8726
+ const sources = new Set((ctx.config.sast.sources ?? []).map((row) => row.callee));
8727
+ const sanitizers = /* @__PURE__ */ new Map();
8728
+ for (const row of ctx.config.sast.sanitizers ?? []) {
8729
+ const classes = sanitizers.get(row.callee) ?? /* @__PURE__ */ new Set();
8730
+ for (const name of row.clears) classes.add(name);
8731
+ sanitizers.set(row.callee, classes);
7597
8732
  }
7598
- if (TEMPLATE_EXT.test(rel)) {
7599
- findings.push(...findingsFromTemplate(rel, source));
7600
- continue;
7601
- }
7602
- const ast = await parseCode(source);
7603
- if (ast === void 0) continue;
7604
- parsed.push({ file: rel, source, ast });
7605
- }
7606
- const files = new Set(parsed.map((p) => p.file));
7607
- const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7608
- const returning = /* @__PURE__ */ new Map();
7609
- for (const entry of parsed) {
7610
- returning.set(entry.file, functionsThatReturnTaint(entry.ast));
8733
+ setCustomTaint({ sources, sanitizers });
8734
+ } else {
8735
+ setCustomTaint(void 0);
7611
8736
  }
7612
- for (const entry of parsed) {
7613
- findings.push(
7614
- ...findingsFromAst(
8737
+ setTaintSummaryConfig(taintSummariesEnabled() ? ctx.config : void 0);
8738
+ try {
8739
+ const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
8740
+ if (candidates.length > sastStreamThreshold()) return await runSastStreaming(ctx);
8741
+ const maxBytes = ctx.config.sast.maxFileBytes;
8742
+ const findings = [];
8743
+ const parsed = [];
8744
+ for (const rel of candidates) {
8745
+ const source = await readScanSource(ctx.cwd, rel);
8746
+ if (source === void 0) continue;
8747
+ const why = skipReason(source, maxBytes);
8748
+ if (why !== void 0) {
8749
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
8750
+ continue;
8751
+ }
8752
+ if (TEMPLATE_EXT.test(rel)) {
8753
+ findings.push(...findingsFromTemplate(rel, source));
8754
+ continue;
8755
+ }
8756
+ const ast = await parseCode(source);
8757
+ if (ast === void 0) continue;
8758
+ parsed.push({ file: rel, source, ast });
8759
+ }
8760
+ const files = new Set(parsed.map((p) => p.file));
8761
+ const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
8762
+ const returning = /* @__PURE__ */ new Map();
8763
+ for (const entry of parsed) {
8764
+ returning.set(
7615
8765
  entry.file,
7616
- entry.source,
7617
- entry.ast,
7618
- extraReturners(entry.file, entry.ast, files, returning),
7619
- projectCsrf
7620
- )
7621
- );
8766
+ taintSummariesEnabled() ? cachedReturners(entry.source, entry.ast) : functionsThatReturnTaint(entry.ast)
8767
+ );
8768
+ }
8769
+ for (const entry of parsed) {
8770
+ findings.push(
8771
+ ...findingsFromAst(
8772
+ entry.file,
8773
+ entry.source,
8774
+ entry.ast,
8775
+ extraReturners(entry.file, entry.ast, files, returning),
8776
+ projectCsrf
8777
+ )
8778
+ );
8779
+ }
8780
+ findings.push(...runInterproc(parsed));
8781
+ const out = dedupeSastSinks(findings);
8782
+ releaseParsed(parsed);
8783
+ return out;
8784
+ } finally {
8785
+ setCustomTaint(void 0);
8786
+ setTaintSummaryConfig(void 0);
7622
8787
  }
7623
- findings.push(...runInterproc(parsed));
7624
- const out = dedupeSastSinks(findings);
7625
- releaseParsed(parsed);
7626
- return out;
7627
8788
  }
7628
8789
  };
7629
8790
  }
@@ -8007,7 +9168,7 @@ function scanTextForSecretHits(path, text) {
8007
9168
  return hits;
8008
9169
  }
8009
9170
  function scanTextForSecrets(path, text) {
8010
- return scanTextForSecretHits(path, text).map((hit) => hit.finding);
9171
+ return scanTextForSecretHits(path, text).map((hit2) => hit2.finding);
8011
9172
  }
8012
9173
  var secretsRules, REMEDIATION, MAX_FILE_SIZE, secretsModule;
8013
9174
  var init_secrets = __esm({
@@ -8363,12 +9524,12 @@ ${text}`);
8363
9524
  const seeds = fixtureLogic(prompt);
8364
9525
  const findings = [];
8365
9526
  for (const seed of seeds) {
8366
- const hit = fileTexts.find((f) => f.file.includes(seed.fileHint) && f.text.includes(seed.fileHint === "user" ? "findById" : "Object.assign")) ?? fileTexts.find(
9527
+ const hit2 = fileTexts.find((f) => f.file.includes(seed.fileHint) && f.text.includes(seed.fileHint === "user" ? "findById" : "Object.assign")) ?? fileTexts.find(
8367
9528
  (f) => f.text.includes(seed.ruleId === "AI-IDOR-001" ? "findById(req.params.id)" : "Object.assign(user, req.body)")
8368
9529
  );
8369
- if (hit === void 0) continue;
8370
- const line = hit.text.split(/\r?\n/).findIndex((l) => l.includes(seed.ruleId === "AI-IDOR-001" ? "findById" : "Object.assign")) + 1;
8371
- findings.push(asAiFinding(seed, hit.file, line > 0 ? line : 1));
9530
+ if (hit2 === void 0) continue;
9531
+ const line = hit2.text.split(/\r?\n/).findIndex((l) => l.includes(seed.ruleId === "AI-IDOR-001" ? "findById" : "Object.assign")) + 1;
9532
+ findings.push(asAiFinding(seed, hit2.file, line > 0 ? line : 1));
8372
9533
  }
8373
9534
  return findings;
8374
9535
  }
@@ -8626,6 +9787,40 @@ var init_baseline = __esm({
8626
9787
  "src/core/baseline.ts"() {
8627
9788
  }
8628
9789
  });
9790
+ function parseSastSources(value, source) {
9791
+ if (value === void 0) return void 0;
9792
+ if (!Array.isArray(value)) throw new ConfigError(`${source}: "sast.sources" must be an array`);
9793
+ return value.map((item) => {
9794
+ if (typeof item !== "object" || item === null) {
9795
+ throw new ConfigError(`${source}: "sast.sources" entries must be objects`);
9796
+ }
9797
+ const row = item;
9798
+ if (typeof row.callee !== "string" || row.callee.length === 0) {
9799
+ throw new ConfigError(`${source}: "sast.sources[].callee" must be a string`);
9800
+ }
9801
+ if (row.returns !== "tainted") {
9802
+ throw new ConfigError(`${source}: "sast.sources[].returns" must be "tainted"`);
9803
+ }
9804
+ return { callee: row.callee, returns: "tainted" };
9805
+ });
9806
+ }
9807
+ function parseSastSanitizers(value, source) {
9808
+ if (value === void 0) return void 0;
9809
+ if (!Array.isArray(value)) throw new ConfigError(`${source}: "sast.sanitizers" must be an array`);
9810
+ return value.map((item) => {
9811
+ if (typeof item !== "object" || item === null) {
9812
+ throw new ConfigError(`${source}: "sast.sanitizers" entries must be objects`);
9813
+ }
9814
+ const row = item;
9815
+ if (typeof row.callee !== "string" || row.callee.length === 0) {
9816
+ throw new ConfigError(`${source}: "sast.sanitizers[].callee" must be a string`);
9817
+ }
9818
+ if (!Array.isArray(row.clears) || row.clears.some((name) => typeof name !== "string" || !SAST_CLASSES.has(name))) {
9819
+ throw new ConfigError(`${source}: "sast.sanitizers[].clears" must be B4 class names`);
9820
+ }
9821
+ return { callee: row.callee, clears: row.clears.filter((name) => name !== "secrets" && name !== "deps") };
9822
+ });
9823
+ }
8629
9824
  function validate(raw, source) {
8630
9825
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
8631
9826
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -8751,6 +9946,16 @@ function validate(raw, source) {
8751
9946
  }
8752
9947
  sast.maxFileBytes = rawSast.maxFileBytes;
8753
9948
  }
9949
+ const custom = process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1";
9950
+ if (!custom && (rawSast.sources !== void 0 || rawSast.sanitizers !== void 0)) {
9951
+ throw new ConfigError(
9952
+ `${source}: unknown key "sast.sources" (FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS)`
9953
+ );
9954
+ }
9955
+ if (custom) {
9956
+ sast.sources = parseSastSources(rawSast.sources, source);
9957
+ sast.sanitizers = parseSastSanitizers(rawSast.sanitizers, source);
9958
+ }
8754
9959
  out.sast = sast;
8755
9960
  }
8756
9961
  return out;
@@ -8778,7 +9983,7 @@ async function loadConfig(cwd, configPath) {
8778
9983
  }
8779
9984
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
8780
9985
  }
8781
- var CONFIG_FILE_NAME, DEFAULT_CONFIG, ConfigError, KNOWN_KEYS;
9986
+ var CONFIG_FILE_NAME, DEFAULT_CONFIG, ConfigError, KNOWN_KEYS, SAST_CLASSES;
8782
9987
  var init_config2 = __esm({
8783
9988
  "src/core/config.ts"() {
8784
9989
  init_finding();
@@ -8808,6 +10013,22 @@ var init_config2 = __esm({
8808
10013
  "ai",
8809
10014
  "sast"
8810
10015
  ]);
10016
+ SAST_CLASSES = /* @__PURE__ */ new Set([
10017
+ "sqli",
10018
+ "nosqli",
10019
+ "cmdi",
10020
+ "path",
10021
+ "xss",
10022
+ "ssrf",
10023
+ "redirect",
10024
+ "eval",
10025
+ "jwt",
10026
+ "crypto",
10027
+ "proto",
10028
+ "secrets",
10029
+ "deps",
10030
+ "config"
10031
+ ]);
8811
10032
  }
8812
10033
  });
8813
10034
  async function walkFiles(options) {
@@ -9197,13 +10418,13 @@ async function readBlob(cwd, hash) {
9197
10418
  return void 0;
9198
10419
  }
9199
10420
  }
9200
- function remapHit(hit, file) {
9201
- const snippet = hit.finding.snippet;
9202
- const line = hit.finding.line ?? 0;
9203
- const id = makeFindingId(hit.finding.ruleId, file, `${line}|${snippet ?? "file"}`);
10421
+ function remapHit(hit2, file) {
10422
+ const snippet = hit2.finding.snippet;
10423
+ const line = hit2.finding.line ?? 0;
10424
+ const id = makeFindingId(hit2.finding.ruleId, file, `${line}|${snippet ?? "file"}`);
9204
10425
  return {
9205
- finding: { ...hit.finding, id, file },
9206
- secret: hit.secret
10426
+ finding: { ...hit2.finding, id, file },
10427
+ secret: hit2.secret
9207
10428
  };
9208
10429
  }
9209
10430
  function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
@@ -9293,8 +10514,8 @@ async function headSecretKeys(cwd) {
9293
10514
  hits = scanTextForSecretHits(path, text);
9294
10515
  seen.set(hash, hits);
9295
10516
  }
9296
- for (const hit of hits) {
9297
- keys.add(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
10517
+ for (const hit2 of hits) {
10518
+ keys.add(`${hit2.finding.ruleId}\0${hit2.finding.snippet ?? ""}`);
9298
10519
  }
9299
10520
  }
9300
10521
  return keys;
@@ -9327,8 +10548,8 @@ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
9327
10548
  hits2 = rawHits.filter((h) => allowed.has(h.finding.id));
9328
10549
  blobHits.set(change.hash, hits2);
9329
10550
  }
9330
- for (const hit of hits2) {
9331
- const mapped = remapHit(hit, change.path);
10551
+ for (const hit2 of hits2) {
10552
+ const mapped = remapHit(hit2, change.path);
9332
10553
  if (!first.has(mapped.finding.id)) {
9333
10554
  first.set(mapped.finding.id, { hit: mapped, commit: change.commit });
9334
10555
  }
@@ -9336,11 +10557,11 @@ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
9336
10557
  }
9337
10558
  const atHead = await headSecretKeys(cwd);
9338
10559
  const hits = [];
9339
- for (const { hit, commit } of first.values()) {
9340
- const stillAtHead = atHead.has(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
10560
+ for (const { hit: hit2, commit } of first.values()) {
10561
+ const stillAtHead = atHead.has(`${hit2.finding.ruleId}\0${hit2.finding.snippet ?? ""}`);
9341
10562
  hits.push({
9342
- finding: { ...hit.finding, introducedIn: commit, stillAtHead },
9343
- secret: hit.secret
10563
+ finding: { ...hit2.finding, introducedIn: commit, stillAtHead },
10564
+ secret: hit2.secret
9344
10565
  });
9345
10566
  }
9346
10567
  const scanned = Math.min(maxCommits, total);
@@ -9582,15 +10803,15 @@ function providerFor(ruleId, secret) {
9582
10803
  async function verifySecretHits(hits, options = {}) {
9583
10804
  const fetchImpl = options.fetchImpl ?? fetch;
9584
10805
  const out = [];
9585
- for (const hit of hits) {
9586
- const secret = hit.secret;
10806
+ for (const hit2 of hits) {
10807
+ const secret = hit2.secret;
9587
10808
  if (secret === void 0 || secret.length === 0) {
9588
- out.push({ finding: { ...hit.finding, verification: "unknown" }, secret: hit.secret });
10809
+ out.push({ finding: { ...hit2.finding, verification: "unknown" }, secret: hit2.secret });
9589
10810
  continue;
9590
10811
  }
9591
- const run = providerFor(hit.finding.ruleId, secret);
10812
+ const run = providerFor(hit2.finding.ruleId, secret);
9592
10813
  const verification = run === void 0 ? "unknown" : await run(secret, fetchImpl);
9593
- out.push({ finding: { ...hit.finding, verification }, secret: hit.secret });
10814
+ out.push({ finding: { ...hit2.finding, verification }, secret: hit2.secret });
9594
10815
  }
9595
10816
  return out;
9596
10817
  }
@@ -9623,6 +10844,8 @@ function dedupe2(findings) {
9623
10844
  async function runScan(options = {}, modules = builtinModules) {
9624
10845
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9625
10846
  const t0 = performance.now();
10847
+ depsModule.unusedDirect = void 0;
10848
+ depsModule.upgradePlan = void 0;
9626
10849
  const cwd = resolve(options.cwd ?? process.cwd());
9627
10850
  const stats = await stat(cwd).catch(() => null);
9628
10851
  if (stats === null || !stats.isDirectory()) {
@@ -9721,8 +10944,8 @@ async function runScan(options = {}, modules = builtinModules) {
9721
10944
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
9722
10945
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
9723
10946
  const secretById = /* @__PURE__ */ new Map();
9724
- for (const hit of extraHits) {
9725
- if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
10947
+ for (const hit2 of extraHits) {
10948
+ if (hit2.secret !== void 0) secretById.set(hit2.finding.id, hit2.secret);
9726
10949
  }
9727
10950
  for (const finding6 of findings) {
9728
10951
  if (finding6.module !== "secrets" || finding6.file === void 0 || secretById.has(finding6.id)) {
@@ -9736,8 +10959,8 @@ async function runScan(options = {}, modules = builtinModules) {
9736
10959
  continue;
9737
10960
  }
9738
10961
  }
9739
- for (const hit of scanTextForSecretHits2(finding6.file, text)) {
9740
- if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
10962
+ for (const hit2 of scanTextForSecretHits2(finding6.file, text)) {
10963
+ if (hit2.secret !== void 0) secretById.set(hit2.finding.id, hit2.secret);
9741
10964
  }
9742
10965
  }
9743
10966
  findings = sortFindings(
@@ -9789,7 +11012,9 @@ async function runScan(options = {}, modules = builtinModules) {
9789
11012
  filesScanned: files.length,
9790
11013
  modulesRequested: [...modulesRequested],
9791
11014
  modulesRun,
9792
- ...historyNotice !== void 0 ? { history: historyNotice } : {}
11015
+ ...historyNotice !== void 0 ? { history: historyNotice } : {},
11016
+ ...depsModule.unusedDirect !== void 0 ? { unusedDirect: depsModule.unusedDirect } : {},
11017
+ ...depsModule.upgradePlan !== void 0 ? { upgradePlan: depsModule.upgradePlan } : {}
9793
11018
  },
9794
11019
  config,
9795
11020
  policy: policyResult,
@@ -9934,6 +11159,16 @@ function validateCycloneDx15(doc) {
9934
11159
  if (!Array.isArray(root.components)) errors.push("components must be an array");
9935
11160
  return errors;
9936
11161
  }
11162
+ function reachProperties(finding6) {
11163
+ const properties = [
11164
+ { name: "fullshield:reachability", value: finding6.reachability ?? "unspecified" },
11165
+ { name: "fullshield:devOnly", value: finding6.message.includes("(dev-only)") ? "true" : "false" }
11166
+ ];
11167
+ if (finding6.dependencyPath !== void 0 && finding6.dependencyPath.length > 0) {
11168
+ properties.splice(1, 0, { name: "fullshield:dependencyPath", value: finding6.dependencyPath.join(" > ") });
11169
+ }
11170
+ return properties;
11171
+ }
9937
11172
  function renderCycloneDx(input) {
9938
11173
  const vulns = (input.findings ?? []).filter((f) => f.ruleId === "DEPS-VULN-001");
9939
11174
  return {
@@ -9944,13 +11179,18 @@ function renderCycloneDx(input) {
9944
11179
  metadata: {
9945
11180
  component: { type: "application", name: input.name, version: input.version }
9946
11181
  },
9947
- components: input.components.map((c) => ({
9948
- type: "library",
9949
- name: c.name,
9950
- version: c.version,
9951
- purl: c.purl ?? `pkg:npm/${encodeURIComponent(c.name)}@${c.version}`,
9952
- ...c.license !== void 0 ? { licenses: [{ license: { id: c.license } }] } : {}
9953
- })),
11182
+ components: input.components.map((c) => {
11183
+ const match = (input.findings ?? []).find((finding6) => finding6.snippet?.startsWith(`${c.name}@`));
11184
+ const properties = input.includeReach === true && match !== void 0 ? reachProperties(match) : void 0;
11185
+ return {
11186
+ type: "library",
11187
+ name: c.name,
11188
+ version: c.version,
11189
+ purl: c.purl ?? `pkg:npm/${encodeURIComponent(c.name)}@${c.version}`,
11190
+ ...c.license !== void 0 ? { licenses: [{ license: { id: c.license } }] } : {},
11191
+ ...properties !== void 0 ? { properties } : {}
11192
+ };
11193
+ }),
9954
11194
  vulnerabilities: vulns.map((f) => ({
9955
11195
  id: f.references?.find((r) => r.startsWith("GHSA-") || r.startsWith("CVE-")) ?? f.id,
9956
11196
  description: f.message,
@@ -10339,6 +11579,10 @@ var init_compliance = __esm({
10339
11579
  ROW("SUPPLY-PROVENANCE-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
10340
11580
  ROW("SUPPLY-WORM-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3 / 12.10"),
10341
11581
  ROW("SUPPLY-WORKFLOW-001", "A08:2021 Integrity Failures", "CWE-829", "V14.2.3", "PCI DSS 6.3.3"),
11582
+ ROW("SUPPLY-CHAINC2-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3"),
11583
+ ROW("SUPPLY-STEALER-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 12.10"),
11584
+ ROW("SUPPLY-PLATFORMGATE-001", "A06:2021 Vulnerable Components", "CWE-829", "V14.2.1", "PCI DSS 6.3.3"),
11585
+ ROW("SUPPLY-SECHOLDER-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3"),
10342
11586
  ROW("IAC-GHA-UNPINNED-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
10343
11587
  ROW("IAC-GHA-PRTARGET-001", "A01:2021 Broken Access Control", "CWE-269", "V4.1.1", "PCI DSS 7.2"),
10344
11588
  ROW("IAC-GHA-PERMS-001", "A01:2021 Broken Access Control", "CWE-250", "V4.1.1", "PCI DSS 7.2"),
@@ -10723,9 +11967,9 @@ async function callMcpTool(session, name, args = {}) {
10723
11967
  return { findings: session.findings };
10724
11968
  }
10725
11969
  if (name === "explain_finding") {
10726
- const hit = session.findings.find((f) => f.ruleId === args.ruleId);
10727
- if (!hit) return { text: `no finding ${args.ruleId}` };
10728
- return { text: `${hit.title}: ${hit.message} ${hit.remediation}` };
11970
+ const hit2 = session.findings.find((f) => f.ruleId === args.ruleId);
11971
+ if (!hit2) return { text: `no finding ${args.ruleId}` };
11972
+ return { text: `${hit2.title}: ${hit2.message} ${hit2.remediation}` };
10729
11973
  }
10730
11974
  if (name === "propose_fix") {
10731
11975
  return proposeOnly(session, args.ruleId);
@@ -10736,25 +11980,25 @@ async function callMcpTool(session, name, args = {}) {
10736
11980
  return { error: `unknown tool ${name}` };
10737
11981
  }
10738
11982
  function proposeOnly(session, ruleId) {
10739
- const hit = session.findings.find((f) => f.ruleId === ruleId);
10740
- if (!hit?.file || !session.cwd) return { diff: "", wrote: false };
10741
- const abs = join(session.cwd, hit.file);
11983
+ const hit2 = session.findings.find((f) => f.ruleId === ruleId);
11984
+ if (!hit2?.file || !session.cwd) return { diff: "", wrote: false };
11985
+ const abs = join(session.cwd, hit2.file);
10742
11986
  const source = readFileSync(abs, "utf8");
10743
- const proposal = proposeFix(hit, source);
11987
+ const proposal = proposeFix(hit2, source);
10744
11988
  return { diff: proposal?.diff ?? "", wrote: false };
10745
11989
  }
10746
11990
  function verifyInTemp(session, ruleId) {
10747
- const hit = session.findings.find((f) => f.ruleId === ruleId);
10748
- if (!hit?.file || !session.cwd) return { originalUntouched: true, tempApplied: false };
10749
- const original = join(session.cwd, hit.file);
11991
+ const hit2 = session.findings.find((f) => f.ruleId === ruleId);
11992
+ if (!hit2?.file || !session.cwd) return { originalUntouched: true, tempApplied: false };
11993
+ const original = join(session.cwd, hit2.file);
10750
11994
  const before = readFileSync(original, "utf8");
10751
11995
  const mtime = statSync(original).mtimeMs;
10752
- const proposal = proposeFix(hit, before);
11996
+ const proposal = proposeFix(hit2, before);
10753
11997
  if (!proposal?.diff) return { originalUntouched: true, tempApplied: false };
10754
11998
  const tmp = mkdtempSync(join(tmpdir(), "fullshield-mcp-verify-"));
10755
11999
  try {
10756
12000
  cpSync(session.cwd, tmp, { recursive: true });
10757
- const copy = join(tmp, hit.file);
12001
+ const copy = join(tmp, hit2.file);
10758
12002
  const next = applyUnifiedDiff(readFileSync(copy, "utf8"), proposal.diff);
10759
12003
  writeFileSync(copy, next);
10760
12004
  const untouched = readFileSync(original, "utf8") === before && statSync(original).mtimeMs === mtime;
@@ -10978,16 +12222,16 @@ async function confirmTarget(baseUrl, opts) {
10978
12222
  try {
10979
12223
  if (route.kind === "idor") {
10980
12224
  const url2 = `${origin}${route.path}`;
10981
- const hit2 = await probe(fetchImpl, url2, route.method, void 0, {
12225
+ const hit3 = await probe(fetchImpl, url2, route.method, void 0, {
10982
12226
  "x-user": route.asUser ?? opts.users?.b ?? "bob"
10983
12227
  });
10984
12228
  const owner = route.expectOwner ?? opts.users?.a ?? "alice";
10985
- const confirmed2 = hit2.ok && hit2.text.includes(owner);
12229
+ const confirmed2 = hit3.ok && hit3.text.includes(owner);
10986
12230
  confirmations.push({
10987
12231
  kind: "idor",
10988
12232
  status: confirmed2 ? "confirmed" : "unconfirmed",
10989
12233
  proof: curlProof(route.method, url2, `-H 'x-user: ${route.asUser ?? "bob"}' `),
10990
- detail: hit2.text.slice(0, 200)
12234
+ detail: hit3.text.slice(0, 200)
10991
12235
  });
10992
12236
  continue;
10993
12237
  }
@@ -11000,13 +12244,13 @@ async function confirmTarget(baseUrl, opts) {
11000
12244
  } else {
11001
12245
  body = { [param]: payload };
11002
12246
  }
11003
- const hit = await probe(fetchImpl, url, route.method, body);
11004
- const confirmed = hit.text.includes(token) || route.kind === "path" && hit.text.includes("FULLSHIELD_CANARY");
12247
+ const hit2 = await probe(fetchImpl, url, route.method, body);
12248
+ const confirmed = hit2.text.includes(token) || route.kind === "path" && hit2.text.includes("FULLSHIELD_CANARY");
11005
12249
  confirmations.push({
11006
12250
  kind: route.kind,
11007
12251
  status: confirmed ? "confirmed" : "unconfirmed",
11008
12252
  proof: curlProof(route.method, url),
11009
- detail: hit.text.slice(0, 200)
12253
+ detail: hit2.text.slice(0, 200)
11010
12254
  });
11011
12255
  } catch (err) {
11012
12256
  confirmations.push({
@@ -11284,14 +12528,16 @@ function openVexStatement(finding6) {
11284
12528
  vulnerability: { name: vulnName(finding6) },
11285
12529
  products: [{ "@id": npmPurl(finding6.snippet) }]
11286
12530
  };
12531
+ const detail = process.env.FULLSHIELD_EXPERIMENTAL_SBOM_REACH === "1" && finding6.reachabilityEvidence?.reason !== void 0 ? { detail: finding6.reachabilityEvidence.reason } : {};
11287
12532
  if (notImported(finding6)) {
11288
12533
  return {
11289
12534
  ...base,
11290
12535
  status: "not_affected",
11291
- justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION
12536
+ justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION,
12537
+ ...detail
11292
12538
  };
11293
12539
  }
11294
- return { ...base, status: "under_investigation" };
12540
+ return { ...base, status: "under_investigation", ...detail };
11295
12541
  }
11296
12542
  function buildOpenVex(findings) {
11297
12543
  const statements = depFindings(findings).map(openVexStatement);
@@ -11579,6 +12825,28 @@ async function runFix(options = {}) {
11579
12825
  const after = applyUnifiedDiff(source, proposed.diff);
11580
12826
  byFile.set(sourceFile, after);
11581
12827
  }
12828
+ const unused = result.metadata.unusedDirect?.packages ?? [];
12829
+ if (unused.length > 0) {
12830
+ let manifest = byFile.get("package.json");
12831
+ if (manifest === void 0) {
12832
+ try {
12833
+ manifest = await readFile(join(cwd, "package.json"), "utf8");
12834
+ } catch {
12835
+ manifest = void 0;
12836
+ }
12837
+ }
12838
+ if (manifest !== void 0) {
12839
+ const next = removeDirectDependencies(manifest, unused);
12840
+ const diff = dependencyRemovalDiff(manifest, unused);
12841
+ if (diff.length > 0) {
12842
+ patches.push({ file: "package.json", ruleId: "DEPS-VULN-001", safe: false, diff });
12843
+ if (options.unsafe === true) {
12844
+ byFile.set("package.json", next);
12845
+ appliedOverride = true;
12846
+ }
12847
+ }
12848
+ }
12849
+ }
11582
12850
  const notice = appliedOverride ? "run npm install" : void 0;
11583
12851
  if (options.apply !== true) {
11584
12852
  return { applied: false, reverted: false, patches, findings: result.findings, notice };
@@ -11626,6 +12894,29 @@ async function restoreSnapshots(cwd, snapshots) {
11626
12894
  }
11627
12895
  }
11628
12896
  }
12897
+ function dependencyRemovalDiff(manifest, names) {
12898
+ const drop = names.map((name) => `"${name}"`);
12899
+ const removed = manifest.split("\n").filter((line) => drop.some((token) => line.includes(token)));
12900
+ if (removed.length === 0) return "";
12901
+ return ["--- a/package.json", "+++ b/package.json", ...removed.map((line) => `-${line}`)].join("\n");
12902
+ }
12903
+ function removeDirectDependencies(manifest, names) {
12904
+ let parsed;
12905
+ try {
12906
+ parsed = JSON.parse(manifest);
12907
+ } catch {
12908
+ return manifest;
12909
+ }
12910
+ if (parsed.dependencies === void 0 || typeof parsed.dependencies !== "object") return manifest;
12911
+ const drop = new Set(names);
12912
+ const next = {};
12913
+ for (const [name, version] of Object.entries(parsed.dependencies)) {
12914
+ if (!drop.has(name)) next[name] = version;
12915
+ }
12916
+ parsed.dependencies = next;
12917
+ const trailing = manifest.endsWith("\n") ? "\n" : "";
12918
+ return `${JSON.stringify(parsed, null, 2)}${trailing}`;
12919
+ }
11629
12920
  function runProjectTests(cwd) {
11630
12921
  let pkg;
11631
12922
  try {
@@ -11994,7 +13285,159 @@ function renderFinding(finding6) {
11994
13285
  }
11995
13286
  return lines;
11996
13287
  }
11997
- function renderConsoleReport(input) {
13288
+ var SEVERITY_RANK = {
13289
+ critical: 0,
13290
+ high: 1,
13291
+ medium: 2,
13292
+ low: 3,
13293
+ info: 4
13294
+ };
13295
+ var SHOWN_VULN_REACH = /* @__PURE__ */ new Set([
13296
+ "reachable",
13297
+ "symbol-reachable",
13298
+ "reachable-via-dependency",
13299
+ "reachability-unknown"
13300
+ ]);
13301
+ function packageNameFromSnippet3(snippet) {
13302
+ if (snippet === void 0 || snippet.length === 0) return void 0;
13303
+ if (snippet.startsWith("@")) {
13304
+ const second = snippet.indexOf("@", 1);
13305
+ return second === -1 ? snippet : snippet.slice(0, second);
13306
+ }
13307
+ const at = snippet.indexOf("@");
13308
+ return at <= 0 ? snippet : snippet.slice(0, at);
13309
+ }
13310
+ function versionsFromSnippet(snippet) {
13311
+ const name = packageNameFromSnippet3(snippet);
13312
+ if (name === void 0 || snippet === void 0) return [];
13313
+ const rest = snippet.slice(name.length + 1);
13314
+ return rest.length === 0 ? [] : rest.split(",").filter((version) => version.length > 0);
13315
+ }
13316
+ function isDevOnly2(finding6) {
13317
+ return finding6.message.includes("(dev-only)");
13318
+ }
13319
+ function isActionable(finding6) {
13320
+ if (finding6.ruleId === "DEPS-MALICIOUS-001") return true;
13321
+ if (finding6.ruleId === "DEPS-VULN-001") {
13322
+ if (isDevOnly2(finding6)) return false;
13323
+ return finding6.reachability !== void 0 && SHOWN_VULN_REACH.has(finding6.reachability);
13324
+ }
13325
+ if (finding6.module === "sast" || finding6.module === "secrets" || finding6.module === "config") {
13326
+ return finding6.severity === "high" || finding6.severity === "critical" || finding6.confidence === "high";
13327
+ }
13328
+ return false;
13329
+ }
13330
+ function reachRank(finding6) {
13331
+ const reachability = finding6.reachability;
13332
+ if (reachability === "reachable" || reachability === "symbol-reachable") return 0;
13333
+ if (reachability === "reachable-via-dependency") return 1;
13334
+ if (reachability === "reachability-unknown") return 2;
13335
+ if (reachability === void 0) return 4;
13336
+ return 3;
13337
+ }
13338
+ function compareActionable(a, b) {
13339
+ const maliciousA = a.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
13340
+ const maliciousB = b.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
13341
+ return maliciousA - maliciousB || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || reachRank(a) - reachRank(b) || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId);
13342
+ }
13343
+ function highestSeverity(findings) {
13344
+ return findings.reduce(
13345
+ (best, finding6) => SEVERITY_RANK[finding6.severity] < SEVERITY_RANK[best] ? finding6.severity : best,
13346
+ findings[0]?.severity ?? "info"
13347
+ );
13348
+ }
13349
+ function bestReachability(findings) {
13350
+ let best;
13351
+ let bestRank = 5;
13352
+ for (const finding6 of findings) {
13353
+ const rank = reachRank(finding6);
13354
+ if (finding6.reachability !== void 0 && rank < bestRank) {
13355
+ best = finding6.reachability;
13356
+ bestRank = rank;
13357
+ }
13358
+ }
13359
+ return best;
13360
+ }
13361
+ function shownConsoleRows(findings) {
13362
+ return dependencyRows(findings.filter(isActionable));
13363
+ }
13364
+ function dependencyRows(findings) {
13365
+ const groups = /* @__PURE__ */ new Map();
13366
+ const singles = [];
13367
+ for (const finding6 of findings) {
13368
+ const name = packageNameFromSnippet3(finding6.snippet);
13369
+ if ((finding6.ruleId === "DEPS-VULN-001" || finding6.ruleId === "DEPS-MALICIOUS-001") && name !== void 0) {
13370
+ const list = groups.get(name);
13371
+ if (list === void 0) groups.set(name, [finding6]);
13372
+ else list.push(finding6);
13373
+ } else {
13374
+ singles.push(finding6);
13375
+ }
13376
+ }
13377
+ const rows = singles.map((finding6) => ({
13378
+ findings: [finding6],
13379
+ severity: finding6.severity,
13380
+ reachability: finding6.reachability,
13381
+ ruleId: finding6.ruleId,
13382
+ file: finding6.file,
13383
+ line: finding6.line,
13384
+ versions: [],
13385
+ grouped: false
13386
+ }));
13387
+ for (const [packageName, list] of groups) {
13388
+ const ordered = [...list].sort(compareActionable);
13389
+ const first = ordered[0];
13390
+ if (first === void 0) continue;
13391
+ const versions = [...new Set(list.flatMap((finding6) => versionsFromSnippet(finding6.snippet)))].sort();
13392
+ const malicious = list.find((finding6) => finding6.ruleId === "DEPS-MALICIOUS-001");
13393
+ rows.push({
13394
+ findings: ordered,
13395
+ severity: highestSeverity(list),
13396
+ reachability: bestReachability(list),
13397
+ ruleId: malicious?.ruleId ?? "DEPS-VULN-001",
13398
+ file: first.file,
13399
+ line: first.line,
13400
+ packageName,
13401
+ versions,
13402
+ path: first.dependencyPath,
13403
+ grouped: true
13404
+ });
13405
+ }
13406
+ rows.sort((a, b) => {
13407
+ const probeA = a.findings[0];
13408
+ const probeB = b.findings[0];
13409
+ if (probeA === void 0 || probeB === void 0) return 0;
13410
+ const maliciousA = a.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
13411
+ const maliciousB = b.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
13412
+ return maliciousA - maliciousB || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || reachRank({ ...probeA, reachability: a.reachability }) - reachRank({ ...probeB, reachability: b.reachability }) || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId);
13413
+ });
13414
+ return rows;
13415
+ }
13416
+ function renderRow(row) {
13417
+ if (!row.grouped) {
13418
+ const only = row.findings[0];
13419
+ if (only === void 0) return [];
13420
+ return [...renderFinding(only), ` docs: rules/${only.ruleId}.md`];
13421
+ }
13422
+ const reach = row.reachability ?? "unspecified";
13423
+ const lines = [
13424
+ ` ${pc.bold(`[${row.ruleId}]`)} ${row.packageName} \u2014 ${row.findings.length} advisories, ${row.severity}, ${reach}`,
13425
+ ` versions: ${row.versions.join(", ") || "unknown"}`
13426
+ ];
13427
+ if (row.path !== void 0 && row.path.length > 0) {
13428
+ lines.push(` path: ${row.path.join(" > ")}`);
13429
+ }
13430
+ if (row.file !== void 0) {
13431
+ lines.push(` ${row.file}${row.line !== void 0 ? `:${row.line}` : ""}`);
13432
+ }
13433
+ const evidenceImport = row.findings[0]?.reachabilityEvidence?.imports[0];
13434
+ if (evidenceImport !== void 0) {
13435
+ lines.push(` import: ${evidenceImport.file}:${evidenceImport.line} ${evidenceImport.specifier}`);
13436
+ }
13437
+ lines.push(` docs: rules/${row.ruleId}.md`);
13438
+ return lines;
13439
+ }
13440
+ function renderConsoleReport(input, options = {}) {
11998
13441
  const result = redactScan(input);
11999
13442
  const { findings, summary, metadata } = result;
12000
13443
  const lines = [];
@@ -12004,34 +13447,63 @@ function renderConsoleReport(input) {
12004
13447
  )
12005
13448
  );
12006
13449
  if (metadata.history !== void 0) lines.push(metadata.history);
13450
+ if (metadata.upgradePlan !== void 0 && metadata.upgradePlan.sentence.length > 0) {
13451
+ lines.push(metadata.upgradePlan.sentence);
13452
+ }
13453
+ if (metadata.unusedDirect !== void 0) {
13454
+ const count = metadata.unusedDirect.packages.length;
13455
+ const advisories = metadata.unusedDirect.advisories;
13456
+ const packageWord = count === 1 ? "package is" : "packages are";
13457
+ const advisoryWord = advisories === 1 ? "advisory" : "advisories";
13458
+ lines.push(
13459
+ `${count} ${packageWord} never imported; removing them clears ${advisories} ${advisoryWord}.`
13460
+ );
13461
+ }
12007
13462
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
12008
13463
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
12009
13464
  lines.push(
12010
13465
  pc.dim(`note: requested modules not implemented yet: ${pending.join(", ")}`)
12011
13466
  );
12012
13467
  }
12013
- const bySeverityDesc = [...SEVERITIES].reverse();
12014
- const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
12015
- const shown = ordered.slice(0, 10);
12016
- const hidden = ordered.length - shown.length;
12017
- for (const severity of bySeverityDesc) {
12018
- const group = shown.filter((f) => f.severity === severity);
12019
- if (group.length === 0) continue;
12020
- lines.push("");
12021
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
12022
- for (const finding6 of group) {
12023
- lines.push(...renderFinding(finding6));
12024
- lines.push(` docs: rules/${finding6.ruleId}.md`);
13468
+ const showAll = options.all === true;
13469
+ const folded = findings.filter((finding6) => !isActionable(finding6));
13470
+ const rows = showAll ? [...findings].sort(compareActionable).map((finding6) => ({
13471
+ findings: [finding6],
13472
+ severity: finding6.severity,
13473
+ reachability: finding6.reachability,
13474
+ ruleId: finding6.ruleId,
13475
+ file: finding6.file,
13476
+ line: finding6.line,
13477
+ versions: [],
13478
+ grouped: false
13479
+ })) : shownConsoleRows(findings);
13480
+ const printed = showAll ? rows : rows.slice(0, 20);
13481
+ const hiddenActionable = showAll ? 0 : rows.length - printed.length;
13482
+ let previous;
13483
+ for (const row of printed) {
13484
+ if (row.severity !== previous) {
13485
+ const count = printed.filter((item) => item.severity === row.severity).length;
13486
+ lines.push("");
13487
+ lines.push(SEVERITY_COLOR[row.severity](`${row.severity.toUpperCase()} (${count})`));
13488
+ previous = row.severity;
12025
13489
  }
13490
+ lines.push(...renderRow(row));
13491
+ }
13492
+ if (hiddenActionable > 0) {
13493
+ lines.push("");
13494
+ lines.push(`${hiddenActionable} more actionable \u2014 use --json`);
12026
13495
  }
12027
- if (hidden > 0) {
13496
+ if (!showAll && folded.length > 0) {
12028
13497
  lines.push("");
12029
- lines.push(`${hidden} more \u2014 use --json`);
13498
+ lines.push(
13499
+ `${folded.length} more (not directly reachable, or below high confidence) \u2014 --all or see JSON`
13500
+ );
12030
13501
  }
12031
13502
  lines.push("");
12032
13503
  if (summary.total === 0) {
12033
13504
  lines.push(pc.green("\u2714 No findings."));
12034
13505
  } else {
13506
+ const bySeverityDesc = [...SEVERITIES].reverse();
12035
13507
  const counts = bySeverityDesc.filter((s) => summary.bySeverity[s] > 0).map((s) => `${summary.bySeverity[s]} ${s}`).join(", ");
12036
13508
  lines.push(pc.red(`\u2716 ${summary.total} finding(s): ${counts}`));
12037
13509
  }
@@ -12161,8 +13633,6 @@ function renderMarkdownReport(input) {
12161
13633
  }
12162
13634
  return lines.join("\n");
12163
13635
  }
12164
-
12165
- // src/reporters/sarif.ts
12166
13636
  var SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json";
12167
13637
  function sarifLevel(severity) {
12168
13638
  if (severity === "critical" || severity === "high") return "error";
@@ -12172,15 +13642,37 @@ function sarifLevel(severity) {
12172
13642
  function uri(file) {
12173
13643
  return (file ?? "").replace(/\\/g, "/");
12174
13644
  }
13645
+ function sarifV2() {
13646
+ return process.env.FULLSHIELD_EXPERIMENTAL_SARIF_V2 === "1";
13647
+ }
13648
+ function ruleMarkdown(ruleId) {
13649
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "rules", `${ruleId}.md`);
13650
+ if (!existsSync(path)) return void 0;
13651
+ return readFileSync(path, "utf8").slice(0, 2048);
13652
+ }
13653
+ function relatedLocations(finding6) {
13654
+ if (finding6.dependencyPath === void 0 || finding6.dependencyPath.length === 0) return void 0;
13655
+ return finding6.dependencyPath.map((step) => ({
13656
+ message: { text: step },
13657
+ physicalLocation: {
13658
+ artifactLocation: { uri: uri(finding6.file) },
13659
+ ...finding6.line !== void 0 ? { region: { startLine: finding6.line } } : {}
13660
+ }
13661
+ }));
13662
+ }
12175
13663
  function renderSarifReport(input) {
12176
13664
  const result = redactScan(input);
13665
+ const v2 = sarifV2();
12177
13666
  const rules = /* @__PURE__ */ new Map();
12178
13667
  for (const finding6 of result.findings) {
12179
13668
  if (!rules.has(finding6.ruleId)) {
13669
+ const markdown = v2 ? ruleMarkdown(finding6.ruleId) : void 0;
12180
13670
  rules.set(finding6.ruleId, {
12181
13671
  id: finding6.ruleId,
12182
13672
  name: finding6.ruleId,
12183
- shortDescription: { text: finding6.title }
13673
+ shortDescription: { text: finding6.title },
13674
+ ...markdown !== void 0 ? { help: { markdown } } : {},
13675
+ ...v2 ? { properties: { precision: "unreviewed" } } : {}
12184
13676
  });
12185
13677
  }
12186
13678
  }
@@ -12209,6 +13701,7 @@ function renderSarifReport(input) {
12209
13701
  }
12210
13702
  }
12211
13703
  ] : [];
13704
+ const related = v2 ? relatedLocations(finding6) : void 0;
12212
13705
  const codeFlows = finding6.trace !== void 0 && finding6.trace.length > 0 ? [
12213
13706
  {
12214
13707
  threadFlows: [
@@ -12231,7 +13724,12 @@ function renderSarifReport(input) {
12231
13724
  level: sarifLevel(finding6.severity),
12232
13725
  message: { text: finding6.message },
12233
13726
  locations: location4,
12234
- ...codeFlows !== void 0 ? { codeFlows } : {}
13727
+ ...codeFlows !== void 0 ? { codeFlows } : {},
13728
+ ...v2 ? {
13729
+ partialFingerprints: { "fullshield/v1": lineIndependentFingerprint(finding6) },
13730
+ ...related !== void 0 ? { relatedLocations: related } : {},
13731
+ ...finding6.module === "deps" && finding6.reachability !== void 0 ? { properties: { reachability: finding6.reachability } } : {}
13732
+ } : {}
12235
13733
  };
12236
13734
  })
12237
13735
  }
@@ -12373,13 +13871,152 @@ function renderDiffText(diff) {
12373
13871
  init_experimental();
12374
13872
  init_policy();
12375
13873
 
13874
+ // src/deps-diff/index.ts
13875
+ init_semver();
13876
+ init_lockfile();
13877
+ var execFileAsync4 = promisify(execFile);
13878
+ var LOCK_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"];
13879
+ function diffLockedPackages(before, after, direct) {
13880
+ const oldMap = new Map(before.map((pkg) => [pkg.name, pkg.version]));
13881
+ const newMap = new Map(after.map((pkg) => [pkg.name, pkg.version]));
13882
+ const names = [.../* @__PURE__ */ new Set([...oldMap.keys(), ...newMap.keys()])].sort();
13883
+ const changes = [];
13884
+ for (const name of names) {
13885
+ const oldVersion = oldMap.get(name);
13886
+ const newVersion = newMap.get(name);
13887
+ const isDirect2 = direct.has(name);
13888
+ if (oldVersion === void 0 && newVersion !== void 0) {
13889
+ changes.push({ name, change: "added", newVersion, direct: isDirect2 });
13890
+ } else if (oldVersion !== void 0 && newVersion === void 0) {
13891
+ changes.push({ name, change: "removed", oldVersion, direct: isDirect2 });
13892
+ } else if (oldVersion !== void 0 && newVersion !== void 0 && oldVersion !== newVersion) {
13893
+ const order = compareSemver(newVersion, oldVersion);
13894
+ changes.push({
13895
+ name,
13896
+ change: order >= 0 ? "upgraded" : "downgraded",
13897
+ oldVersion,
13898
+ newVersion,
13899
+ direct: isDirect2
13900
+ });
13901
+ }
13902
+ }
13903
+ return changes;
13904
+ }
13905
+ function packagesFromLock(filename, contents) {
13906
+ const graph2 = parseLockfileGraph(filename, contents);
13907
+ return { packages: graph2.packages, direct: /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]) };
13908
+ }
13909
+ async function gitShow(cwd, spec, run = defaultGit) {
13910
+ return run(cwd, ["show", spec]);
13911
+ }
13912
+ async function defaultGit(cwd, args) {
13913
+ if (args[0] !== "show") throw new Error("deps-diff only runs git show");
13914
+ const { stdout } = await execFileAsync4("git", args, { cwd, encoding: "utf8" });
13915
+ return stdout;
13916
+ }
13917
+ async function lockAtRef(cwd, ref, run = defaultGit) {
13918
+ for (const file of LOCK_NAMES) {
13919
+ try {
13920
+ const text = await gitShow(cwd, `${ref}:${file}`, run);
13921
+ if (text.length > 0) return { file, text };
13922
+ } catch {
13923
+ }
13924
+ }
13925
+ throw new Error(`deps-diff cannot read a lockfile at ${ref} (FULLSHIELD_EXPERIMENTAL_DEPS_DIFF)`);
13926
+ }
13927
+ function releaseAgeLabel(publishedAt, nowMs, minHours = 72) {
13928
+ if (publishedAt === void 0) return "unknown";
13929
+ const published = Date.parse(publishedAt);
13930
+ if (Number.isNaN(published)) return "unknown";
13931
+ const hours = (nowMs - published) / 36e5;
13932
+ return hours < minHours ? "new-release" : "aged";
13933
+ }
13934
+ var ExplainError = class extends Error {
13935
+ constructor(message) {
13936
+ super(message);
13937
+ this.name = "ExplainError";
13938
+ }
13939
+ };
13940
+ function matchFinding(findings, rawId) {
13941
+ const id = rawId.trim();
13942
+ if (id.length < 6) {
13943
+ throw new ExplainError(`id prefix must be at least 6 characters (FULLSHIELD_EXPERIMENTAL_EXPLAIN)`);
13944
+ }
13945
+ const exact = findings.find((finding6) => finding6.id === id);
13946
+ if (exact !== void 0) return exact;
13947
+ const matches = findings.filter((finding6) => finding6.id.startsWith(id));
13948
+ if (matches.length === 1) {
13949
+ const only = matches[0];
13950
+ if (only !== void 0) return only;
13951
+ }
13952
+ if (matches.length === 0) {
13953
+ const candidates = findings.slice(0, 5).map((finding6) => finding6.id);
13954
+ throw new ExplainError(
13955
+ `no finding matches ${id}${candidates.length > 0 ? `; candidates: ${candidates.join(", ")}` : ""} (FULLSHIELD_EXPERIMENTAL_EXPLAIN)`
13956
+ );
13957
+ }
13958
+ throw new ExplainError(
13959
+ `ambiguous id ${id}; candidates: ${matches.slice(0, 5).map((finding6) => finding6.id).join(", ")} (FULLSHIELD_EXPERIMENTAL_EXPLAIN)`
13960
+ );
13961
+ }
13962
+ function contextLines(text, line) {
13963
+ if (line === void 0) return [];
13964
+ const rows = text.split(/\r?\n/);
13965
+ const start = Math.max(0, line - 3);
13966
+ const end = Math.min(rows.length, line + 2);
13967
+ return rows.slice(start, end).map((row, index) => `${start + index + 1}|${row}`);
13968
+ }
13969
+ function renderExplain(finding6, fileText = "") {
13970
+ const location4 = finding6.file !== void 0 ? `${finding6.file}${finding6.line !== void 0 ? `:${finding6.line}` : ""}` : "(project)";
13971
+ const lines = [
13972
+ `${finding6.ruleId} ${finding6.title}`,
13973
+ `severity: ${finding6.severity}`,
13974
+ `confidence: ${finding6.confidence}`,
13975
+ `location: ${location4}`
13976
+ ];
13977
+ const context = contextLines(fileText, finding6.line);
13978
+ if (context.length > 0) {
13979
+ lines.push("context:");
13980
+ lines.push(...context.map((row) => ` ${row}`));
13981
+ }
13982
+ if (finding6.snippet !== void 0) lines.push(`snippet: ${finding6.snippet}`);
13983
+ if (finding6.trace !== void 0 && finding6.trace.length > 0) {
13984
+ lines.push(
13985
+ `trace: ${finding6.trace.map((step) => `${step.file}${step.line !== void 0 ? `:${step.line}` : ""} ${step.note}`).join(" \u2192 ")}`
13986
+ );
13987
+ }
13988
+ if (finding6.reachability !== void 0) {
13989
+ const path = finding6.dependencyPath !== void 0 ? ` (${finding6.dependencyPath.join(" > ")})` : "";
13990
+ lines.push(`reachability: ${finding6.reachability}${path}`);
13991
+ }
13992
+ if (finding6.fix !== void 0) lines.push(`fix: ${finding6.fix.diff}`);
13993
+ lines.push(`docs: rules/${finding6.ruleId}.md`);
13994
+ return lines.join("\n");
13995
+ }
13996
+ async function explainFinding(options) {
13997
+ const finding6 = matchFinding(options.findings, options.id);
13998
+ let text = "";
13999
+ if (finding6.file !== void 0) {
14000
+ try {
14001
+ text = await readFile(join(options.cwd, finding6.file), "utf8");
14002
+ } catch {
14003
+ text = "";
14004
+ }
14005
+ }
14006
+ return renderExplain(finding6, text);
14007
+ }
14008
+
14009
+ // src/cli/program.ts
14010
+ init_osv();
14011
+ init_semver();
14012
+
12376
14013
  // src/sbom/index.ts
12377
14014
  init_engine2();
12378
14015
  init_lockfile();
12379
14016
  init_pins();
12380
14017
  init_cyclonedx();
12381
14018
  var LOCKFILE_NAMES2 = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
12382
- async function buildSbom(cwd) {
14019
+ async function buildSbom(cwd, options = {}) {
12383
14020
  const result = await scan({ cwd, modules: ["deps"] });
12384
14021
  let name = "app";
12385
14022
  let version = "0.0.0";
@@ -12405,7 +14042,13 @@ async function buildSbom(cwd) {
12405
14042
  if (components.length === 0) {
12406
14043
  components = exactPinsFromManifest(manifest).map((p) => ({ name: p.name, version: p.version }));
12407
14044
  }
12408
- return renderCycloneDx({ name, version, components, findings: result.findings });
14045
+ return renderCycloneDx({
14046
+ name,
14047
+ version,
14048
+ components,
14049
+ findings: result.findings,
14050
+ includeReach: options.includeReach === true
14051
+ });
12409
14052
  }
12410
14053
 
12411
14054
  // src/cli/program.ts
@@ -13045,7 +14688,7 @@ function stateForGrace(sshd) {
13045
14688
 
13046
14689
  // src/cli/commands/doctor.ts
13047
14690
  init_config2();
13048
- var MIN_NODE = [20, 11, 0];
14691
+ var MIN_NODE = [22, 13, 0];
13049
14692
  var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
13050
14693
  var FLAGS = [
13051
14694
  "FULLSHIELD_EXPERIMENTAL_SAST_GAPS",
@@ -13076,7 +14719,7 @@ function nodeOk(version) {
13076
14719
  async function runDoctor(cwd, options = {}) {
13077
14720
  const lines = [];
13078
14721
  const version = process.versions.node;
13079
- lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 20.11.0)"}`);
14722
+ lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 22.13.0)"}`);
13080
14723
  const locks = LOCKFILES.filter((name) => existsSync(join(cwd, name)));
13081
14724
  lines.push(locks.length > 0 ? `lockfile: ${locks.join(", ")}` : "lockfile: none");
13082
14725
  const cacheDir = join(homedir(), ".cache", "fullshield", "osv");
@@ -13132,6 +14775,21 @@ var KNOWN = [
13132
14775
  test: /FULLSHIELD_EXPERIMENTAL_DIFF|scan JSON|cannot read diff/,
13133
14776
  code: "FS-E006",
13134
14777
  hint: "Set FULLSHIELD_EXPERIMENTAL_DIFF=1 or pass --experimental-diff. Both inputs must be scan --json files."
14778
+ },
14779
+ {
14780
+ test: /FULLSHIELD_EXPERIMENTAL_EXPLAIN|id prefix|no finding matches|ambiguous id/,
14781
+ code: "FS-E007",
14782
+ hint: "Set FULLSHIELD_EXPERIMENTAL_EXPLAIN=1. Pass an id prefix of at least 6 characters from a scan JSON file."
14783
+ },
14784
+ {
14785
+ test: /FULLSHIELD_EXPERIMENTAL_DEPS_DIFF|deps-diff cannot/,
14786
+ code: "FS-E008",
14787
+ hint: "Set FULLSHIELD_EXPERIMENTAL_DEPS_DIFF=1. Pass a git ref that contains a lockfile."
14788
+ },
14789
+ {
14790
+ test: /FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS|sast\.sources|sast\.sanitizers/,
14791
+ code: "FS-E009",
14792
+ hint: "Set FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS=1 before naming sast.sources or sast.sanitizers."
13135
14793
  }
13136
14794
  ];
13137
14795
  var DOCS = "https://github.com/kathanp/fullshield/blob/main/docs/errors.md";
@@ -13183,7 +14841,7 @@ function parseFailOn(value) {
13183
14841
  function createProgram(io = defaultIO) {
13184
14842
  const program = new Command();
13185
14843
  program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion()).option("--debug", "print stack traces on CLI errors");
13186
- program.command("scan").description("Scan a project directory for security findings").argument("[path]", "directory to scan", ".").option("--json", "machine-readable JSON output").option("--fail-on <severity>", "exit 1 if any finding is at or above this severity").option("--modules <modules>", "comma-separated subset of modules to run").option("--staged", "scan only files staged in git (pre-commit mode)").option("--offline", "use the OSV cache only (no network)").option("--offline-safe", "alias of --offline").option("--config <path>", "path to fullshield.config.json").option("--sarif <file>", "write a SARIF 2.1.0 report to this path").option("--markdown <file>", "write a Markdown report to this path").option("--html <file>", "write an HTML report to this path").option(
14844
+ program.command("scan").description("Scan a project directory for security findings").argument("[path]", "directory to scan", ".").option("--json", "machine-readable JSON output").option("--all", "print every finding in the console, with no 20-row cap").option("--fail-on <severity>", "exit 1 if any finding is at or above this severity").option("--modules <modules>", "comma-separated subset of modules to run").option("--staged", "scan only files staged in git (pre-commit mode)").option("--offline", "use the OSV cache only (no network)").option("--offline-safe", "alias of --offline").option("--config <path>", "path to fullshield.config.json").option("--sarif <file>", "write a SARIF 2.1.0 report to this path").option("--markdown <file>", "write a Markdown report to this path").option("--html <file>", "write an HTML report to this path").option(
13187
14845
  "--experimental-ci-reports",
13188
14846
  "experimental: GitHub annotations, JUnit, and GitLab Code Quality (FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
13189
14847
  ).option(
@@ -13217,6 +14875,27 @@ function createProgram(io = defaultIO) {
13217
14875
  "--experimental-vex",
13218
14876
  "experimental: worm/workflow indicators, overrides, provenance, VEX (FULLSHIELD_EXPERIMENTAL_VEX=1)"
13219
14877
  ).option(
14878
+ "--experimental-reach-evidence",
14879
+ "experimental: record why a dependency reachability class was chosen (FULLSHIELD_EXPERIMENTAL_REACH_EVIDENCE=1)"
14880
+ ).option(
14881
+ "--experimental-lockfiles",
14882
+ "experimental: yarn berry, pnpm v9, and bun.lock (FULLSHIELD_EXPERIMENTAL_LOCKFILES=1)"
14883
+ ).option(
14884
+ "--experimental-supply",
14885
+ "experimental: supply-chain campaign heuristics (FULLSHIELD_EXPERIMENTAL_SUPPLY=1)"
14886
+ ).option(
14887
+ "--experimental-workspaces",
14888
+ "experimental: scan each workspace lockfile (FULLSHIELD_EXPERIMENTAL_WORKSPACES=1)"
14889
+ ).option(
14890
+ "--experimental-sarif-v2",
14891
+ "experimental: extra SARIF fields (FULLSHIELD_EXPERIMENTAL_SARIF_V2=1)"
14892
+ ).option(
14893
+ "--experimental-custom-sanitizers",
14894
+ "experimental: config-declared sources and sanitizers (FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS=1)"
14895
+ ).option(
14896
+ "--experimental-validation",
14897
+ "experimental: route schema validation inference (FULLSHIELD_EXPERIMENTAL_VALIDATION=1)"
14898
+ ).option("--taint-summaries", "cache per-function taint summaries (FULLSHIELD_TAINT_SUMMARIES=1)").option(
13220
14899
  "--experimental-mcpcfg",
13221
14900
  "experimental: MCP client/server config rules (FULLSHIELD_EXPERIMENTAL_MCPCFG=1)"
13222
14901
  ).option(
@@ -13245,6 +14924,30 @@ function createProgram(io = defaultIO) {
13245
14924
  if (opts.experimentalVex === true) {
13246
14925
  process.env.FULLSHIELD_EXPERIMENTAL_VEX = "1";
13247
14926
  }
14927
+ if (opts.experimentalReachEvidence === true) {
14928
+ process.env.FULLSHIELD_EXPERIMENTAL_REACH_EVIDENCE = "1";
14929
+ }
14930
+ if (opts.experimentalLockfiles === true) {
14931
+ process.env.FULLSHIELD_EXPERIMENTAL_LOCKFILES = "1";
14932
+ }
14933
+ if (opts.experimentalSupply === true) {
14934
+ process.env.FULLSHIELD_EXPERIMENTAL_SUPPLY = "1";
14935
+ }
14936
+ if (opts.experimentalWorkspaces === true) {
14937
+ process.env.FULLSHIELD_EXPERIMENTAL_WORKSPACES = "1";
14938
+ }
14939
+ if (opts.experimentalSarifV2 === true) {
14940
+ process.env.FULLSHIELD_EXPERIMENTAL_SARIF_V2 = "1";
14941
+ }
14942
+ if (opts.experimentalCustomSanitizers === true) {
14943
+ process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS = "1";
14944
+ }
14945
+ if (opts.experimentalValidation === true) {
14946
+ process.env.FULLSHIELD_EXPERIMENTAL_VALIDATION = "1";
14947
+ }
14948
+ if (opts.taintSummaries === true) {
14949
+ process.env.FULLSHIELD_TAINT_SUMMARIES = "1";
14950
+ }
13248
14951
  if (opts.experimentalMcpcfg === true) {
13249
14952
  process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG = "1";
13250
14953
  }
@@ -13309,7 +15012,9 @@ function createProgram(io = defaultIO) {
13309
15012
  if (opts.prSummary === true) {
13310
15013
  io.out(renderPrSummary(result.findings));
13311
15014
  } else {
13312
- io.out(opts.json === true ? renderJsonReport(result) : renderConsoleReport(result));
15015
+ io.out(
15016
+ opts.json === true ? renderJsonReport(result) : renderConsoleReport(result, { all: opts.all === true })
15017
+ );
13313
15018
  }
13314
15019
  if (result.policy !== void 0 && result.policy.errors.length > 0) {
13315
15020
  for (const message of result.policy.errors) {
@@ -13424,7 +15129,9 @@ function createProgram(io = defaultIO) {
13424
15129
  if (opts.format !== void 0 && opts.format !== "cyclonedx") {
13425
15130
  throw new Error(`unsupported SBOM format "${opts.format}" (valid: cyclonedx)`);
13426
15131
  }
13427
- const doc = await buildSbom(resolve(path));
15132
+ const doc = await buildSbom(resolve(path), {
15133
+ includeReach: process.env.FULLSHIELD_EXPERIMENTAL_SBOM_REACH === "1"
15134
+ });
13428
15135
  const errors = validateCycloneDx15(doc);
13429
15136
  if (errors.length > 0) throw new Error(`invalid SBOM: ${errors.join("; ")}`);
13430
15137
  const json = JSON.stringify(doc, null, 2);
@@ -13578,6 +15285,87 @@ function createProgram(io = defaultIO) {
13578
15285
  process.exitCode = 2;
13579
15286
  }
13580
15287
  });
15288
+ program.command("explain").description("Print one finding from a scan JSON file (requires FULLSHIELD_EXPERIMENTAL_EXPLAIN=1)").argument("<id>", "finding id or a prefix of at least 6 characters").option("--from <file>", "scan --json file").action(async (id, opts) => {
15289
+ try {
15290
+ if (process.env.FULLSHIELD_EXPERIMENTAL_EXPLAIN !== "1") {
15291
+ throw new Error("explain requires FULLSHIELD_EXPERIMENTAL_EXPLAIN=1");
15292
+ }
15293
+ if (opts.from === void 0) throw new Error("explain needs --from <scan.json> (FULLSHIELD_EXPERIMENTAL_EXPLAIN)");
15294
+ const raw = JSON.parse(await readFile(opts.from, "utf8"));
15295
+ if (!Array.isArray(raw.findings)) throw new Error("explain --from is not scan JSON (FULLSHIELD_EXPERIMENTAL_EXPLAIN)");
15296
+ const text = await explainFinding({
15297
+ cwd: process.cwd(),
15298
+ id,
15299
+ findings: raw.findings
15300
+ });
15301
+ io.out(text);
15302
+ } catch (error) {
15303
+ io.err(formatCliError(error));
15304
+ process.exitCode = 2;
15305
+ }
15306
+ });
15307
+ program.command("deps-diff").description("Compare a lockfile at a git ref with the working tree (requires FULLSHIELD_EXPERIMENTAL_DEPS_DIFF=1)").argument("<ref>", "git ref to read with git show").argument("[path]", "project directory", ".").option("--json", "print JSON").option("--fail-on <severity>", "exit 1 when an introduced advisory is at or above this severity").option("--offline", "use the OSV cache only").action(async (ref, path, opts) => {
15308
+ try {
15309
+ if (process.env.FULLSHIELD_EXPERIMENTAL_DEPS_DIFF !== "1") {
15310
+ throw new Error("deps-diff requires FULLSHIELD_EXPERIMENTAL_DEPS_DIFF=1");
15311
+ }
15312
+ const cwd = resolve(path);
15313
+ const previous = await lockAtRef(cwd, ref);
15314
+ let currentText = "";
15315
+ try {
15316
+ currentText = await readFile(join(cwd, previous.file), "utf8");
15317
+ } catch {
15318
+ currentText = "";
15319
+ }
15320
+ const before = packagesFromLock(previous.file, previous.text);
15321
+ const after = packagesFromLock(previous.file, currentText);
15322
+ const changes = diffLockedPackages(before.packages, after.packages, after.direct);
15323
+ const moved = after.packages.filter(
15324
+ (pkg) => changes.some((change) => change.name === pkg.name && change.change !== "removed" && change.newVersion === pkg.version)
15325
+ );
15326
+ const hits = await queryOsv(moved, {
15327
+ cacheDir: join(cwd, "osv-cache"),
15328
+ offline: opts.offline === true
15329
+ });
15330
+ const introduced = hits.flatMap(
15331
+ (hit2) => hit2.vulns.map((vuln) => ({
15332
+ name: hit2.pkg.name,
15333
+ version: hit2.pkg.version,
15334
+ id: vuln.id,
15335
+ severity: osvSeverity(vuln)
15336
+ }))
15337
+ );
15338
+ const added = changes.filter((change) => change.change === "added");
15339
+ const ages = [];
15340
+ for (const change of added) {
15341
+ let published;
15342
+ let installScripts = "unknown";
15343
+ try {
15344
+ const raw = JSON.parse(await readFile(join(cwd, "osv-cache", "registry", `${change.name}.json`), "utf8"));
15345
+ published = raw.time;
15346
+ if (raw.hasInstallScript === true) installScripts = "yes";
15347
+ if (raw.hasInstallScript === false) installScripts = "no";
15348
+ } catch {
15349
+ published = void 0;
15350
+ }
15351
+ ages.push({
15352
+ name: change.name,
15353
+ version: change.newVersion,
15354
+ age: releaseAgeLabel(published, Date.now()),
15355
+ installScripts
15356
+ });
15357
+ }
15358
+ const report = { changes, introduced, ages };
15359
+ io.out(JSON.stringify(report, null, 2));
15360
+ if (opts.failOn !== void 0) {
15361
+ const threshold = parseFailOn(opts.failOn);
15362
+ if (introduced.some((item) => meetsThreshold(item.severity, threshold))) process.exitCode = 1;
15363
+ }
15364
+ } catch (error) {
15365
+ io.err(formatCliError(error));
15366
+ process.exitCode = 2;
15367
+ }
15368
+ });
13581
15369
  program.command("confirm").description("Experimental exploit confirm (localhost/127.0.0.1/FullShield container only)").argument("<url>", "target base URL").action(async (url) => {
13582
15370
  try {
13583
15371
  if (process.env.FULLSHIELD_EXPERIMENTAL_CONFIRM !== "1") {
@@ -13713,8 +15501,8 @@ function createProgram(io = defaultIO) {
13713
15501
  let name = "app";
13714
15502
  let version = "0.0.0";
13715
15503
  try {
13716
- const { readFile: readFile21 } = await import('fs/promises');
13717
- const pkg = JSON.parse(await readFile21(join(cwd, "package.json"), "utf8"));
15504
+ const { readFile: readFile22 } = await import('fs/promises');
15505
+ const pkg = JSON.parse(await readFile22(join(cwd, "package.json"), "utf8"));
13718
15506
  if (typeof pkg.name === "string") name = pkg.name;
13719
15507
  if (typeof pkg.version === "string") version = pkg.version;
13720
15508
  } catch {