fullshield 0.2.6 → 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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'crypto';
3
- import { writeFile, mkdir, readFile, stat, mkdtemp, unlink, readdir } from 'fs/promises';
3
+ import { writeFile, readFile, mkdir, stat, mkdtemp, unlink, readdir } from 'fs/promises';
4
4
  import { resolve, join, dirname, relative, basename, posix } from 'path';
5
5
  import { readFileSync, existsSync, readdirSync, cpSync, mkdirSync, rmSync, statSync, writeFileSync, mkdtempSync, unlinkSync } from 'fs';
6
6
  import { homedir, tmpdir, availableParallelism } from 'os';
@@ -113,7 +113,8 @@ function walk(node, visit) {
113
113
  }
114
114
  }
115
115
  function attachParents(node, parent) {
116
- node.parent = parent;
116
+ const current = node;
117
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
117
118
  for (const key of Object.keys(node)) {
118
119
  if (SKIP_KEYS.has(String(key))) continue;
119
120
  const value = node[key];
@@ -192,8 +193,18 @@ function mcpCfgEnabled() {
192
193
  function taintV2Enabled() {
193
194
  return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
194
195
  }
196
+ function licenseEnabled() {
197
+ return process.env[LICENSE_FLAG] === "1";
198
+ }
199
+ function diffEnabled() {
200
+ return process.env[DIFF_FLAG] === "1";
201
+ }
202
+ var CI_REPORTS_FLAG, DIFF_FLAG, LICENSE_FLAG;
195
203
  var init_experimental = __esm({
196
204
  "src/modules/sast/experimental.ts"() {
205
+ CI_REPORTS_FLAG = "FULLSHIELD_EXPERIMENTAL_CI_REPORTS";
206
+ DIFF_FLAG = "FULLSHIELD_EXPERIMENTAL_DIFF";
207
+ LICENSE_FLAG = "FULLSHIELD_EXPERIMENTAL_LICENSE";
197
208
  }
198
209
  });
199
210
  function isSeverity(value) {
@@ -634,6 +645,173 @@ var init_config = __esm({
634
645
  }
635
646
  });
636
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
+
637
815
  // src/modules/deps/lockfile.ts
638
816
  function basename2(filename) {
639
817
  const parts = filename.replace(/\\/g, "/").split("/");
@@ -663,7 +841,7 @@ function objectKeys(value) {
663
841
  }
664
842
  return Object.keys(value);
665
843
  }
666
- function addEdge(edges, from, to) {
844
+ function addEdge2(edges, from, to) {
667
845
  const set = edges.get(from) ?? /* @__PURE__ */ new Set();
668
846
  set.add(to);
669
847
  edges.set(from, set);
@@ -743,7 +921,7 @@ function parseYarnGraph(contents) {
743
921
  const depBerry = /^\s{4}(?:"([^"]+)"|(@?[\w./-]+)):/.exec(raw);
744
922
  const depName = depClassic?.[1] ?? depClassic?.[2] ?? depBerry?.[1] ?? depBerry?.[2];
745
923
  if (depName !== void 0) {
746
- addEdge(edges, currentName, depName);
924
+ addEdge2(edges, currentName, depName);
747
925
  continue;
748
926
  }
749
927
  if (/^\s{2}\S/.test(raw)) inDeps = false;
@@ -837,7 +1015,7 @@ function parsePnpmGraph(contents) {
837
1015
  const dep = /^ (?:'([^']+)'|"([^"]+)"|([^:]+)):/.exec(raw);
838
1016
  const depName = dep?.[1] ?? dep?.[2] ?? dep?.[3];
839
1017
  if (depName !== void 0) {
840
- addEdge(edges, currentPkg, depName);
1018
+ addEdge2(edges, currentPkg, depName);
841
1019
  continue;
842
1020
  }
843
1021
  if (/^ \S/.test(raw)) inPkgDeps = false;
@@ -872,7 +1050,7 @@ function graphFromPackagesTable(packages) {
872
1050
  const name = key === "" ? "" : nameFromPackagesKey(key);
873
1051
  if (name === void 0 || name.length === 0) continue;
874
1052
  for (const dep of [...objectKeys(value.dependencies), ...objectKeys(value.optionalDependencies)]) {
875
- addEdge(edges, name, dep);
1053
+ addEdge2(edges, name, dep);
876
1054
  }
877
1055
  }
878
1056
  return { edges, directProd, directDev };
@@ -882,7 +1060,7 @@ function graphFromDependenciesTree(deps, edges) {
882
1060
  const child = value.dependencies;
883
1061
  if (child === void 0 || typeof child !== "object" || child === null) continue;
884
1062
  const nested = child;
885
- for (const childName of Object.keys(nested)) addEdge(edges, name, childName);
1063
+ for (const childName of Object.keys(nested)) addEdge2(edges, name, childName);
886
1064
  graphFromDependenciesTree(nested, edges);
887
1065
  }
888
1066
  }
@@ -895,8 +1073,16 @@ function parseLockfileGraph(filename, contents) {
895
1073
  directDev: /* @__PURE__ */ new Set(),
896
1074
  edgesKnown: false
897
1075
  };
898
- if (name === "yarn.lock") return parseYarnGraph(contents);
899
- 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);
900
1086
  if (name !== "package-lock.json") return empty;
901
1087
  try {
902
1088
  const json = JSON.parse(contents);
@@ -923,6 +1109,7 @@ function parseLockfileGraph(filename, contents) {
923
1109
  var PNPM_KEY;
924
1110
  var init_lockfile = __esm({
925
1111
  "src/modules/deps/lockfile.ts"() {
1112
+ init_lockfile_modern();
926
1113
  PNPM_KEY = /^ {2}['"]?(?:\/)?(@?[^'"@\s/]+(?:\/[^'"@\s/]+)?)@([^'"(\s:]+)(?:\([^)]*\))?['"]?:\s*$/;
927
1114
  }
928
1115
  });
@@ -1081,6 +1268,91 @@ var init_pins = __esm({
1081
1268
  }
1082
1269
  });
1083
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
+
1084
1356
  // src/modules/deps/symbol-reachability.ts
1085
1357
  function affectedFunctionNames(vuln) {
1086
1358
  const names = /* @__PURE__ */ new Set();
@@ -1189,7 +1461,7 @@ function packageNameFromSnippet(snippet) {
1189
1461
  const at = snippet.indexOf("@");
1190
1462
  return at <= 0 ? snippet : snippet.slice(0, at);
1191
1463
  }
1192
- function packageNameFromSpec(spec) {
1464
+ function packageNameFromSpec2(spec) {
1193
1465
  if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return void 0;
1194
1466
  if (spec.startsWith("@")) {
1195
1467
  const parts = spec.split("/");
@@ -1225,7 +1497,7 @@ function indexPackageUsage(sources) {
1225
1497
  const local = m[1];
1226
1498
  const spec = m[2];
1227
1499
  if (local === void 0 || spec === void 0) continue;
1228
- const pkg = packageNameFromSpec(spec);
1500
+ const pkg = packageNameFromSpec2(spec);
1229
1501
  if (pkg === void 0) continue;
1230
1502
  bump(pkg, localIsCalled(source, local));
1231
1503
  }
@@ -1235,7 +1507,7 @@ function indexPackageUsage(sources) {
1235
1507
  while ((named = namedRe.exec(source)) !== null) {
1236
1508
  const spec = named[2];
1237
1509
  if (spec === void 0) continue;
1238
- const pkg = packageNameFromSpec(spec);
1510
+ const pkg = packageNameFromSpec2(spec);
1239
1511
  if (pkg === void 0) continue;
1240
1512
  const locals = (named[1] ?? "").split(",").map((s) => s.trim().split(/\s+as\s+/).pop() ?? "");
1241
1513
  bump(
@@ -1267,37 +1539,82 @@ function shortestDependencyPath(target, starts, edges) {
1267
1539
  }
1268
1540
  return void 0;
1269
1541
  }
1542
+ function collectDependencyPaths(target, starts, edges, limit = 8) {
1543
+ const out = [];
1544
+ const queue = [];
1545
+ let steps = 0;
1546
+ for (const start of starts) queue.push([start]);
1547
+ while (queue.length > 0 && out.length < limit && steps < 4e3) {
1548
+ steps += 1;
1549
+ const path = queue.shift();
1550
+ if (path === void 0) continue;
1551
+ const last = path[path.length - 1];
1552
+ if (last === void 0) continue;
1553
+ if (last === target) {
1554
+ if (path.length >= 2) out.push(path);
1555
+ continue;
1556
+ }
1557
+ if (path.length >= 12) continue;
1558
+ for (const next of edges.get(last) ?? []) {
1559
+ if (path.includes(next)) continue;
1560
+ queue.push([...path, next]);
1561
+ }
1562
+ }
1563
+ return out;
1564
+ }
1270
1565
  function reachableFrom(target, roots, edges) {
1271
1566
  if (roots.has(target)) return true;
1272
1567
  return shortestDependencyPath(target, roots, edges) !== void 0;
1273
1568
  }
1274
- function importedDirectNames(usage, graph) {
1275
- const directs = /* @__PURE__ */ new Set([...graph.directProd, ...graph.directDev]);
1569
+ function importedDirectNames(usage, graph2) {
1570
+ const directs = /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]);
1276
1571
  const out = /* @__PURE__ */ new Set();
1277
- for (const [name, hit] of usage) {
1278
- if (!hit.imported) continue;
1572
+ for (const [name, hit2] of usage) {
1573
+ if (!hit2.imported) continue;
1279
1574
  if (directs.size === 0 || directs.has(name)) out.add(name);
1280
1575
  }
1281
1576
  return out;
1282
1577
  }
1283
- function classifyReachability(pkgName, productionUsage, graph) {
1284
- const hit = productionUsage.get(pkgName);
1285
- if (hit?.imported === true) {
1286
- 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" };
1287
1582
  }
1288
- if (graph !== void 0 && graph.edgesKnown) {
1289
- const roots = importedDirectNames(productionUsage, graph);
1290
- const path = shortestDependencyPath(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);
1586
+ const path = paths[0];
1291
1587
  if (path !== void 0 && path.length >= 2) {
1292
- return { reachability: "reachable-via-dependency", path };
1588
+ return { reachability: "reachable-via-dependency", path, paths };
1293
1589
  }
1294
1590
  return { reachability: "not-imported" };
1295
1591
  }
1296
- if (graph !== void 0 && !graph.edgesKnown) {
1592
+ if (graph2 !== void 0 && !graph2.edgesKnown) {
1297
1593
  return { reachability: "reachability-unknown" };
1298
1594
  }
1299
1595
  return { reachability: "not-imported" };
1300
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
+ }
1301
1618
  function reachabilitySteps(reachability) {
1302
1619
  if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
1303
1620
  return 0;
@@ -1319,8 +1636,99 @@ function isDevOnly(pkgName, ctx) {
1319
1636
  if (inProd) return false;
1320
1637
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
1321
1638
  }
1639
+ function versionFromSnippet(snippet) {
1640
+ const name = packageNameFromSnippet(snippet);
1641
+ if (name === void 0) return snippet;
1642
+ return snippet.slice(name.length + 1);
1643
+ }
1644
+ function pathFields(classified) {
1645
+ const paths = classified.paths ?? (classified.path !== void 0 ? [classified.path] : []);
1646
+ if (paths.length === 0) return {};
1647
+ return {
1648
+ dependencyPath: paths[0],
1649
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
1650
+ };
1651
+ }
1652
+ function appendOtherPaths(message, paths) {
1653
+ if (paths === void 0 || paths.length < 2) return message;
1654
+ const rest = paths.slice(1).map((p) => p.join(" > ")).join("; ");
1655
+ return `${message} Other paths: ${rest}.`;
1656
+ }
1657
+ function groupPackageAdvisories(findings) {
1658
+ const passthrough = [];
1659
+ const groups = /* @__PURE__ */ new Map();
1660
+ for (const finding6 of findings) {
1661
+ if (finding6.ruleId !== "DEPS-VULN-001" && finding6.ruleId !== "DEPS-MALICIOUS-001") {
1662
+ passthrough.push(finding6);
1663
+ continue;
1664
+ }
1665
+ const name = packageNameFromSnippet(finding6.snippet ?? "") ?? finding6.snippet ?? finding6.id;
1666
+ const advisory = finding6.references?.[0] ?? finding6.id;
1667
+ const key = `${finding6.ruleId}\0${name}\0${advisory}`;
1668
+ const list = groups.get(key);
1669
+ if (list === void 0) groups.set(key, [finding6]);
1670
+ else list.push(finding6);
1671
+ }
1672
+ const merged = [];
1673
+ for (const list of groups.values()) {
1674
+ const only = list[0];
1675
+ if (list.length === 1 && only !== void 0) {
1676
+ merged.push(only);
1677
+ continue;
1678
+ }
1679
+ merged.push(mergeAdvisory(list));
1680
+ }
1681
+ return [...passthrough, ...merged];
1682
+ }
1683
+ function mergeAdvisory(list) {
1684
+ const ranked = [...list].sort(
1685
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || (REACH_RANK[a.reachability ?? "not-imported"] ?? 9) - (REACH_RANK[b.reachability ?? "not-imported"] ?? 9)
1686
+ );
1687
+ const primary = ranked[0];
1688
+ if (primary === void 0) {
1689
+ throw new Error("empty advisory group");
1690
+ }
1691
+ const name = packageNameFromSnippet(primary.snippet ?? "") ?? "package";
1692
+ const versions = [...new Set(list.map((f) => versionFromSnippet(f.snippet ?? "")))].filter((v) => v.length > 0).sort();
1693
+ const paths = [];
1694
+ const seenPath = /* @__PURE__ */ new Set();
1695
+ for (const finding6 of list) {
1696
+ const candidates = finding6.dependencyPaths ?? (finding6.dependencyPath !== void 0 ? [finding6.dependencyPath] : []);
1697
+ for (const path of candidates) {
1698
+ const key = path.join("\0");
1699
+ if (seenPath.has(key)) continue;
1700
+ seenPath.add(key);
1701
+ paths.push(path);
1702
+ }
1703
+ }
1704
+ paths.sort((a, b) => a.length - b.length || a.join(">").localeCompare(b.join(">")));
1705
+ const advisory = primary.references?.[0] ?? "";
1706
+ const onManifest = list.find((f) => f.file === "package.json");
1707
+ const file = onManifest?.file ?? primary.file;
1708
+ const line = onManifest?.line ?? primary.line;
1709
+ const snippet = versions.length > 0 ? `${name}@${versions.join(",")}` : primary.snippet ?? name;
1710
+ let message = primary.message;
1711
+ if (versions.length > 1) {
1712
+ message = `${message} Versions: ${versions.join(", ")}.`;
1713
+ }
1714
+ if (paths.length > 1) {
1715
+ message = `${message} Dependency paths: ${paths.map((p) => p.join(" > ")).join("; ")}.`;
1716
+ }
1717
+ return {
1718
+ ...primary,
1719
+ id: makeFindingId(primary.ruleId, file, `${name}|${advisory}|${versions.join(",")}`),
1720
+ file,
1721
+ line,
1722
+ snippet,
1723
+ message,
1724
+ severity: primary.severity,
1725
+ reachability: primary.reachability,
1726
+ ...paths[0] !== void 0 ? { dependencyPath: paths[0] } : {},
1727
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
1728
+ };
1729
+ }
1322
1730
  function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1323
- return findings.map((finding6) => {
1731
+ const mapped = findings.map((finding6) => {
1324
1732
  if (finding6.ruleId === "DEPS-MALICIOUS-001") {
1325
1733
  if (finding6.snippet === void 0) return finding6;
1326
1734
  const pkg2 = packageNameFromSnippet(finding6.snippet);
@@ -1330,7 +1738,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1330
1738
  ...finding6,
1331
1739
  severity: "critical",
1332
1740
  reachability: classified2.reachability,
1333
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
1741
+ ...pathFields(classified2)
1334
1742
  };
1335
1743
  }
1336
1744
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
@@ -1340,7 +1748,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1340
1748
  const named = symbols.get(finding6.id) ?? [];
1341
1749
  if (named.length > 0 && ctx.sources !== void 0) {
1342
1750
  const symbolClass = classifySymbolUse(named, ctx.sources);
1343
- classified = { reachability: symbolClass, path: classified.path };
1751
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
1344
1752
  }
1345
1753
  const steps = reachabilitySteps(classified.reachability);
1346
1754
  let severity = steps === 0 ? finding6.severity : downgradeSeverity(finding6.severity, steps);
@@ -1348,6 +1756,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1348
1756
  if (classified.path !== void 0) {
1349
1757
  const shown = classified.path.join(" > ");
1350
1758
  message = `${message} Reachable via ${shown}.`;
1759
+ message = appendOtherPaths(message, classified.paths);
1351
1760
  }
1352
1761
  const devOnly = isDevOnly(pkg, ctx);
1353
1762
  if (devOnly) {
@@ -1357,16 +1766,27 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1357
1766
  return {
1358
1767
  ...finding6,
1359
1768
  reachability: classified.reachability,
1360
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
1769
+ ...pathFields(classified),
1361
1770
  severity,
1362
1771
  message
1363
1772
  };
1364
1773
  });
1774
+ return groupPackageAdvisories(mapped);
1365
1775
  }
1776
+ var REACH_RANK;
1366
1777
  var init_reachability = __esm({
1367
1778
  "src/modules/deps/reachability.ts"() {
1368
1779
  init_finding();
1369
1780
  init_symbol_reachability();
1781
+ REACH_RANK = {
1782
+ reachable: 0,
1783
+ "symbol-reachable": 1,
1784
+ "reachable-via-dependency": 2,
1785
+ "imported-not-called": 3,
1786
+ "symbol-not-called": 4,
1787
+ "reachability-unknown": 5,
1788
+ "not-imported": 6
1789
+ };
1370
1790
  }
1371
1791
  });
1372
1792
 
@@ -1428,6 +1848,186 @@ var init_semver = __esm({
1428
1848
  }
1429
1849
  });
1430
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
+
1431
2031
  // src/modules/deps/popular.ts
1432
2032
  var POPULAR_PACKAGES;
1433
2033
  var init_popular = __esm({
@@ -3294,6 +3894,62 @@ var init_experimental2 = __esm({
3294
3894
  "src/supply/experimental.ts"() {
3295
3895
  }
3296
3896
  });
3897
+ function isCopyleftLicense(license) {
3898
+ return COPYLEFT.test(license);
3899
+ }
3900
+ function licenseText(value) {
3901
+ if (typeof value === "string") return value;
3902
+ if (typeof value === "object" && value !== null && "type" in value) {
3903
+ const type = value.type;
3904
+ if (typeof type === "string") return type;
3905
+ }
3906
+ return void 0;
3907
+ }
3908
+ async function readJson(path) {
3909
+ try {
3910
+ return JSON.parse(await readFile(path, "utf8"));
3911
+ } catch {
3912
+ return void 0;
3913
+ }
3914
+ }
3915
+ async function scanCopyleftLicenses(cwd) {
3916
+ const manifest = await readJson(join(cwd, "package.json"));
3917
+ if (typeof manifest !== "object" || manifest === null) return [];
3918
+ const names = /* @__PURE__ */ new Set();
3919
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
3920
+ const block = manifest[field];
3921
+ if (typeof block === "object" && block !== null && !Array.isArray(block)) {
3922
+ for (const name of Object.keys(block)) names.add(name);
3923
+ }
3924
+ }
3925
+ const findings = [];
3926
+ for (const name of names) {
3927
+ const pkg = await readJson(join(cwd, "node_modules", name, "package.json"));
3928
+ if (typeof pkg !== "object" || pkg === null) continue;
3929
+ const license = licenseText(pkg.license);
3930
+ if (license === void 0 || !isCopyleftLicense(license)) continue;
3931
+ findings.push({
3932
+ id: makeFindingId("DEPS-LICENSE-001", "package.json", `${name}|${license}`),
3933
+ ruleId: "DEPS-LICENSE-001",
3934
+ module: "deps",
3935
+ title: `Copyleft dependency ${name}`,
3936
+ severity: "medium",
3937
+ confidence: "high",
3938
+ file: "package.json",
3939
+ snippet: `${name} ${license}`,
3940
+ message: `${name} is licensed ${license}.`,
3941
+ remediation: "Replace the dependency or get a legal review before shipping it in a commercial product."
3942
+ });
3943
+ }
3944
+ return findings;
3945
+ }
3946
+ var COPYLEFT;
3947
+ var init_license = __esm({
3948
+ "src/modules/deps/license.ts"() {
3949
+ init_finding();
3950
+ COPYLEFT = /(?:^|[^\w])(?:AGPL|GPL)(?:-|[^\w]|$)/i;
3951
+ }
3952
+ });
3297
3953
 
3298
3954
  // src/fixer/eol.ts
3299
3955
  function detectEol(text) {
@@ -3407,7 +4063,7 @@ __export(supply_exports, {
3407
4063
  checkProvenance: () => checkProvenance,
3408
4064
  diffPackageVersions: () => diffPackageVersions,
3409
4065
  installGuard: () => installGuard,
3410
- packageNameFromSpec: () => packageNameFromSpec2,
4066
+ packageNameFromSpec: () => packageNameFromSpec3,
3411
4067
  provenanceCoverage: () => provenanceCoverage
3412
4068
  });
3413
4069
  function assertRegistryUrl(url) {
@@ -3421,7 +4077,7 @@ function assertRegistryUrl(url) {
3421
4077
  throw new Error(`supply: only ${REGISTRY_HOST} is allowed (got ${parsed.hostname || url})`);
3422
4078
  }
3423
4079
  }
3424
- function packageNameFromSpec2(spec) {
4080
+ function packageNameFromSpec3(spec) {
3425
4081
  if (spec.startsWith("@")) {
3426
4082
  const slash = spec.indexOf("/");
3427
4083
  if (slash < 0) return spec;
@@ -3459,7 +4115,7 @@ function walkJs(dir, out = []) {
3459
4115
  }
3460
4116
  return out;
3461
4117
  }
3462
- function readJson(path) {
4118
+ function readJson2(path) {
3463
4119
  return JSON.parse(readFileSync(path, "utf8"));
3464
4120
  }
3465
4121
  function installScriptKeys(scripts) {
@@ -3470,7 +4126,7 @@ function analyzePackageDir(dir) {
3470
4126
  const findings = [];
3471
4127
  const manifestPath = join(dir, "package.json");
3472
4128
  if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) return findings;
3473
- const pkg = readJson(manifestPath);
4129
+ const pkg = readJson2(manifestPath);
3474
4130
  const scripts = pkg.scripts ?? {};
3475
4131
  const keys = installScriptKeys(scripts);
3476
4132
  if (keys.length > 0) {
@@ -3490,13 +4146,13 @@ function analyzePackageDir(dir) {
3490
4146
  const readsNpmrc = sources.some((s) => s.text.includes(".npmrc") || s.text.includes(".ssh"));
3491
4147
  const hasNet = sources.some((s) => /https\.request|http\.request|fetch\s*\(|axios\./.test(s.text));
3492
4148
  if (readsNpmrc && hasNet) {
3493
- 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"));
3494
4150
  findings.push(
3495
4151
  finding(
3496
4152
  "SUPPLY-NPMRC-NET-001",
3497
4153
  "Install-time code reads npm/ssh credentials and uses the network",
3498
4154
  "static: credential file read combined with http(s)/fetch",
3499
- hit?.file,
4155
+ hit2?.file,
3500
4156
  "critical"
3501
4157
  )
3502
4158
  );
@@ -3520,8 +4176,8 @@ function analyzePackageDir(dir) {
3520
4176
  return findings;
3521
4177
  }
3522
4178
  function diffPackageVersions(oldDir, newDir) {
3523
- const oldManifest = existsSync(join(oldDir, "package.json")) ? readJson(join(oldDir, "package.json")) : {};
3524
- const nextManifest = existsSync(join(newDir, "package.json")) ? readJson(join(newDir, "package.json")) : {};
4179
+ const oldManifest = existsSync(join(oldDir, "package.json")) ? readJson2(join(oldDir, "package.json")) : {};
4180
+ const nextManifest = existsSync(join(newDir, "package.json")) ? readJson2(join(newDir, "package.json")) : {};
3525
4181
  const oldScripts = oldManifest.scripts ?? {};
3526
4182
  const nextScripts = nextManifest.scripts ?? {};
3527
4183
  const added = INSTALL_SCRIPT_KEYS.filter((k) => typeof nextScripts[k] === "string" && oldScripts[k] === void 0);
@@ -3562,14 +4218,24 @@ function checkLockfileIntegrity(lock) {
3562
4218
  if (name === "") continue;
3563
4219
  const resolved = entry.resolved;
3564
4220
  if (!resolved) continue;
4221
+ if (entry.integrity === void 0 || entry.integrity.length === 0) {
4222
+ findings.push(
4223
+ finding(
4224
+ "SUPPLY-LOCKINTEGRITY-001",
4225
+ "Lockfile entry has no integrity hash",
4226
+ `${name} resolved without an integrity hash`,
4227
+ "package-lock.json"
4228
+ )
4229
+ );
4230
+ }
3565
4231
  try {
3566
- const host = new URL(resolved).hostname;
3567
- if (host !== REGISTRY_HOST) {
4232
+ const url = new URL(resolved);
4233
+ if (url.protocol !== "https:" || url.hostname !== REGISTRY_HOST) {
3568
4234
  findings.push(
3569
4235
  finding(
3570
4236
  "SUPPLY-LOCKURL-001",
3571
4237
  "Lockfile entry resolves from a non-npm registry host",
3572
- `${name} resolved host ${host}`,
4238
+ `${name} resolved host ${url.hostname || resolved}`,
3573
4239
  "package-lock.json"
3574
4240
  )
3575
4241
  );
@@ -3617,7 +4283,7 @@ async function installGuard(spec, opts = {}) {
3617
4283
  if (!opts.experimental && process.env.FULLSHIELD_EXPERIMENTAL_SUPPLY !== "1") {
3618
4284
  return { findings: [], skipped: true };
3619
4285
  }
3620
- const name = packageNameFromSpec2(spec);
4286
+ const name = packageNameFromSpec3(spec);
3621
4287
  const url = `https://${REGISTRY_HOST}/${encodeURIComponent(name)}`;
3622
4288
  assertRegistryUrl(url);
3623
4289
  if (opts.fetchPackument) await opts.fetchPackument(url);
@@ -3632,7 +4298,7 @@ function packageSources(dir) {
3632
4298
  let scripts = "";
3633
4299
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
3634
4300
  try {
3635
- const pkg = readJson(manifestPath);
4301
+ const pkg = readJson2(manifestPath);
3636
4302
  const block = pkg.scripts ?? {};
3637
4303
  scripts = Object.values(block).join("\n");
3638
4304
  } catch {
@@ -3653,7 +4319,7 @@ ${scripts}`;
3653
4319
  if (!TOKEN_RE.test(text)) return [];
3654
4320
  const replicates = PUBLISH_RE.test(text) || REGISTRY_PUT_RE.test(text) || GITHUB_CREATE_RE.test(text) || WORKFLOW_PATH_RE.test(text);
3655
4321
  if (!replicates) return [];
3656
- const hit = sources.find(
4322
+ const hit2 = sources.find(
3657
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))
3658
4324
  ) ?? sources.find((s) => TOKEN_RE.test(s.text));
3659
4325
  return [
@@ -3661,7 +4327,7 @@ ${scripts}`;
3661
4327
  "SUPPLY-WORM-001",
3662
4328
  "Self-replicating worm indicators in dependency code",
3663
4329
  "static: npm/GitHub token read combined with publish, registry PUT, GitHub repo/workflow creation, or writing .github/workflows",
3664
- hit?.file,
4330
+ hit2?.file,
3665
4331
  "critical"
3666
4332
  )
3667
4333
  ];
@@ -3671,13 +4337,13 @@ function analyzeWorkflowTampering(dir) {
3671
4337
  const text = `${sources.map((s) => s.text).join("\n")}
3672
4338
  ${scripts}`;
3673
4339
  if (!CI_WRITE_RE.test(text)) return [];
3674
- const hit = sources.find((s) => CI_WRITE_RE.test(s.text));
4340
+ const hit2 = sources.find((s) => CI_WRITE_RE.test(s.text));
3675
4341
  return [
3676
4342
  finding(
3677
4343
  "SUPPLY-WORKFLOW-001",
3678
4344
  "Dependency writes GitHub workflow, git hooks, or CI config",
3679
4345
  "static: install script or package code writes .github/, .git/hooks/, or CI configuration",
3680
- hit?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
4346
+ hit2?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
3681
4347
  "critical"
3682
4348
  )
3683
4349
  ];
@@ -3686,9 +4352,9 @@ function provenanceCoverage(packages, known) {
3686
4352
  let withProvenance = 0;
3687
4353
  let unknown = 0;
3688
4354
  for (const pkg of packages) {
3689
- const hit = known.get(`${pkg.name}@${pkg.version}`);
3690
- if (hit === true) withProvenance += 1;
3691
- 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;
3692
4358
  }
3693
4359
  const total = packages.length;
3694
4360
  return {
@@ -3728,8 +4394,8 @@ function parentsOf(name, edges) {
3728
4394
  }
3729
4395
  return out;
3730
4396
  }
3731
- function isDirect(name, graph) {
3732
- return graph.directProd.has(name) || graph.directDev.has(name);
4397
+ function isDirect(name, graph2) {
4398
+ return graph2.directProd.has(name) || graph2.directDev.has(name);
3733
4399
  }
3734
4400
  function packagesWithAFixedRelease(findings) {
3735
4401
  const names = /* @__PURE__ */ new Set();
@@ -3747,17 +4413,17 @@ function targetVersion(finding6) {
3747
4413
  function kindFromLockfile(lockFile) {
3748
4414
  return lockfileKind(lockFile) ?? "npm";
3749
4415
  }
3750
- function attachOverrideProposals(findings, graph, lockFile, manifestRaw) {
3751
- if (!graph.edgesKnown || manifestRaw === void 0) return findings;
4416
+ function attachOverrideProposals(findings, graph2, lockFile, manifestRaw) {
4417
+ if (!graph2.edgesKnown || manifestRaw === void 0) return findings;
3752
4418
  const withFix = packagesWithAFixedRelease(findings);
3753
4419
  const kind = kindFromLockfile(lockFile);
3754
4420
  return findings.map((finding6) => {
3755
4421
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
3756
4422
  const name = packageNameFromSnippet2(finding6.snippet);
3757
- if (name.length === 0 || isDirect(name, graph)) return finding6;
4423
+ if (name.length === 0 || isDirect(name, graph2)) return finding6;
3758
4424
  const version = targetVersion(finding6);
3759
4425
  if (version === void 0) return finding6;
3760
- const parentFixed = parentsOf(name, graph.edges).some((parent) => withFix.has(parent));
4426
+ const parentFixed = parentsOf(name, graph2.edges).some((parent) => withFix.has(parent));
3761
4427
  if (parentFixed) return finding6;
3762
4428
  const after = withManifestOverride(manifestRaw, kind, name, version);
3763
4429
  const diff = unifiedDiff("package.json", manifestRaw, after);
@@ -3778,10 +4444,10 @@ function scanInstalledPackages(cwd, packages) {
3778
4444
  const dir = packageDir(cwd, pkg);
3779
4445
  if (dir === void 0) continue;
3780
4446
  const prefix = pkg.path.startsWith("node_modules/") ? pkg.path : `node_modules/${pkg.name}`;
3781
- for (const hit of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
4447
+ for (const hit2 of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
3782
4448
  out.push({
3783
- ...hit,
3784
- 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
3785
4451
  });
3786
4452
  }
3787
4453
  }
@@ -3862,19 +4528,19 @@ function maliciousFinding(lockFile, pkg, vuln) {
3862
4528
  references: [vuln.id, ...vuln.aliases ?? []]
3863
4529
  };
3864
4530
  }
3865
- function typosquatFinding(lockFile, hit) {
3866
- const snippet = `${hit.name}@${hit.version}`;
4531
+ function typosquatFinding(lockFile, hit2) {
4532
+ const snippet = `${hit2.name}@${hit2.version}`;
3867
4533
  return {
3868
4534
  id: makeFindingId("DEPS-TYPOSQUAT-001", lockFile, snippet),
3869
4535
  ruleId: "DEPS-TYPOSQUAT-001",
3870
4536
  module: "deps",
3871
- title: `Possible typosquat: ${hit.name}`,
4537
+ title: `Possible typosquat: ${hit2.name}`,
3872
4538
  severity: "medium",
3873
4539
  confidence: "low",
3874
4540
  file: lockFile,
3875
4541
  snippet,
3876
- message: `Package "${hit.name}" is 1 character from popular package "${hit.lookalike}" (possible typosquat).`,
3877
- 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.`
3878
4544
  };
3879
4545
  }
3880
4546
  function noLockFinding() {
@@ -3905,46 +4571,121 @@ function deprecatedFinding(lockFile, pkg) {
3905
4571
  remediation: `Replace ${pkg.name} with a maintained alternative.`
3906
4572
  };
3907
4573
  }
3908
- function graphFromManifest(manifest) {
3909
- const keys = (field) => {
3910
- const block = manifest[field];
3911
- if (block !== null && typeof block === "object" && !Array.isArray(block)) {
3912
- return new Set(Object.keys(block));
3913
- }
3914
- return /* @__PURE__ */ new Set();
3915
- };
3916
- return {
3917
- packages: [],
3918
- edges: /* @__PURE__ */ new Map(),
3919
- directProd: keys("dependencies"),
3920
- directDev: keys("devDependencies"),
3921
- edgesKnown: false
3922
- };
4574
+ function workspacesEnabled() {
4575
+ return process.env.FULLSHIELD_EXPERIMENTAL_WORKSPACES === "1";
3923
4576
  }
3924
- async function reachabilityCtx(cwd, files, graph) {
3925
- const prodSources = /* @__PURE__ */ new Map();
3926
- for (const rel of files) {
3927
- if (!/\.[cm]?[jt]sx?$/.test(rel)) continue;
3928
- if (!isProductionSource(rel)) continue;
3929
- try {
3930
- prodSources.set(rel, await readFile(join(cwd, rel), "utf8"));
3931
- } catch {
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");
3932
4584
  }
4585
+ } catch {
4586
+ return [];
3933
4587
  }
3934
- return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
4588
+ return [];
3935
4589
  }
3936
- var LOCKFILE_NAMES, MANIFEST_FIELDS2, MALICIOUS_REMEDIATION, depsModule;
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
+ }
4644
+ function graphFromManifest(manifest) {
4645
+ const keys = (field) => {
4646
+ const block = manifest[field];
4647
+ if (block !== null && typeof block === "object" && !Array.isArray(block)) {
4648
+ return new Set(Object.keys(block));
4649
+ }
4650
+ return /* @__PURE__ */ new Set();
4651
+ };
4652
+ return {
4653
+ packages: [],
4654
+ edges: /* @__PURE__ */ new Map(),
4655
+ directProd: keys("dependencies"),
4656
+ directDev: keys("devDependencies"),
4657
+ edgesKnown: false
4658
+ };
4659
+ }
4660
+ async function reachabilityCtx(cwd, files, graph2) {
4661
+ const prodSources = /* @__PURE__ */ new Map();
4662
+ for (const rel of files) {
4663
+ if (!/\.[cm]?[jt]sx?$/.test(rel)) continue;
4664
+ if (!isProductionSource(rel)) continue;
4665
+ try {
4666
+ prodSources.set(rel, await readFile(join(cwd, rel), "utf8"));
4667
+ } catch {
4668
+ }
4669
+ }
4670
+ return { productionUsage: indexPackageUsage(prodSources), graph: graph2, sources: prodSources };
4671
+ }
4672
+ var LOCKFILE_NAMES, MANIFEST_FIELDS2, MALICIOUS_REMEDIATION, depsModule;
3937
4673
  var init_deps = __esm({
3938
4674
  "src/modules/deps/index.ts"() {
3939
4675
  init_finding();
3940
4676
  init_lockfile();
3941
4677
  init_osv();
3942
4678
  init_pins();
4679
+ init_evidence();
3943
4680
  init_reachability();
4681
+ init_upgrade_plan();
4682
+ init_campaign();
3944
4683
  init_symbol_reachability();
3945
4684
  init_semver();
3946
4685
  init_typosquat();
3947
4686
  init_experimental2();
4687
+ init_experimental();
4688
+ init_license();
3948
4689
  init_phase31();
3949
4690
  LOCKFILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
3950
4691
  MANIFEST_FIELDS2 = ["dependencies", "devDependencies", "optionalDependencies"];
@@ -3952,6 +4693,8 @@ var init_deps = __esm({
3952
4693
  depsModule = {
3953
4694
  name: "deps",
3954
4695
  async run(ctx) {
4696
+ depsModule.unusedDirect = void 0;
4697
+ depsModule.upgradePlan = void 0;
3955
4698
  const lockFile = LOCKFILE_NAMES.find((name) => ctx.files.includes(name));
3956
4699
  const cacheDir = ctx.config.deps.cacheDir !== void 0 ? resolve(ctx.cwd, ctx.config.deps.cacheDir) : defaultCacheDir();
3957
4700
  const osvOpts = { cacheDir, offline: ctx.config.deps.offline };
@@ -3977,21 +4720,21 @@ var init_deps = __esm({
3977
4720
  }
3978
4721
  const pins = exactPinsFromManifest(manifest);
3979
4722
  const directNames2 = await readDirectDependencyNames(ctx.cwd);
3980
- for (const hit of findTyposquats(pins, { directNames: directNames2 })) {
3981
- findings2.push(typosquatFinding("package.json", hit));
4723
+ for (const hit2 of findTyposquats(pins, { directNames: directNames2 })) {
4724
+ findings2.push(typosquatFinding("package.json", hit2));
3982
4725
  }
3983
4726
  const osvHits2 = await queryOsv(pins, osvOpts);
3984
4727
  const symbols2 = /* @__PURE__ */ new Map();
3985
- for (const hit of osvHits2) {
3986
- for (const vuln of hit.vulns) {
3987
- 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));
3988
4731
  const names = affectedFunctionNames(vuln);
3989
4732
  if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols2.set(finding6.id, names);
3990
4733
  findings2.push(finding6);
3991
4734
  }
3992
4735
  }
3993
4736
  const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
3994
- return applyReachability(findings2, noLockCtx, symbols2);
4737
+ return withWorkspaceAdvisories(ctx.cwd, manifestRaw, osvOpts, applyReachability(findings2, noLockCtx, symbols2));
3995
4738
  }
3996
4739
  let contents;
3997
4740
  try {
@@ -3999,12 +4742,12 @@ var init_deps = __esm({
3999
4742
  } catch {
4000
4743
  return [];
4001
4744
  }
4002
- const graph = parseLockfileGraph(lockFile, contents);
4003
- const packages = graph.packages;
4745
+ const graph2 = parseLockfileGraph(lockFile, contents);
4746
+ const packages = graph2.packages;
4004
4747
  const findings = [];
4005
4748
  const directNames = await readDirectDependencyNames(ctx.cwd);
4006
- for (const hit of findTyposquats(packages, { directNames })) {
4007
- findings.push(typosquatFinding(lockFile, hit));
4749
+ for (const hit2 of findTyposquats(packages, { directNames })) {
4750
+ findings.push(typosquatFinding(lockFile, hit2));
4008
4751
  }
4009
4752
  for (const pkg of packages) {
4010
4753
  if (pkg.deprecated !== void 0) {
@@ -4013,19 +4756,56 @@ var init_deps = __esm({
4013
4756
  }
4014
4757
  const osvHits = await queryOsv(packages, osvOpts);
4015
4758
  const symbols = /* @__PURE__ */ new Map();
4016
- for (const hit of osvHits) {
4017
- for (const vuln of hit.vulns) {
4018
- 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));
4019
4762
  const names = affectedFunctionNames(vuln);
4020
4763
  if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols.set(finding6.id, names);
4021
4764
  findings.push(finding6);
4022
4765
  }
4023
4766
  }
4024
- 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);
4025
4769
  if (experimentalVex()) {
4026
- reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4770
+ reached = attachOverrideProposals(reached, graph2, lockFile, manifestRaw);
4027
4771
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4028
4772
  }
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
+ }
4029
4809
  return reached;
4030
4810
  }
4031
4811
  };
@@ -4407,6 +5187,153 @@ var init_mcp = __esm({
4407
5187
  }
4408
5188
  });
4409
5189
 
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
+
4410
5337
  // src/modules/sast/frameworks.ts
4411
5338
  function decoratorExpr(dec) {
4412
5339
  if (dec.type !== "Decorator") return void 0;
@@ -4555,6 +5482,9 @@ var init_mcp2 = __esm({
4555
5482
  });
4556
5483
 
4557
5484
  // src/modules/sast/taint.ts
5485
+ function setCustomTaint(config) {
5486
+ customTaint = config;
5487
+ }
4558
5488
  function objectChainMentions(node, pattern) {
4559
5489
  let current = node;
4560
5490
  for (let i = 0; i < 8 && current !== void 0; i += 1) {
@@ -4686,6 +5616,61 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4686
5616
  return false;
4687
5617
  }
4688
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
+ }
4689
5674
  function exprIsSanitized(node, tainted, names, isCall, returners = NO_RETURNERS) {
4690
5675
  if (node === void 0) return true;
4691
5676
  if (isCall(node)) return true;
@@ -4749,7 +5734,7 @@ function seedMcpToolParams(scope, tainted) {
4749
5734
  for (const name of boundNames(param)) tainted.add(name);
4750
5735
  }
4751
5736
  }
4752
- function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5737
+ function analyzeScope(scope, seed = [], returners = NO_RETURNERS, filterReturners = NO_RETURNERS, initialBuilt = NO_RETURNERS) {
4753
5738
  const tainted = new Set(seed);
4754
5739
  seedDestructuredRequestParams(scope, tainted);
4755
5740
  seedFrameworkParams(scope, tainted);
@@ -4757,6 +5742,8 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4757
5742
  const urlSanitized = /* @__PURE__ */ new Set();
4758
5743
  const xssSanitized = /* @__PURE__ */ new Set();
4759
5744
  const nosqlSanitized = /* @__PURE__ */ new Set();
5745
+ const sqlCleared = /* @__PURE__ */ new Set();
5746
+ const customPath = /* @__PURE__ */ new Set();
4760
5747
  let changed = true;
4761
5748
  while (changed) {
4762
5749
  changed = false;
@@ -4767,6 +5754,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4767
5754
  for (const name of boundNames(node.id)) nosqlSanitized.add(name);
4768
5755
  }
4769
5756
  if (isSchemaValidatorCall(init)) return;
5757
+ applyCustomCall(init, boundNames(node.id), tainted, sqlCleared, customPath);
4770
5758
  if (isUrlSanitizerCall(init)) {
4771
5759
  for (const name of boundNames(node.id)) urlSanitized.add(name);
4772
5760
  }
@@ -4787,6 +5775,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4787
5775
  for (const name of boundNames(node.left)) nosqlSanitized.add(name);
4788
5776
  }
4789
5777
  if (isSchemaValidatorCall(node.right)) return;
5778
+ applyCustomCall(node.right, boundNames(node.left), tainted, sqlCleared, customPath);
4790
5779
  if (isUrlSanitizerCall(node.right)) {
4791
5780
  for (const name of boundNames(node.left)) urlSanitized.add(name);
4792
5781
  }
@@ -4813,7 +5802,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4813
5802
  for (const name of boundNames(node.left)) resolved.add(name);
4814
5803
  }
4815
5804
  });
4816
- const pathSanitized = /* @__PURE__ */ new Set();
5805
+ const pathSanitized = new Set(customPath);
4817
5806
  walkScope(scope, (node) => {
4818
5807
  if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
4819
5808
  if (propName(node.callee) !== "startsWith") return;
@@ -4855,17 +5844,92 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
4855
5844
  for (const name of boundNames(node.left)) requestObjects.add(name);
4856
5845
  }
4857
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
+ }
4858
5873
  return {
4859
5874
  tainted,
4860
5875
  pathSanitized,
5876
+ sqlCleared,
4861
5877
  urlSanitized,
4862
5878
  xssSanitized,
4863
5879
  nosqlSanitized,
4864
5880
  hasPasswordName,
4865
5881
  requestObjects,
5882
+ builtFilters,
5883
+ filterReturners: new Set(filterReturners),
4866
5884
  returners: new Set(returners)
4867
5885
  };
4868
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
+ }
4869
5933
  function functionName(fn) {
4870
5934
  if (fn.type === "FunctionDeclaration" && fn.id !== null) return fn.id.name;
4871
5935
  if (isFunctionNode(fn) && fn.id !== null && fn.id.type === "Identifier") return fn.id.name;
@@ -4939,7 +6003,7 @@ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
4939
6003
  }
4940
6004
  return changed;
4941
6005
  }
4942
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
6006
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
4943
6007
  attachParents(ast);
4944
6008
  const facts = /* @__PURE__ */ new WeakMap();
4945
6009
  const functions = [];
@@ -4953,12 +6017,13 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4953
6017
  }
4954
6018
  const seeds = /* @__PURE__ */ new Map();
4955
6019
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
4956
- const returners = new Set(extraReturners);
6020
+ const returners = new Set(extraReturners2);
6021
+ const filterReturners = /* @__PURE__ */ new Set();
4957
6022
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
4958
6023
  let changed = false;
4959
- facts.set(ast, analyzeScope(ast, [], returners));
6024
+ facts.set(ast, analyzeScope(ast, [], returners, filterReturners));
4960
6025
  for (const fn of functions) {
4961
- facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners));
6026
+ facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners, filterReturners, inheritedBuiltFilters(fn, facts)));
4962
6027
  }
4963
6028
  for (const fn of functions) {
4964
6029
  const name = functionName(fn);
@@ -4968,6 +6033,10 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4968
6033
  returners.add(name);
4969
6034
  changed = true;
4970
6035
  }
6036
+ if (scopeReturnsBuiltFilter(fn, f) && !filterReturners.has(name)) {
6037
+ filterReturners.add(name);
6038
+ changed = true;
6039
+ }
4971
6040
  }
4972
6041
  for (const fn of functions) {
4973
6042
  const f = facts.get(fn);
@@ -5000,6 +6069,51 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5000
6069
  }
5001
6070
  return facts;
5002
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
+ }
5003
6117
  function functionsThatReturnTaint(ast) {
5004
6118
  attachParents(ast);
5005
6119
  const names = /* @__PURE__ */ new Set();
@@ -5021,18 +6135,50 @@ function factsFor(node, map) {
5021
6135
  return map.get(enclosingScope(node)) ?? {
5022
6136
  tainted: /* @__PURE__ */ new Set(),
5023
6137
  pathSanitized: /* @__PURE__ */ new Set(),
6138
+ sqlCleared: /* @__PURE__ */ new Set(),
5024
6139
  urlSanitized: /* @__PURE__ */ new Set(),
5025
6140
  xssSanitized: /* @__PURE__ */ new Set(),
5026
6141
  nosqlSanitized: /* @__PURE__ */ new Set(),
5027
6142
  hasPasswordName: false,
5028
6143
  requestObjects: /* @__PURE__ */ new Set(),
6144
+ builtFilters: /* @__PURE__ */ new Set(),
6145
+ filterReturners: /* @__PURE__ */ new Set(),
5029
6146
  returners: /* @__PURE__ */ new Set()
5030
6147
  };
5031
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
+ }
5032
6158
  function nodeIsTainted(node, map) {
5033
6159
  const facts = factsFor(node, map);
5034
6160
  return exprIsTainted(node, facts.tainted, facts.returners);
5035
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
+ }
5036
6182
  function nodeIsPathSanitized(node, map) {
5037
6183
  if (node.type !== "Identifier") return false;
5038
6184
  return factsFor(node, map).pathSanitized.has(node.name);
@@ -5054,7 +6200,7 @@ function nodeIsNosqlSanitized(node, map) {
5054
6200
  if (node.type === "Identifier") return factsFor(node, map).nosqlSanitized.has(node.name);
5055
6201
  return false;
5056
6202
  }
5057
- 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;
5058
6204
  var init_taint = __esm({
5059
6205
  "src/modules/sast/taint.ts"() {
5060
6206
  init_ast();
@@ -5076,6 +6222,157 @@ var init_taint = __esm({
5076
6222
  ]);
5077
6223
  taintedThisFields = /* @__PURE__ */ new WeakMap();
5078
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();
5079
6376
  }
5080
6377
  });
5081
6378
  function collectFunctions(file, ast) {
@@ -5186,22 +6483,22 @@ function collectInstances(ast, ns) {
5186
6483
  if (node.type !== "VariableDeclarator" || node.init === null) return;
5187
6484
  if (node.id.type !== "Identifier" || node.init.type !== "NewExpression") return;
5188
6485
  if (node.init.callee.type !== "Identifier") return;
5189
- const hit = ns.get(node.init.callee.name);
5190
- if (hit === void 0) return;
5191
- 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);
5192
6489
  });
5193
6490
  return instances;
5194
6491
  }
5195
6492
  function resolveCallee(node, ns, instances, currentFile, functions) {
5196
6493
  if (node.type !== "CallExpression") return void 0;
5197
6494
  if (node.callee.type === "Identifier") {
5198
- const hit = ns.get(node.callee.name);
5199
- if (hit !== void 0) {
5200
- if (hit.includes("#")) {
5201
- 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("#");
5202
6499
  if (file !== void 0 && name !== void 0) return { file, name };
5203
6500
  }
5204
- return { file: hit, name: node.callee.name };
6501
+ return { file: hit2, name: node.callee.name };
5205
6502
  }
5206
6503
  if (functions.has(`${currentFile}#${node.callee.name}`)) {
5207
6504
  return { file: currentFile, name: node.callee.name };
@@ -5308,9 +6605,9 @@ function runInterproc(parsed) {
5308
6605
  if (fn === void 0) continue;
5309
6606
  const param = fn.params[seed.paramIndex];
5310
6607
  if (param === void 0) continue;
5311
- const facts = analyzeFunctionWithSeed(fn.node, [param]);
5312
6608
  const p = byFile.get(fn.file);
5313
6609
  if (p === void 0) continue;
6610
+ const facts = taintSummariesEnabled() ? cachedFunctionFacts(p.source, fn.node, [param]) : analyzeFunctionWithSeed(fn.node, [param]);
5314
6611
  const ns = requires.get(fn.file) ?? /* @__PURE__ */ new Map();
5315
6612
  const inst = instances.get(fn.file) ?? /* @__PURE__ */ new Map();
5316
6613
  walk(fn.node, (node) => {
@@ -5404,6 +6701,7 @@ var init_interproc = __esm({
5404
6701
  init_finding();
5405
6702
  init_ast();
5406
6703
  init_experimental();
6704
+ init_summaries();
5407
6705
  init_taint();
5408
6706
  MAX_HOPS = 5;
5409
6707
  }
@@ -5775,6 +7073,184 @@ var init_jwt = __esm({
5775
7073
  }
5776
7074
  });
5777
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
+
5778
7254
  // src/modules/sast/rules/nosqli.ts
5779
7255
  function objectKey(prop) {
5780
7256
  if (prop.type !== "Property" || prop.key === void 0) return void 0;
@@ -5836,6 +7312,7 @@ function isNosqlSafeValue(node, source, isNosqlSanitized) {
5836
7312
  if (isNosqlSanitized(node) || isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) {
5837
7313
  return true;
5838
7314
  }
7315
+ if (isValidatedRouteField(node)) return true;
5839
7316
  return isSanitizeWrap(node, source);
5840
7317
  }
5841
7318
  function objectHasUnsafeTaintedValue(node, isTainted, isSafe) {
@@ -5857,6 +7334,7 @@ var init_nosqli = __esm({
5857
7334
  "src/modules/sast/rules/nosqli.ts"() {
5858
7335
  init_ast();
5859
7336
  init_taint();
7337
+ init_validation();
5860
7338
  MONGO_SINKS = /* @__PURE__ */ new Set([
5861
7339
  "find",
5862
7340
  "findOne",
@@ -5927,6 +7405,14 @@ var init_nosqli = __esm({
5927
7405
  return;
5928
7406
  }
5929
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
+ }
5930
7416
  if (objectHasUnsafeTaintedValue(filter, (n) => ctx.isTainted(n), isSafe)) {
5931
7417
  ctx.report({
5932
7418
  node,
@@ -6128,7 +7614,7 @@ var init_sqli = __esm({
6128
7614
  CallExpression(node) {
6129
7615
  if (node.type !== "CallExpression" || !isSqlSink2(node)) return;
6130
7616
  const sql = node.arguments[0];
6131
- if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql)) {
7617
+ if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql) && !ctx.isSqlCleared(sql)) {
6132
7618
  ctx.report({
6133
7619
  node,
6134
7620
  message: "Tainted input is concatenated into a SQL string passed to query/execute."
@@ -6292,27 +7778,27 @@ function numberLiteral(node) {
6292
7778
  return node.value;
6293
7779
  }
6294
7780
  function containsIdentMember(node, objectName) {
6295
- let hit = false;
7781
+ let hit2 = false;
6296
7782
  walk(node, (child) => {
6297
7783
  if (child.type === "MemberExpression" && child.object.type === "Identifier" && child.object.name === objectName) {
6298
- hit = true;
7784
+ hit2 = true;
6299
7785
  }
6300
7786
  });
6301
- return hit;
7787
+ return hit2;
6302
7788
  }
6303
7789
  function containsCallNamed(node, name) {
6304
- let hit = false;
7790
+ let hit2 = false;
6305
7791
  walk(node, (child) => {
6306
- if (child.type === "CallExpression" && calleeName(child) === name) hit = true;
7792
+ if (child.type === "CallExpression" && calleeName(child) === name) hit2 = true;
6307
7793
  });
6308
- return hit;
7794
+ return hit2;
6309
7795
  }
6310
7796
  function containsOriginalName(node) {
6311
- let hit = false;
7797
+ let hit2 = false;
6312
7798
  walk(node, (child) => {
6313
- if (child.type === "MemberExpression" && propName(child) === "originalname") hit = true;
7799
+ if (child.type === "MemberExpression" && propName(child) === "originalname") hit2 = true;
6314
7800
  });
6315
- return hit;
7801
+ return hit2;
6316
7802
  }
6317
7803
  function schemaLooksSafe(schema) {
6318
7804
  if (schema === void 0) return false;
@@ -6904,118 +8390,44 @@ var init_phase29 = __esm({
6904
8390
  gated(timingRule),
6905
8391
  gated(hashRule),
6906
8392
  gated(gqlRule)
6907
- ];
6908
- }
6909
- });
6910
-
6911
- // src/ai/injection.ts
6912
- function isCommentLine(line) {
6913
- const trimmed = line.trim();
6914
- return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
6915
- }
6916
- function scanPromptInjection(file, source) {
6917
- const findings = [];
6918
- const lines = source.split(/\r?\n/);
6919
- lines.forEach((line, index) => {
6920
- if (!isCommentLine(line)) return;
6921
- if (!PHRASES.some((p) => p.test(line))) return;
6922
- findings.push({
6923
- id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
6924
- ruleId: "AI-INJECT-001",
6925
- module: "sast",
6926
- title: "Prompt-injection comment aimed at AI reviewers",
6927
- severity: "medium",
6928
- confidence: "high",
6929
- file,
6930
- line: index + 1,
6931
- snippet: line.trim().slice(0, 120),
6932
- message: "Code contains text attempting to manipulate AI reviewers.",
6933
- remediation: "Remove comments that try to instruct an AI reviewer.",
6934
- references: ["AI-INJECT-001"],
6935
- source: "deterministic"
6936
- });
6937
- });
6938
- return findings;
6939
- }
6940
- var PHRASES;
6941
- var init_injection = __esm({
6942
- "src/ai/injection.ts"() {
6943
- init_finding();
6944
- PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
6945
- }
6946
- });
6947
- function packageVersion() {
6948
- let dir = dirname(fileURLToPath(import.meta.url));
6949
- for (let depth = 0; depth < 6; depth += 1) {
6950
- try {
6951
- const raw = readFileSync(join(dir, "package.json"), "utf8");
6952
- const pkg = JSON.parse(raw);
6953
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
6954
- return pkg.version;
6955
- }
6956
- } catch {
6957
- }
6958
- const parent = dirname(dir);
6959
- if (parent === dir) break;
6960
- dir = parent;
6961
- }
6962
- throw new Error("fullshield package.json not found");
6963
- }
6964
- var init_version = __esm({
6965
- "src/core/version.ts"() {
6966
- }
6967
- });
6968
- function parsePoolEnabled() {
6969
- return process.env.FULLSHIELD_PARSE_POOL === "1";
6970
- }
6971
- function parsePoolSize() {
6972
- const raw = process.env.FULLSHIELD_PARSE_WORKERS;
6973
- if (raw !== void 0 && raw !== "") {
6974
- const parsed = Number(raw);
6975
- if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
6976
- }
6977
- return Math.max(1, availableParallelism() - 1);
6978
- }
6979
- function parseCacheKey(code) {
6980
- return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
8393
+ ];
8394
+ }
8395
+ });
8396
+
8397
+ // src/ai/injection.ts
8398
+ function isCommentLine(line) {
8399
+ const trimmed = line.trim();
8400
+ return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
6981
8401
  }
6982
- function parseOne(code) {
6983
- const key = parseCacheKey(code);
6984
- const hit = cache.get(key);
6985
- if (hit !== void 0) return Promise.resolve(hit);
6986
- return new Promise((resolve8, reject) => {
6987
- const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
6988
- worker.once("message", (ast) => {
6989
- cache.set(key, ast);
6990
- resolve8(ast);
8402
+ function scanPromptInjection(file, source) {
8403
+ const findings = [];
8404
+ const lines = source.split(/\r?\n/);
8405
+ lines.forEach((line, index) => {
8406
+ if (!isCommentLine(line)) return;
8407
+ if (!PHRASES.some((p) => p.test(line))) return;
8408
+ findings.push({
8409
+ id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
8410
+ ruleId: "AI-INJECT-001",
8411
+ module: "sast",
8412
+ title: "Prompt-injection comment aimed at AI reviewers",
8413
+ severity: "medium",
8414
+ confidence: "high",
8415
+ file,
8416
+ line: index + 1,
8417
+ snippet: line.trim().slice(0, 120),
8418
+ message: "Code contains text attempting to manipulate AI reviewers.",
8419
+ remediation: "Remove comments that try to instruct an AI reviewer.",
8420
+ references: ["AI-INJECT-001"],
8421
+ source: "deterministic"
6991
8422
  });
6992
- worker.once("error", reject);
6993
8423
  });
8424
+ return findings;
6994
8425
  }
6995
- async function parseWithPool(codes) {
6996
- const size = parsePoolSize();
6997
- const out = new Array(codes.length);
6998
- let next = 0;
6999
- async function run() {
7000
- for (; ; ) {
7001
- const index = next;
7002
- next += 1;
7003
- if (index >= codes.length) return;
7004
- const code = codes[index];
7005
- if (code === void 0) return;
7006
- out[index] = await parseOne(code);
7007
- }
7008
- }
7009
- const workers = Math.min(size, Math.max(1, codes.length));
7010
- await Promise.all(Array.from({ length: workers }, () => run()));
7011
- return out;
7012
- }
7013
- var workerUrl, cache;
7014
- var init_parse_pool = __esm({
7015
- "src/core/parse-pool.ts"() {
7016
- init_version();
7017
- workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7018
- cache = /* @__PURE__ */ new Map();
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];
7019
8431
  }
7020
8432
  });
7021
8433
  async function parseSource(code) {
@@ -7033,8 +8445,8 @@ function snippetOf(source, node) {
7033
8445
  if (node.range === void 0) return "";
7034
8446
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7035
8447
  }
7036
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7037
- const facts = analyzeFile(ast, extraReturners);
8448
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
8449
+ const facts = analyzeFile(ast, extraReturners2);
7038
8450
  const findings = [];
7039
8451
  for (const rule of sastRules) {
7040
8452
  const ctx = {
@@ -7060,10 +8472,12 @@ function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */
7060
8472
  });
7061
8473
  },
7062
8474
  isTainted: (node) => nodeIsTainted(node, facts),
8475
+ isSqlCleared: (node) => nodeIsSqlCleared(node, facts),
7063
8476
  isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
7064
8477
  isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
7065
8478
  isNosqlSanitized: (node) => nodeIsNosqlSanitized(node, facts),
7066
8479
  isRequestObject: (node) => nodeIsRequestObject(node, facts),
8480
+ holdsBuiltFilter: (node) => nodeHoldsBuiltFilter(node, facts),
7067
8481
  functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
7068
8482
  assignedName: (node) => assignedNameOf(node),
7069
8483
  projectCsrfProtected
@@ -7101,10 +8515,12 @@ function findingsFromTemplate(filename, source) {
7101
8515
  });
7102
8516
  },
7103
8517
  isTainted: () => false,
8518
+ isSqlCleared: () => false,
7104
8519
  isPathSanitized: () => false,
7105
8520
  isSanitizedFor: () => false,
7106
8521
  isNosqlSanitized: () => false,
7107
8522
  isRequestObject: () => false,
8523
+ holdsBuiltFilter: () => false,
7108
8524
  functionHasPasswordName: () => false,
7109
8525
  assignedName: () => void 0,
7110
8526
  projectCsrfProtected: false
@@ -7112,12 +8528,165 @@ function findingsFromTemplate(filename, source) {
7112
8528
  xssRule.create(ctx);
7113
8529
  return findings;
7114
8530
  }
8531
+ function dedupeSastSinks(findings) {
8532
+ const seen = /* @__PURE__ */ new Set();
8533
+ const out = [];
8534
+ for (const finding6 of findings) {
8535
+ if (finding6.module !== "sast" || finding6.line === void 0) {
8536
+ out.push(finding6);
8537
+ continue;
8538
+ }
8539
+ const key = `${finding6.ruleId}\0${finding6.file ?? ""}\0${finding6.line}`;
8540
+ if (seen.has(key)) continue;
8541
+ seen.add(key);
8542
+ out.push(finding6);
8543
+ }
8544
+ return out;
8545
+ }
8546
+ function sastStreamThreshold() {
8547
+ const raw = process.env.FULLSHIELD_SAST_STREAM_AT;
8548
+ if (raw !== void 0 && raw !== "") {
8549
+ const parsed = Number(raw);
8550
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
8551
+ }
8552
+ return 4e3;
8553
+ }
8554
+ function sastBatchSize() {
8555
+ const raw = process.env.FULLSHIELD_SAST_BATCH;
8556
+ if (raw !== void 0 && raw !== "") {
8557
+ const parsed = Number(raw);
8558
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
8559
+ }
8560
+ return 150;
8561
+ }
8562
+ function skipFinding(rel, message) {
8563
+ return {
8564
+ id: makeFindingId("SAST-SKIP-001", rel, message),
8565
+ ruleId: "SAST-SKIP-001",
8566
+ module: "sast",
8567
+ title: "File skipped by SAST",
8568
+ severity: "info",
8569
+ confidence: "high",
8570
+ file: rel,
8571
+ message,
8572
+ remediation: "Raise sast.maxFileBytes in fullshield.config.json to parse this file, or leave it skipped."
8573
+ };
8574
+ }
8575
+ function skipReason(source, maxBytes) {
8576
+ if (source.includes("\0")) return "it contains NUL bytes";
8577
+ const bytes = Buffer.byteLength(source);
8578
+ if (bytes > maxBytes) return `${bytes} bytes exceeds the SAST parse limit of ${maxBytes} bytes`;
8579
+ return void 0;
8580
+ }
8581
+ async function readScanSource(cwd, rel) {
8582
+ try {
8583
+ return await readFile(join(cwd, rel), "utf8");
8584
+ } catch {
8585
+ return void 0;
8586
+ }
8587
+ }
8588
+ function cloneAst(value) {
8589
+ if (value === null || typeof value !== "object") return void 0;
8590
+ try {
8591
+ return structuredClone(value);
8592
+ } catch {
8593
+ return void 0;
8594
+ }
8595
+ }
8596
+ async function parseInProcess(source) {
8597
+ try {
8598
+ return await parseSource(source);
8599
+ } catch {
8600
+ return void 0;
8601
+ }
8602
+ }
8603
+ async function parseCode(source) {
8604
+ if (!parsePoolEnabled()) return parseInProcess(source);
8605
+ try {
8606
+ const cloned = cloneAst((await parseWithPool([source]))[0]);
8607
+ if (cloned !== void 0) return cloned;
8608
+ } catch {
8609
+ }
8610
+ return parseInProcess(source);
8611
+ }
8612
+ function releaseParsed(parsed) {
8613
+ for (const entry of parsed) {
8614
+ entry.source = "";
8615
+ entry.ast = void 0;
8616
+ }
8617
+ parsed.length = 0;
8618
+ }
8619
+ function extraReturners(rel, ast, files, returning) {
8620
+ const ns = collectRequires(rel, ast, files);
8621
+ const extra = new Set(returning.get(rel));
8622
+ for (const [local, spec] of ns) {
8623
+ const hash = spec.indexOf("#");
8624
+ const file = hash >= 0 ? spec.slice(0, hash) : spec;
8625
+ const exp = hash >= 0 ? spec.slice(hash + 1) : local;
8626
+ if (returning.get(file)?.has(exp)) extra.add(local);
8627
+ }
8628
+ return extra;
8629
+ }
8630
+ async function runSastStreaming(ctx) {
8631
+ const maxBytes = ctx.config.sast.maxFileBytes;
8632
+ const findings = [];
8633
+ const returning = /* @__PURE__ */ new Map();
8634
+ const parseable = [];
8635
+ const fileSet = /* @__PURE__ */ new Set();
8636
+ let projectCsrf = false;
8637
+ for (const rel of ctx.files) {
8638
+ const isSource = SOURCE_EXT.test(rel);
8639
+ const isTemplate = TEMPLATE_EXT.test(rel);
8640
+ if (!isSource && !isTemplate) continue;
8641
+ const source = await readScanSource(ctx.cwd, rel);
8642
+ if (source === void 0) continue;
8643
+ const why = skipReason(source, maxBytes);
8644
+ if (why !== void 0) {
8645
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
8646
+ continue;
8647
+ }
8648
+ if (sourceHasCsrfGuard(source)) projectCsrf = true;
8649
+ if (isTemplate) {
8650
+ findings.push(...findingsFromTemplate(rel, source));
8651
+ continue;
8652
+ }
8653
+ const ast = await parseCode(source);
8654
+ if (ast === void 0) continue;
8655
+ returning.set(rel, taintSummariesEnabled() ? cachedReturners(source, ast) : functionsThatReturnTaint(ast));
8656
+ parseable.push(rel);
8657
+ fileSet.add(rel);
8658
+ }
8659
+ for (const rel of parseable) {
8660
+ const source = await readScanSource(ctx.cwd, rel);
8661
+ if (source === void 0) continue;
8662
+ const ast = await parseCode(source);
8663
+ if (ast === void 0) continue;
8664
+ findings.push(
8665
+ ...findingsFromAst(rel, source, ast, extraReturners(rel, ast, fileSet, returning), projectCsrf)
8666
+ );
8667
+ }
8668
+ const batch = sastBatchSize();
8669
+ for (let i = 0; i < parseable.length; i += batch) {
8670
+ const parsed = [];
8671
+ for (const rel of parseable.slice(i, i + batch)) {
8672
+ const source = await readScanSource(ctx.cwd, rel);
8673
+ if (source === void 0) continue;
8674
+ const ast = await parseCode(source);
8675
+ if (ast === void 0) continue;
8676
+ parsed.push({ file: rel, source, ast });
8677
+ }
8678
+ findings.push(...runInterproc(parsed));
8679
+ releaseParsed(parsed);
8680
+ }
8681
+ return dedupeSastSinks(findings);
8682
+ }
7115
8683
  var sastRules, SOURCE_EXT, sastModule;
7116
8684
  var init_engine = __esm({
7117
8685
  "src/modules/sast/engine.ts"() {
7118
8686
  init_finding();
7119
8687
  init_ast();
7120
8688
  init_interproc();
8689
+ init_summaries();
7121
8690
  init_taint();
7122
8691
  init_auth();
7123
8692
  init_cmdi();
@@ -7153,49 +8722,69 @@ var init_engine = __esm({
7153
8722
  sastModule = {
7154
8723
  name: "sast",
7155
8724
  async run(ctx) {
7156
- const findings = [];
7157
- const parsed = [];
7158
- for (const rel of ctx.files) {
7159
- const isSource = SOURCE_EXT.test(rel);
7160
- const isTemplate = TEMPLATE_EXT.test(rel);
7161
- if (!isSource && !isTemplate) continue;
7162
- let source;
7163
- try {
7164
- source = await readFile(join(ctx.cwd, rel), "utf8");
7165
- } catch {
7166
- continue;
7167
- }
7168
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7169
- if (isTemplate) {
7170
- findings.push(...findingsFromTemplate(rel, source));
7171
- 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);
7172
8732
  }
7173
- try {
7174
- const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
8733
+ setCustomTaint({ sources, sanitizers });
8734
+ } else {
8735
+ setCustomTaint(void 0);
8736
+ }
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);
7175
8757
  if (ast === void 0) continue;
7176
8758
  parsed.push({ file: rel, source, ast });
7177
- } catch {
7178
8759
  }
7179
- }
7180
- const files = new Set(parsed.map((p) => p.file));
7181
- const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7182
- const returning = /* @__PURE__ */ new Map();
7183
- for (const p of parsed) {
7184
- returning.set(p.file, functionsThatReturnTaint(p.ast));
7185
- }
7186
- for (const p of parsed) {
7187
- const ns = collectRequires(p.file, p.ast, files);
7188
- const extra = new Set(returning.get(p.file));
7189
- for (const [local, spec] of ns) {
7190
- const hash = spec.indexOf("#");
7191
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7192
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7193
- if (returning.get(file)?.has(exp)) extra.add(local);
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(
8765
+ entry.file,
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
+ );
7194
8779
  }
7195
- findings.push(...findingsFromAst(p.file, p.source, p.ast, extra, projectCsrf));
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);
7196
8787
  }
7197
- findings.push(...runInterproc(parsed));
7198
- return findings;
7199
8788
  }
7200
8789
  };
7201
8790
  }
@@ -7579,7 +9168,7 @@ function scanTextForSecretHits(path, text) {
7579
9168
  return hits;
7580
9169
  }
7581
9170
  function scanTextForSecrets(path, text) {
7582
- return scanTextForSecretHits(path, text).map((hit) => hit.finding);
9171
+ return scanTextForSecretHits(path, text).map((hit2) => hit2.finding);
7583
9172
  }
7584
9173
  var secretsRules, REMEDIATION, MAX_FILE_SIZE, secretsModule;
7585
9174
  var init_secrets = __esm({
@@ -7935,12 +9524,12 @@ ${text}`);
7935
9524
  const seeds = fixtureLogic(prompt);
7936
9525
  const findings = [];
7937
9526
  for (const seed of seeds) {
7938
- 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(
7939
9528
  (f) => f.text.includes(seed.ruleId === "AI-IDOR-001" ? "findById(req.params.id)" : "Object.assign(user, req.body)")
7940
9529
  );
7941
- if (hit === void 0) continue;
7942
- const line = hit.text.split(/\r?\n/).findIndex((l) => l.includes(seed.ruleId === "AI-IDOR-001" ? "findById" : "Object.assign")) + 1;
7943
- 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));
7944
9533
  }
7945
9534
  return findings;
7946
9535
  }
@@ -8121,16 +9710,37 @@ var init_provider = __esm({
8121
9710
  FIXTURE_COST = 0.01;
8122
9711
  }
8123
9712
  });
9713
+ function isIsoDate(value) {
9714
+ if (!/^\d{4}-\d{2}-\d{2}(T[\d:.+\-Z]+)?$/.test(value)) return false;
9715
+ return Number.isFinite(Date.parse(value));
9716
+ }
9717
+ function expiresAtMs(expires) {
9718
+ if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) return Date.parse(`${expires}T23:59:59.999Z`);
9719
+ return Date.parse(expires);
9720
+ }
9721
+ function validSuppression(value) {
9722
+ if (typeof value !== "object" || value === null) return void 0;
9723
+ const row = value;
9724
+ if (typeof row.ruleId !== "string" || row.ruleId.length === 0) return void 0;
9725
+ if (typeof row.owner !== "string" || row.owner.length === 0) return void 0;
9726
+ if (typeof row.reason !== "string" || row.reason.length === 0) return void 0;
9727
+ if (typeof row.expires !== "string" || !isIsoDate(row.expires)) return void 0;
9728
+ const file = row.file === void 0 ? void 0 : row.file.replace(/\\/g, "/");
9729
+ if (row.file !== void 0 && (typeof row.file !== "string" || row.file.length === 0)) return void 0;
9730
+ return { ruleId: row.ruleId, file, owner: row.owner, reason: row.reason, expires: row.expires };
9731
+ }
8124
9732
  async function readBaseline(path) {
8125
9733
  try {
8126
9734
  const raw = JSON.parse(await readFile(path, "utf8"));
8127
9735
  if (raw.version !== 1 || !Array.isArray(raw.findings)) return void 0;
9736
+ if (raw.suppressions !== void 0 && !Array.isArray(raw.suppressions)) return void 0;
8128
9737
  return raw;
8129
9738
  } catch {
8130
9739
  return void 0;
8131
9740
  }
8132
9741
  }
8133
9742
  async function writeBaseline(path, findings) {
9743
+ const existing = await readBaseline(path);
8134
9744
  const doc = {
8135
9745
  version: 1,
8136
9746
  findings: findings.map((f) => ({
@@ -8138,7 +9748,8 @@ async function writeBaseline(path, findings) {
8138
9748
  ruleId: f.ruleId,
8139
9749
  file: f.file,
8140
9750
  line: f.line
8141
- }))
9751
+ })),
9752
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
8142
9753
  };
8143
9754
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
8144
9755
  `, "utf8");
@@ -8148,10 +9759,68 @@ function applyBaseline(findings, baseline) {
8148
9759
  const known = new Set(baseline.findings.map((f) => f.id));
8149
9760
  return findings.filter((f) => !known.has(f.id));
8150
9761
  }
9762
+ function applySuppressions(findings, rows, now = /* @__PURE__ */ new Date()) {
9763
+ if (rows === void 0 || rows.length === 0) return { findings: [...findings], errors: [] };
9764
+ const errors = [];
9765
+ const active = [];
9766
+ rows.forEach((row, index) => {
9767
+ const parsed = validSuppression(row);
9768
+ if (parsed === void 0) {
9769
+ errors.push(`suppression ${index + 1} needs ruleId, owner, reason, and an ISO expires date`);
9770
+ return;
9771
+ }
9772
+ if (expiresAtMs(parsed.expires) < now.getTime()) {
9773
+ errors.push(
9774
+ `suppression for ${parsed.ruleId} expired on ${parsed.expires} (owner: ${parsed.owner}); renew or remove it`
9775
+ );
9776
+ return;
9777
+ }
9778
+ active.push(parsed);
9779
+ });
9780
+ const kept = findings.filter((finding6) => {
9781
+ const file = (finding6.file ?? "").replace(/\\/g, "/");
9782
+ return !active.some((row) => row.ruleId === finding6.ruleId && (row.file === void 0 || row.file === file));
9783
+ });
9784
+ return { findings: kept, errors };
9785
+ }
8151
9786
  var init_baseline = __esm({
8152
9787
  "src/core/baseline.ts"() {
8153
9788
  }
8154
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
+ }
8155
9824
  function validate(raw, source) {
8156
9825
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
8157
9826
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -8253,6 +9922,42 @@ function validate(raw, source) {
8253
9922
  }
8254
9923
  out.ai = ai;
8255
9924
  }
9925
+ if (record.defaultExcludes !== void 0) {
9926
+ if (typeof record.defaultExcludes !== "boolean") {
9927
+ throw new ConfigError(`${source}: "defaultExcludes" must be a boolean`);
9928
+ }
9929
+ out.defaultExcludes = record.defaultExcludes;
9930
+ }
9931
+ if (record.excludes !== void 0) {
9932
+ if (!Array.isArray(record.excludes) || record.excludes.some((p) => typeof p !== "string")) {
9933
+ throw new ConfigError(`${source}: "excludes" must be an array of glob strings`);
9934
+ }
9935
+ out.excludes = record.excludes;
9936
+ }
9937
+ if (record.sast !== void 0) {
9938
+ if (typeof record.sast !== "object" || record.sast === null || Array.isArray(record.sast)) {
9939
+ throw new ConfigError(`${source}: "sast" must be an object`);
9940
+ }
9941
+ const rawSast = record.sast;
9942
+ const sast = { maxFileBytes: 1024 * 1024 };
9943
+ if (rawSast.maxFileBytes !== void 0) {
9944
+ if (typeof rawSast.maxFileBytes !== "number" || rawSast.maxFileBytes < 1) {
9945
+ throw new ConfigError(`${source}: "sast.maxFileBytes" must be a positive number`);
9946
+ }
9947
+ sast.maxFileBytes = rawSast.maxFileBytes;
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
+ }
9959
+ out.sast = sast;
9960
+ }
8256
9961
  return out;
8257
9962
  }
8258
9963
  async function loadConfig(cwd, configPath) {
@@ -8278,7 +9983,7 @@ async function loadConfig(cwd, configPath) {
8278
9983
  }
8279
9984
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
8280
9985
  }
8281
- var CONFIG_FILE_NAME, DEFAULT_CONFIG, ConfigError, KNOWN_KEYS;
9986
+ var CONFIG_FILE_NAME, DEFAULT_CONFIG, ConfigError, KNOWN_KEYS, SAST_CLASSES;
8282
9987
  var init_config2 = __esm({
8283
9988
  "src/core/config.ts"() {
8284
9989
  init_finding();
@@ -8287,19 +9992,51 @@ var init_config2 = __esm({
8287
9992
  modules: [...MODULE_NAMES],
8288
9993
  failOn: "high",
8289
9994
  ignore: ["dist/**", "**/*.test.ts"],
9995
+ defaultExcludes: true,
8290
9996
  baseline: ".fullshield-baseline.json",
8291
9997
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
8292
9998
  deps: { offline: false },
8293
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
9999
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
10000
+ sast: { maxFileBytes: 1024 * 1024 }
8294
10001
  };
8295
10002
  ConfigError = class extends Error {
8296
10003
  };
8297
- KNOWN_KEYS = /* @__PURE__ */ new Set(["modules", "failOn", "ignore", "baseline", "notify", "deps", "ai"]);
10004
+ KNOWN_KEYS = /* @__PURE__ */ new Set([
10005
+ "modules",
10006
+ "failOn",
10007
+ "ignore",
10008
+ "defaultExcludes",
10009
+ "excludes",
10010
+ "baseline",
10011
+ "notify",
10012
+ "deps",
10013
+ "ai",
10014
+ "sast"
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
+ ]);
8298
10032
  }
8299
10033
  });
8300
10034
  async function walkFiles(options) {
8301
10035
  const ig = ignoreFactory();
8302
- ig.add(ALWAYS_IGNORED);
10036
+ ig.add(HARD_IGNORED);
10037
+ if (options.defaultExcludes !== false) {
10038
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
10039
+ }
8303
10040
  try {
8304
10041
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
8305
10042
  } catch {
@@ -8325,11 +10062,24 @@ async function walkFiles(options) {
8325
10062
  await walk5("");
8326
10063
  return files.sort();
8327
10064
  }
8328
- var ignoreFactory, ALWAYS_IGNORED;
10065
+ var ignoreFactory, HARD_IGNORED, DEFAULT_EXCLUDES;
8329
10066
  var init_fileWalker = __esm({
8330
10067
  "src/core/fileWalker.ts"() {
8331
10068
  ignoreFactory = ignoreModule.default;
8332
- ALWAYS_IGNORED = [".git/", "node_modules/", ".fullshield/"];
10069
+ HARD_IGNORED = [".git/", ".fullshield/"];
10070
+ DEFAULT_EXCLUDES = [
10071
+ "node_modules/",
10072
+ "dist/",
10073
+ "build/",
10074
+ ".next/",
10075
+ "coverage/",
10076
+ "vendor/",
10077
+ "vendored/",
10078
+ "third_party/",
10079
+ "third-party/",
10080
+ "compiled/",
10081
+ "*.min.js"
10082
+ ];
8333
10083
  }
8334
10084
  });
8335
10085
  async function listStagedFiles(cwd) {
@@ -8543,7 +10293,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8543
10293
  return "*";
8544
10294
  }
8545
10295
  }
8546
- function expiresAtMs(expires) {
10296
+ function expiresAtMs2(expires) {
8547
10297
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8548
10298
  return Date.parse(`${expires}T23:59:59.999Z`);
8549
10299
  }
@@ -8565,7 +10315,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8565
10315
  });
8566
10316
  const suppressed = /* @__PURE__ */ new Set();
8567
10317
  for (const exception of policy.exceptions ?? []) {
8568
- if (expiresAtMs(exception.expires) < now.getTime()) {
10318
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8569
10319
  errors.push(
8570
10320
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8571
10321
  );
@@ -8634,6 +10384,7 @@ var init_incremental = __esm({
8634
10384
  // src/modules/secrets/history.ts
8635
10385
  var history_exports = {};
8636
10386
  __export(history_exports, {
10387
+ historyCommitLimit: () => historyCommitLimit,
8637
10388
  scanGitHistory: () => scanGitHistory
8638
10389
  });
8639
10390
  function gitError(error) {
@@ -8667,16 +10418,22 @@ async function readBlob(cwd, hash) {
8667
10418
  return void 0;
8668
10419
  }
8669
10420
  }
8670
- function remapHit(hit, file) {
8671
- const snippet = hit.finding.snippet;
8672
- const line = hit.finding.line ?? 0;
8673
- 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"}`);
8674
10425
  return {
8675
- finding: { ...hit.finding, id, file },
8676
- secret: hit.secret
10426
+ finding: { ...hit2.finding, id, file },
10427
+ secret: hit2.secret
8677
10428
  };
8678
10429
  }
8679
- async function streamLogChanges(cwd) {
10430
+ function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
10431
+ if (raw === void 0 || raw.trim() === "") return 500;
10432
+ const parsed = Number(raw);
10433
+ if (!Number.isInteger(parsed) || parsed < 1) return 500;
10434
+ return Math.min(parsed, 5e3);
10435
+ }
10436
+ async function streamLogChanges(cwd, maxCommits) {
8680
10437
  const changes = [];
8681
10438
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
8682
10439
  const child = spawn(
@@ -8693,6 +10450,8 @@ async function streamLogChanges(cwd) {
8693
10450
  "--format=FSCOMMIT:%H",
8694
10451
  "--no-textconv",
8695
10452
  "--no-ext-diff",
10453
+ "-n",
10454
+ String(maxCommits),
8696
10455
  "HEAD"
8697
10456
  ],
8698
10457
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -8755,14 +10514,20 @@ async function headSecretKeys(cwd) {
8755
10514
  hits = scanTextForSecretHits(path, text);
8756
10515
  seen.set(hash, hits);
8757
10516
  }
8758
- for (const hit of hits) {
8759
- 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 ?? ""}`);
8760
10519
  }
8761
10520
  }
8762
10521
  return keys;
8763
10522
  }
8764
- async function scanGitHistory(cwd) {
8765
- const changes = await streamLogChanges(cwd);
10523
+ async function commitCount(cwd) {
10524
+ const raw = await gitOk(cwd, ["rev-list", "--count", "HEAD"]);
10525
+ const total = Number(raw.trim());
10526
+ return Number.isInteger(total) && total >= 0 ? total : 0;
10527
+ }
10528
+ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
10529
+ const total = await commitCount(cwd);
10530
+ const changes = await streamLogChanges(cwd, maxCommits);
8766
10531
  const blobHits = /* @__PURE__ */ new Map();
8767
10532
  const first = /* @__PURE__ */ new Map();
8768
10533
  for (const change of changes) {
@@ -8783,8 +10548,8 @@ async function scanGitHistory(cwd) {
8783
10548
  hits2 = rawHits.filter((h) => allowed.has(h.finding.id));
8784
10549
  blobHits.set(change.hash, hits2);
8785
10550
  }
8786
- for (const hit of hits2) {
8787
- const mapped = remapHit(hit, change.path);
10551
+ for (const hit2 of hits2) {
10552
+ const mapped = remapHit(hit2, change.path);
8788
10553
  if (!first.has(mapped.finding.id)) {
8789
10554
  first.set(mapped.finding.id, { hit: mapped, commit: change.commit });
8790
10555
  }
@@ -8792,14 +10557,20 @@ async function scanGitHistory(cwd) {
8792
10557
  }
8793
10558
  const atHead = await headSecretKeys(cwd);
8794
10559
  const hits = [];
8795
- for (const { hit, commit } of first.values()) {
8796
- 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 ?? ""}`);
8797
10562
  hits.push({
8798
- finding: { ...hit.finding, introducedIn: commit, stillAtHead },
8799
- secret: hit.secret
10563
+ finding: { ...hit2.finding, introducedIn: commit, stillAtHead },
10564
+ secret: hit2.secret
8800
10565
  });
8801
10566
  }
8802
- return { findings: hits.map((h) => h.finding), hits };
10567
+ const scanned = Math.min(maxCommits, total);
10568
+ const history = total > maxCommits ? `scanned ${scanned} of ${total} commits` : void 0;
10569
+ return {
10570
+ findings: hits.map((h) => h.finding),
10571
+ hits,
10572
+ ...history !== void 0 ? { history } : {}
10573
+ };
8803
10574
  }
8804
10575
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
8805
10576
  var init_history = __esm({
@@ -9032,15 +10803,15 @@ function providerFor(ruleId, secret) {
9032
10803
  async function verifySecretHits(hits, options = {}) {
9033
10804
  const fetchImpl = options.fetchImpl ?? fetch;
9034
10805
  const out = [];
9035
- for (const hit of hits) {
9036
- const secret = hit.secret;
10806
+ for (const hit2 of hits) {
10807
+ const secret = hit2.secret;
9037
10808
  if (secret === void 0 || secret.length === 0) {
9038
- out.push({ finding: { ...hit.finding, verification: "unknown" }, secret: hit.secret });
10809
+ out.push({ finding: { ...hit2.finding, verification: "unknown" }, secret: hit2.secret });
9039
10810
  continue;
9040
10811
  }
9041
- const run = providerFor(hit.finding.ruleId, secret);
10812
+ const run = providerFor(hit2.finding.ruleId, secret);
9042
10813
  const verification = run === void 0 ? "unknown" : await run(secret, fetchImpl);
9043
- out.push({ finding: { ...hit.finding, verification }, secret: hit.secret });
10814
+ out.push({ finding: { ...hit2.finding, verification }, secret: hit2.secret });
9044
10815
  }
9045
10816
  return out;
9046
10817
  }
@@ -9073,6 +10844,8 @@ function dedupe2(findings) {
9073
10844
  async function runScan(options = {}, modules = builtinModules) {
9074
10845
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9075
10846
  const t0 = performance.now();
10847
+ depsModule.unusedDirect = void 0;
10848
+ depsModule.upgradePlan = void 0;
9076
10849
  const cwd = resolve(options.cwd ?? process.cwd());
9077
10850
  const stats = await stat(cwd).catch(() => null);
9078
10851
  if (stats === null || !stats.isDirectory()) {
@@ -9094,7 +10867,12 @@ async function runScan(options = {}, modules = builtinModules) {
9094
10867
  options.modules ?? config.modules,
9095
10868
  loadedPolicy?.requiredModules
9096
10869
  );
9097
- let files = await walkFiles({ cwd, ignore: config.ignore });
10870
+ let files = await walkFiles({
10871
+ cwd,
10872
+ ignore: config.ignore,
10873
+ defaultExcludes: config.defaultExcludes,
10874
+ excludes: config.excludes
10875
+ });
9098
10876
  if (options.staged === true) {
9099
10877
  const staged = new Set(await listStagedFiles(cwd));
9100
10878
  files = files.filter((file) => staged.has(file));
@@ -9128,6 +10906,7 @@ async function runScan(options = {}, modules = builtinModules) {
9128
10906
  );
9129
10907
  }
9130
10908
  const extraHits = [];
10909
+ let historyNotice;
9131
10910
  const secretsWanted = modulesRequested.includes("secrets");
9132
10911
  if (secretsAdvEnabled() && secretsWanted) {
9133
10912
  if (options.history === true) {
@@ -9135,6 +10914,7 @@ async function runScan(options = {}, modules = builtinModules) {
9135
10914
  const hist = await scanGitHistory2(cwd);
9136
10915
  extraHits.push(...hist.hits);
9137
10916
  collected.push(...hist.findings);
10917
+ historyNotice = hist.history;
9138
10918
  }
9139
10919
  if (options.bundle !== void 0 && options.bundle.length > 0) {
9140
10920
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -9155,13 +10935,17 @@ async function runScan(options = {}, modules = builtinModules) {
9155
10935
  if (options.updateBaseline === true) {
9156
10936
  await writeBaseline(baselinePath, unsuppressed);
9157
10937
  }
9158
- let findings = sortFindings(applyBaseline(unsuppressed, await readBaseline(baselinePath)));
10938
+ const baselineDoc = await readBaseline(baselinePath);
10939
+ let findings = sortFindings(applyBaseline(unsuppressed, baselineDoc));
10940
+ const suppressed = applySuppressions(findings, baselineDoc?.suppressions);
10941
+ findings = sortFindings(suppressed.findings);
10942
+ const suppressionErrors = suppressed.errors;
9159
10943
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
9160
10944
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
9161
10945
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
9162
10946
  const secretById = /* @__PURE__ */ new Map();
9163
- for (const hit of extraHits) {
9164
- 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);
9165
10949
  }
9166
10950
  for (const finding6 of findings) {
9167
10951
  if (finding6.module !== "secrets" || finding6.file === void 0 || secretById.has(finding6.id)) {
@@ -9175,8 +10959,8 @@ async function runScan(options = {}, modules = builtinModules) {
9175
10959
  continue;
9176
10960
  }
9177
10961
  }
9178
- for (const hit of scanTextForSecretHits2(finding6.file, text)) {
9179
- 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);
9180
10964
  }
9181
10965
  }
9182
10966
  findings = sortFindings(
@@ -9227,10 +11011,14 @@ async function runScan(options = {}, modules = builtinModules) {
9227
11011
  cwd,
9228
11012
  filesScanned: files.length,
9229
11013
  modulesRequested: [...modulesRequested],
9230
- modulesRun
11014
+ modulesRun,
11015
+ ...historyNotice !== void 0 ? { history: historyNotice } : {},
11016
+ ...depsModule.unusedDirect !== void 0 ? { unusedDirect: depsModule.unusedDirect } : {},
11017
+ ...depsModule.upgradePlan !== void 0 ? { upgradePlan: depsModule.upgradePlan } : {}
9231
11018
  },
9232
11019
  config,
9233
- policy: policyResult
11020
+ policy: policyResult,
11021
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
9234
11022
  };
9235
11023
  }
9236
11024
  function scan(options = {}) {
@@ -9371,6 +11159,16 @@ function validateCycloneDx15(doc) {
9371
11159
  if (!Array.isArray(root.components)) errors.push("components must be an array");
9372
11160
  return errors;
9373
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
+ }
9374
11172
  function renderCycloneDx(input) {
9375
11173
  const vulns = (input.findings ?? []).filter((f) => f.ruleId === "DEPS-VULN-001");
9376
11174
  return {
@@ -9381,13 +11179,18 @@ function renderCycloneDx(input) {
9381
11179
  metadata: {
9382
11180
  component: { type: "application", name: input.name, version: input.version }
9383
11181
  },
9384
- components: input.components.map((c) => ({
9385
- type: "library",
9386
- name: c.name,
9387
- version: c.version,
9388
- purl: c.purl ?? `pkg:npm/${encodeURIComponent(c.name)}@${c.version}`,
9389
- ...c.license !== void 0 ? { licenses: [{ license: { id: c.license } }] } : {}
9390
- })),
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
+ }),
9391
11194
  vulnerabilities: vulns.map((f) => ({
9392
11195
  id: f.references?.find((r) => r.startsWith("GHSA-") || r.startsWith("CVE-")) ?? f.id,
9393
11196
  description: f.message,
@@ -9546,6 +11349,32 @@ function finding4(ruleId, title, message, file, severity = "high") {
9546
11349
  remediation: "Pin actions by SHA, drop pull_request_target, tighten permissions, close 0.0.0.0/0, encrypt storage, drop privileged."
9547
11350
  };
9548
11351
  }
11352
+ function runBodies(text) {
11353
+ const bodies = [];
11354
+ const lines = text.split(/\r?\n/);
11355
+ for (let i = 0; i < lines.length; i += 1) {
11356
+ const match = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(lines[i] ?? "");
11357
+ if (match === null) continue;
11358
+ const indent = match[1]?.length ?? 0;
11359
+ let body = match[2] ?? "";
11360
+ if (body === "|" || body === ">") {
11361
+ const block = [];
11362
+ for (let j = i + 1; j < lines.length; j += 1) {
11363
+ const line = lines[j] ?? "";
11364
+ if (line.trim() === "") {
11365
+ block.push(line);
11366
+ continue;
11367
+ }
11368
+ const lead = line.match(/^\s*/)?.[0].length ?? 0;
11369
+ if (lead <= indent) break;
11370
+ block.push(line);
11371
+ }
11372
+ body = block.join("\n");
11373
+ }
11374
+ bodies.push(body);
11375
+ }
11376
+ return bodies;
11377
+ }
9549
11378
  function walk3(dir, out = []) {
9550
11379
  if (!existsSync(dir)) return out;
9551
11380
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -9573,6 +11402,28 @@ function scanIac(dir, opts = {}) {
9573
11402
  if (/pull_request_target/.test(text)) {
9574
11403
  findings.push(finding4("IAC-GHA-PRTARGET-001", "pull_request_target workflow", "untrusted PR can run with base secrets", rel, "critical"));
9575
11404
  }
11405
+ if (/pull_request_target/.test(text) && /actions\/checkout/.test(text) && /github\.event\.pull_request/.test(text)) {
11406
+ findings.push(
11407
+ finding4(
11408
+ "IAC-GHA-PRCHECKOUT-001",
11409
+ "pull_request_target checks out pull-request code",
11410
+ "untrusted head can run with the base repository token",
11411
+ rel,
11412
+ "critical"
11413
+ )
11414
+ );
11415
+ }
11416
+ if (runBodies(text).some((body) => /\$\{\{\s*github\.event\./.test(body))) {
11417
+ findings.push(
11418
+ finding4(
11419
+ "IAC-GHA-INJECT-001",
11420
+ "Workflow run step interpolates github.event",
11421
+ "untrusted event text is passed to the shell",
11422
+ rel,
11423
+ "critical"
11424
+ )
11425
+ );
11426
+ }
9576
11427
  if (/permissions:\s*\n\s+contents:\s*write/.test(text) || /permissions:\s+write-all/.test(text)) {
9577
11428
  findings.push(finding4("IAC-GHA-PERMS-001", "Overly broad GitHub Actions permissions", "write-all or contents: write", rel));
9578
11429
  }
@@ -9723,14 +11574,22 @@ var init_compliance = __esm({
9723
11574
  ROW("SUPPLY-BEHAVIOR-001", "A06:2021 Vulnerable Components", "CWE-829", "V14.2.1", "PCI DSS 6.3.3"),
9724
11575
  ROW("SUPPLY-COOLDOWN-001", "A06:2021 Vulnerable Components", "CWE-1104", "V14.2.1", "PCI DSS 6.3.3"),
9725
11576
  ROW("SUPPLY-LOCKURL-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
11577
+ ROW("SUPPLY-LOCKINTEGRITY-001", "A08:2021 Integrity Failures", "CWE-353", "V14.2.3", "PCI DSS 6.3.3"),
9726
11578
  ROW("SUPPLY-CONFUSION-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9727
11579
  ROW("SUPPLY-PROVENANCE-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9728
11580
  ROW("SUPPLY-WORM-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3 / 12.10"),
9729
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"),
9730
11586
  ROW("IAC-GHA-UNPINNED-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9731
11587
  ROW("IAC-GHA-PRTARGET-001", "A01:2021 Broken Access Control", "CWE-269", "V4.1.1", "PCI DSS 7.2"),
9732
11588
  ROW("IAC-GHA-PERMS-001", "A01:2021 Broken Access Control", "CWE-250", "V4.1.1", "PCI DSS 7.2"),
9733
11589
  ROW("IAC-GHA-SECRETLOG-001", "A09:2021 Security Logging Failures", "CWE-532", "V7.1.1", "PCI DSS 3.2 / 10.2"),
11590
+ ROW("IAC-GHA-INJECT-001", "A03:2021 Injection", "CWE-94", "V5.2.4", "PCI DSS 6.2.4"),
11591
+ ROW("IAC-GHA-PRCHECKOUT-001", "A01:2021 Broken Access Control", "CWE-829", "V4.1.1", "PCI DSS 6.2.4"),
11592
+ ROW("DEPS-LICENSE-001", "A06:2021 Vulnerable Components", "CWE-1104", "V14.2.1", "PCI DSS 6.3.3"),
9734
11593
  ROW("IAC-TF-S3PUBLIC-001", "A01:2021 Broken Access Control", "CWE-284", "V12.1.1", "PCI DSS 1.3 / 3.2"),
9735
11594
  ROW("IAC-TF-SGOPEN-001", "A05:2021 Security Misconfiguration", "CWE-284", "V1.4.1", "PCI DSS 1.2 / 1.3"),
9736
11595
  ROW("IAC-TF-UNENCRYPTED-001", "A02:2021 Cryptographic Failures", "CWE-311", "V6.2.1", "PCI DSS 3.4 / 4.2"),
@@ -9757,6 +11616,7 @@ var init_compliance = __esm({
9757
11616
  ROW("SAST-TIMING-001", "A07:2021 Identification Failures", "CWE-208", "V2.10.3", "PCI DSS 8.3"),
9758
11617
  ROW("SAST-HASH-001", "A02:2021 Cryptographic Failures", "CWE-916", "V6.2.2", "PCI DSS 8.3.1"),
9759
11618
  ROW("SAST-GQL-001", "A04:2021 Insecure Design", "CWE-770/200", "V13.1.1", "PCI DSS 6.2.4"),
11619
+ ROW("SAST-SKIP-001", "A05:2021 Security Misconfiguration", "CWE-693", "V1.1.1", "PCI DSS 6.2.4"),
9760
11620
  ROW("CONFIG-TRUSTPROXY-001", "A07:2021 Identification Failures", "CWE-290", "V2.2.1", "PCI DSS 8.3.4"),
9761
11621
  ROW("SECRET-BUNDLE-001", "A02:2021 Cryptographic Failures", "CWE-798", "V6.4.1", "PCI DSS 3.2 / 8.3"),
9762
11622
  ROW("DLP-SECRET-001", "A02:2021 Cryptographic Failures", "CWE-200", "V8.3.4", "PCI DSS 3.2 / 8.3"),
@@ -9776,6 +11636,23 @@ var init_compliance = __esm({
9776
11636
  byId = new Map(RULE_MAP.map((r) => [r.ruleId, r]));
9777
11637
  }
9778
11638
  });
11639
+ function nodeSqliteAvailable() {
11640
+ try {
11641
+ require2("node:sqlite");
11642
+ return true;
11643
+ } catch {
11644
+ return false;
11645
+ }
11646
+ }
11647
+ function nodeSqliteUnavailableMessage(feature) {
11648
+ return `${feature} requires Node 22.13+ (node:sqlite is not available on Node ${process.versions.node})`;
11649
+ }
11650
+ var require2;
11651
+ var init_sqlite = __esm({
11652
+ "src/core/sqlite.ts"() {
11653
+ require2 = createRequire(import.meta.url);
11654
+ }
11655
+ });
9779
11656
 
9780
11657
  // src/dashboard/index.ts
9781
11658
  var dashboard_exports = {};
@@ -9789,7 +11666,7 @@ __export(dashboard_exports, {
9789
11666
  startDashboard: () => startDashboard
9790
11667
  });
9791
11668
  function sqliteDb(file) {
9792
- const { DatabaseSync } = require2("node:sqlite");
11669
+ const { DatabaseSync } = require3("node:sqlite");
9793
11670
  let db;
9794
11671
  try {
9795
11672
  db = new DatabaseSync(file);
@@ -9840,6 +11717,9 @@ function sqliteDb(file) {
9840
11717
  };
9841
11718
  }
9842
11719
  function openHistory(dir) {
11720
+ if (!nodeSqliteAvailable()) {
11721
+ throw new Error(nodeSqliteUnavailableMessage("history"));
11722
+ }
9843
11723
  const root = join(dir, ".fullshield");
9844
11724
  mkdirSync(root, { recursive: true });
9845
11725
  return sqliteDb(join(root, "history.db"));
@@ -9941,10 +11821,11 @@ function startDashboard(opts) {
9941
11821
  });
9942
11822
  });
9943
11823
  }
9944
- var require2;
11824
+ var require3;
9945
11825
  var init_dashboard = __esm({
9946
11826
  "src/dashboard/index.ts"() {
9947
- require2 = createRequire(import.meta.url);
11827
+ init_sqlite();
11828
+ require3 = createRequire(import.meta.url);
9948
11829
  }
9949
11830
  });
9950
11831
  function finding5(ruleId, title, message, file, severity = "high") {
@@ -10086,9 +11967,9 @@ async function callMcpTool(session, name, args = {}) {
10086
11967
  return { findings: session.findings };
10087
11968
  }
10088
11969
  if (name === "explain_finding") {
10089
- const hit = session.findings.find((f) => f.ruleId === args.ruleId);
10090
- if (!hit) return { text: `no finding ${args.ruleId}` };
10091
- 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}` };
10092
11973
  }
10093
11974
  if (name === "propose_fix") {
10094
11975
  return proposeOnly(session, args.ruleId);
@@ -10099,25 +11980,25 @@ async function callMcpTool(session, name, args = {}) {
10099
11980
  return { error: `unknown tool ${name}` };
10100
11981
  }
10101
11982
  function proposeOnly(session, ruleId) {
10102
- const hit = session.findings.find((f) => f.ruleId === ruleId);
10103
- if (!hit?.file || !session.cwd) return { diff: "", wrote: false };
10104
- 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);
10105
11986
  const source = readFileSync(abs, "utf8");
10106
- const proposal = proposeFix(hit, source);
11987
+ const proposal = proposeFix(hit2, source);
10107
11988
  return { diff: proposal?.diff ?? "", wrote: false };
10108
11989
  }
10109
11990
  function verifyInTemp(session, ruleId) {
10110
- const hit = session.findings.find((f) => f.ruleId === ruleId);
10111
- if (!hit?.file || !session.cwd) return { originalUntouched: true, tempApplied: false };
10112
- 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);
10113
11994
  const before = readFileSync(original, "utf8");
10114
11995
  const mtime = statSync(original).mtimeMs;
10115
- const proposal = proposeFix(hit, before);
11996
+ const proposal = proposeFix(hit2, before);
10116
11997
  if (!proposal?.diff) return { originalUntouched: true, tempApplied: false };
10117
11998
  const tmp = mkdtempSync(join(tmpdir(), "fullshield-mcp-verify-"));
10118
11999
  try {
10119
12000
  cpSync(session.cwd, tmp, { recursive: true });
10120
- const copy = join(tmp, hit.file);
12001
+ const copy = join(tmp, hit2.file);
10121
12002
  const next = applyUnifiedDiff(readFileSync(copy, "utf8"), proposal.diff);
10122
12003
  writeFileSync(copy, next);
10123
12004
  const untouched = readFileSync(original, "utf8") === before && statSync(original).mtimeMs === mtime;
@@ -10341,16 +12222,16 @@ async function confirmTarget(baseUrl, opts) {
10341
12222
  try {
10342
12223
  if (route.kind === "idor") {
10343
12224
  const url2 = `${origin}${route.path}`;
10344
- const hit2 = await probe(fetchImpl, url2, route.method, void 0, {
12225
+ const hit3 = await probe(fetchImpl, url2, route.method, void 0, {
10345
12226
  "x-user": route.asUser ?? opts.users?.b ?? "bob"
10346
12227
  });
10347
12228
  const owner = route.expectOwner ?? opts.users?.a ?? "alice";
10348
- const confirmed2 = hit2.ok && hit2.text.includes(owner);
12229
+ const confirmed2 = hit3.ok && hit3.text.includes(owner);
10349
12230
  confirmations.push({
10350
12231
  kind: "idor",
10351
12232
  status: confirmed2 ? "confirmed" : "unconfirmed",
10352
12233
  proof: curlProof(route.method, url2, `-H 'x-user: ${route.asUser ?? "bob"}' `),
10353
- detail: hit2.text.slice(0, 200)
12234
+ detail: hit3.text.slice(0, 200)
10354
12235
  });
10355
12236
  continue;
10356
12237
  }
@@ -10363,13 +12244,13 @@ async function confirmTarget(baseUrl, opts) {
10363
12244
  } else {
10364
12245
  body = { [param]: payload };
10365
12246
  }
10366
- const hit = await probe(fetchImpl, url, route.method, body);
10367
- 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");
10368
12249
  confirmations.push({
10369
12250
  kind: route.kind,
10370
12251
  status: confirmed ? "confirmed" : "unconfirmed",
10371
12252
  proof: curlProof(route.method, url),
10372
- detail: hit.text.slice(0, 200)
12253
+ detail: hit2.text.slice(0, 200)
10373
12254
  });
10374
12255
  } catch (err) {
10375
12256
  confirmations.push({
@@ -10647,14 +12528,16 @@ function openVexStatement(finding6) {
10647
12528
  vulnerability: { name: vulnName(finding6) },
10648
12529
  products: [{ "@id": npmPurl(finding6.snippet) }]
10649
12530
  };
12531
+ const detail = process.env.FULLSHIELD_EXPERIMENTAL_SBOM_REACH === "1" && finding6.reachabilityEvidence?.reason !== void 0 ? { detail: finding6.reachabilityEvidence.reason } : {};
10650
12532
  if (notImported(finding6)) {
10651
12533
  return {
10652
12534
  ...base,
10653
12535
  status: "not_affected",
10654
- justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION
12536
+ justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION,
12537
+ ...detail
10655
12538
  };
10656
12539
  }
10657
- return { ...base, status: "under_investigation" };
12540
+ return { ...base, status: "under_investigation", ...detail };
10658
12541
  }
10659
12542
  function buildOpenVex(findings) {
10660
12543
  const statements = depFindings(findings).map(openVexStatement);
@@ -10942,6 +12825,28 @@ async function runFix(options = {}) {
10942
12825
  const after = applyUnifiedDiff(source, proposed.diff);
10943
12826
  byFile.set(sourceFile, after);
10944
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
+ }
10945
12850
  const notice = appliedOverride ? "run npm install" : void 0;
10946
12851
  if (options.apply !== true) {
10947
12852
  return { applied: false, reverted: false, patches, findings: result.findings, notice };
@@ -10989,6 +12894,29 @@ async function restoreSnapshots(cwd, snapshots) {
10989
12894
  }
10990
12895
  }
10991
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
+ }
10992
12920
  function runProjectTests(cwd) {
10993
12921
  let pkg;
10994
12922
  try {
@@ -11169,6 +13097,153 @@ ${target.message}`
11169
13097
  // src/cli/program.ts
11170
13098
  init_version();
11171
13099
 
13100
+ // src/core/fingerprint.ts
13101
+ init_finding();
13102
+ function lineIndependentFingerprint(finding6) {
13103
+ const file = (finding6.file ?? "").replace(/\\/g, "/");
13104
+ const text = (finding6.snippet ?? finding6.message ?? "").replace(/\s+/g, " ").trim();
13105
+ return makeFindingId(finding6.ruleId, file, `${finding6.severity}\0${text}`);
13106
+ }
13107
+
13108
+ // src/reporters/ci.ts
13109
+ init_finding();
13110
+
13111
+ // src/reporters/redact.ts
13112
+ init_mask();
13113
+ var SECRET = /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk_live_[A-Za-z0-9]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
13114
+ function redactSecrets2(text) {
13115
+ return text.replace(SECRET, (match) => maskSecret(match));
13116
+ }
13117
+ function redactFinding(finding6) {
13118
+ return {
13119
+ ...finding6,
13120
+ title: redactSecrets2(finding6.title),
13121
+ message: redactSecrets2(finding6.message),
13122
+ remediation: redactSecrets2(finding6.remediation),
13123
+ snippet: finding6.snippet === void 0 ? void 0 : redactSecrets2(finding6.snippet)
13124
+ };
13125
+ }
13126
+ function redactScan(result) {
13127
+ return { ...result, findings: result.findings.map(redactFinding) };
13128
+ }
13129
+
13130
+ // src/reporters/ci.ts
13131
+ var MESSAGE_CAP = 4e3;
13132
+ function posix2(file) {
13133
+ return file.replace(/\\/g, "/");
13134
+ }
13135
+ function clip(text) {
13136
+ return text.length <= MESSAGE_CAP ? text : `${text.slice(0, MESSAGE_CAP)}\u2026`;
13137
+ }
13138
+ function githubEscape(value, property) {
13139
+ let out = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A");
13140
+ if (property) out = out.replace(/,/g, "%2C");
13141
+ return out;
13142
+ }
13143
+ function githubLevel(severity) {
13144
+ if (severity === "critical" || severity === "high") return "error";
13145
+ if (severity === "medium") return "warning";
13146
+ return "notice";
13147
+ }
13148
+ function gitlabSeverity(severity) {
13149
+ if (severity === "info") return "info";
13150
+ if (severity === "low") return "minor";
13151
+ if (severity === "medium") return "major";
13152
+ if (severity === "high") return "critical";
13153
+ return "blocker";
13154
+ }
13155
+ function xmlEscape(value) {
13156
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13157
+ }
13158
+ function oneLine(value) {
13159
+ return value.replace(/\s+/g, " ").trim();
13160
+ }
13161
+ function renderGithubAnnotations(input) {
13162
+ const result = redactScan(input);
13163
+ if (result.findings.length === 0) {
13164
+ return "::notice title=FullShield::No findings\n";
13165
+ }
13166
+ const lines = result.findings.map((finding6) => {
13167
+ const props = [];
13168
+ if (finding6.file !== void 0) props.push(`file=${githubEscape(posix2(finding6.file), true)}`);
13169
+ if (finding6.line !== void 0) props.push(`line=${finding6.line}`);
13170
+ if (finding6.column !== void 0) props.push(`col=${finding6.column + 1}`);
13171
+ props.push(`title=${githubEscape(finding6.ruleId, true)}`);
13172
+ const message = githubEscape(clip(finding6.message), false);
13173
+ return `::${githubLevel(finding6.severity)} ${props.join(",")}::${message}`;
13174
+ });
13175
+ return `${lines.join("\n")}
13176
+ `;
13177
+ }
13178
+ function junitCase(finding6) {
13179
+ const file = finding6.file !== void 0 ? posix2(finding6.file) : "fullshield";
13180
+ const attrs = [
13181
+ `name="${xmlEscape(finding6.ruleId)}"`,
13182
+ `classname="${xmlEscape(file)}"`,
13183
+ `file="${xmlEscape(file)}"`
13184
+ ];
13185
+ if (finding6.line !== void 0) attrs.push(`line="${finding6.line}"`);
13186
+ const open = `<testcase ${attrs.join(" ")}>`;
13187
+ if (!meetsThreshold(finding6.severity, "medium")) {
13188
+ return `${open}<skipped message="${xmlEscape(oneLine(clip(finding6.message)))}"/></testcase>`;
13189
+ }
13190
+ const detail = clip(`${finding6.message}${finding6.remediation.length > 0 ? `
13191
+ Fix: ${finding6.remediation}` : ""}`);
13192
+ return `${open}<failure type="${finding6.severity}" message="${xmlEscape(oneLine(clip(finding6.message)))}">${xmlEscape(detail)}</failure></testcase>`;
13193
+ }
13194
+ function renderJunitReport(input) {
13195
+ const result = redactScan(input);
13196
+ const header = '<?xml version="1.0" encoding="UTF-8"?>';
13197
+ if (result.findings.length === 0) {
13198
+ return `${header}
13199
+ <testsuites name="fullshield" tests="1" failures="0" skipped="0"><testsuite name="fullshield" tests="1" failures="0" skipped="0"><testcase name="scan" classname="fullshield"/></testsuite></testsuites>
13200
+ `;
13201
+ }
13202
+ const groups = /* @__PURE__ */ new Map();
13203
+ for (const finding6 of result.findings) {
13204
+ const list = groups.get(finding6.module) ?? [];
13205
+ list.push(finding6);
13206
+ groups.set(finding6.module, list);
13207
+ }
13208
+ let failures = 0;
13209
+ let skipped = 0;
13210
+ const suites = [];
13211
+ for (const [name, findings] of groups) {
13212
+ let suiteFailures = 0;
13213
+ let suiteSkipped = 0;
13214
+ const cases = findings.map((finding6) => {
13215
+ if (meetsThreshold(finding6.severity, "medium")) suiteFailures += 1;
13216
+ else suiteSkipped += 1;
13217
+ return junitCase(finding6);
13218
+ });
13219
+ failures += suiteFailures;
13220
+ skipped += suiteSkipped;
13221
+ suites.push(
13222
+ `<testsuite name="${xmlEscape(name)}" tests="${findings.length}" failures="${suiteFailures}" skipped="${suiteSkipped}">${cases.join("")}</testsuite>`
13223
+ );
13224
+ }
13225
+ return `${header}
13226
+ <testsuites name="fullshield" tests="${result.findings.length}" failures="${failures}" skipped="${skipped}">${suites.join("")}</testsuites>
13227
+ `;
13228
+ }
13229
+ function renderGitlabCodeQuality(input) {
13230
+ const result = redactScan(input);
13231
+ const report = result.findings.map((finding6) => {
13232
+ const path = finding6.file !== void 0 ? posix2(finding6.file) : ".";
13233
+ const location4 = { path };
13234
+ if (finding6.line !== void 0) location4.lines = { begin: finding6.line };
13235
+ return {
13236
+ description: clip(finding6.message),
13237
+ check_name: finding6.ruleId,
13238
+ fingerprint: lineIndependentFingerprint(finding6),
13239
+ severity: gitlabSeverity(finding6.severity),
13240
+ location: location4
13241
+ };
13242
+ });
13243
+ return `${JSON.stringify(report, null, 2)}
13244
+ `;
13245
+ }
13246
+
11172
13247
  // src/reporters/console.ts
11173
13248
  init_finding();
11174
13249
  var SEVERITY_COLOR = {
@@ -11210,7 +13285,160 @@ function renderFinding(finding6) {
11210
13285
  }
11211
13286
  return lines;
11212
13287
  }
11213
- function renderConsoleReport(result) {
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 = {}) {
13441
+ const result = redactScan(input);
11214
13442
  const { findings, summary, metadata } = result;
11215
13443
  const lines = [];
11216
13444
  lines.push(
@@ -11218,34 +13446,64 @@ function renderConsoleReport(result) {
11218
13446
  ` \u2014 scanned ${metadata.filesScanned} files in ${metadata.durationMs}ms (modules: ${metadata.modulesRun.length > 0 ? metadata.modulesRun.join(", ") : "none"})`
11219
13447
  )
11220
13448
  );
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
+ }
11221
13462
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
11222
13463
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
11223
13464
  lines.push(
11224
13465
  pc.dim(`note: requested modules not implemented yet: ${pending.join(", ")}`)
11225
13466
  );
11226
13467
  }
11227
- const bySeverityDesc = [...SEVERITIES].reverse();
11228
- const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
11229
- const shown = ordered.slice(0, 10);
11230
- const hidden = ordered.length - shown.length;
11231
- for (const severity of bySeverityDesc) {
11232
- const group = shown.filter((f) => f.severity === severity);
11233
- if (group.length === 0) continue;
11234
- lines.push("");
11235
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
11236
- for (const finding6 of group) {
11237
- lines.push(...renderFinding(finding6));
11238
- 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;
11239
13489
  }
13490
+ lines.push(...renderRow(row));
13491
+ }
13492
+ if (hiddenActionable > 0) {
13493
+ lines.push("");
13494
+ lines.push(`${hiddenActionable} more actionable \u2014 use --json`);
11240
13495
  }
11241
- if (hidden > 0) {
13496
+ if (!showAll && folded.length > 0) {
11242
13497
  lines.push("");
11243
- 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
+ );
11244
13501
  }
11245
13502
  lines.push("");
11246
13503
  if (summary.total === 0) {
11247
13504
  lines.push(pc.green("\u2714 No findings."));
11248
13505
  } else {
13506
+ const bySeverityDesc = [...SEVERITIES].reverse();
11249
13507
  const counts = bySeverityDesc.filter((s) => summary.bySeverity[s] > 0).map((s) => `${summary.bySeverity[s]} ${s}`).join(", ");
11250
13508
  lines.push(pc.red(`\u2716 ${summary.total} finding(s): ${counts}`));
11251
13509
  }
@@ -11261,7 +13519,8 @@ function location2(finding6) {
11261
13519
  if (finding6.file === void 0) return "(project)";
11262
13520
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
11263
13521
  }
11264
- function renderHtmlReport(result) {
13522
+ function renderHtmlReport(input) {
13523
+ const result = redactScan(input);
11265
13524
  const sections = [];
11266
13525
  const bySeverityDesc = [...SEVERITIES].reverse();
11267
13526
  for (const severity of bySeverityDesc) {
@@ -11308,7 +13567,7 @@ ${items}`);
11308
13567
 
11309
13568
  // src/reporters/json.ts
11310
13569
  function publicFinding(finding6) {
11311
- const copy = { ...finding6 };
13570
+ const copy = redactFinding(finding6);
11312
13571
  delete copy.aiVerdict;
11313
13572
  delete copy.exploitScenario;
11314
13573
  delete copy.aiReasoning;
@@ -11332,7 +13591,8 @@ function location3(finding6) {
11332
13591
  if (finding6.file === void 0) return "(project)";
11333
13592
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
11334
13593
  }
11335
- function renderMarkdownReport(result) {
13594
+ function renderMarkdownReport(input) {
13595
+ const result = redactScan(input);
11336
13596
  const lines = [
11337
13597
  "# FullShield report",
11338
13598
  "",
@@ -11373,8 +13633,6 @@ function renderMarkdownReport(result) {
11373
13633
  }
11374
13634
  return lines.join("\n");
11375
13635
  }
11376
-
11377
- // src/reporters/sarif.ts
11378
13636
  var SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json";
11379
13637
  function sarifLevel(severity) {
11380
13638
  if (severity === "critical" || severity === "high") return "error";
@@ -11384,14 +13642,37 @@ function sarifLevel(severity) {
11384
13642
  function uri(file) {
11385
13643
  return (file ?? "").replace(/\\/g, "/");
11386
13644
  }
11387
- function renderSarifReport(result) {
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
+ }
13663
+ function renderSarifReport(input) {
13664
+ const result = redactScan(input);
13665
+ const v2 = sarifV2();
11388
13666
  const rules = /* @__PURE__ */ new Map();
11389
13667
  for (const finding6 of result.findings) {
11390
13668
  if (!rules.has(finding6.ruleId)) {
13669
+ const markdown = v2 ? ruleMarkdown(finding6.ruleId) : void 0;
11391
13670
  rules.set(finding6.ruleId, {
11392
13671
  id: finding6.ruleId,
11393
13672
  name: finding6.ruleId,
11394
- shortDescription: { text: finding6.title }
13673
+ shortDescription: { text: finding6.title },
13674
+ ...markdown !== void 0 ? { help: { markdown } } : {},
13675
+ ...v2 ? { properties: { precision: "unreviewed" } } : {}
11395
13676
  });
11396
13677
  }
11397
13678
  }
@@ -11420,6 +13701,7 @@ function renderSarifReport(result) {
11420
13701
  }
11421
13702
  }
11422
13703
  ] : [];
13704
+ const related = v2 ? relatedLocations(finding6) : void 0;
11423
13705
  const codeFlows = finding6.trace !== void 0 && finding6.trace.length > 0 ? [
11424
13706
  {
11425
13707
  threadFlows: [
@@ -11442,7 +13724,12 @@ function renderSarifReport(result) {
11442
13724
  level: sarifLevel(finding6.severity),
11443
13725
  message: { text: finding6.message },
11444
13726
  locations: location4,
11445
- ...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
+ } : {}
11446
13733
  };
11447
13734
  })
11448
13735
  }
@@ -11451,16 +13738,285 @@ function renderSarifReport(result) {
11451
13738
  return JSON.stringify(doc, null, 2);
11452
13739
  }
11453
13740
 
13741
+ // src/diff/index.ts
13742
+ init_finding();
13743
+ init_experimental();
13744
+ function posix3(file) {
13745
+ return (file ?? "").replace(/\\/g, "/");
13746
+ }
13747
+ function diffKey(finding6) {
13748
+ return lineIndependentFingerprint(finding6);
13749
+ }
13750
+ function groupById(findings) {
13751
+ const groups = /* @__PURE__ */ new Map();
13752
+ for (const finding6 of findings) {
13753
+ if (finding6.id === void 0 || finding6.id.length === 0) continue;
13754
+ const list = groups.get(finding6.id) ?? [];
13755
+ list.push(finding6);
13756
+ groups.set(finding6.id, list);
13757
+ }
13758
+ return groups;
13759
+ }
13760
+ function pairMoves(removed, added) {
13761
+ const removedIds = groupById(removed);
13762
+ const addedIds = groupById(added);
13763
+ const moved = [];
13764
+ for (const [id, group] of removedIds) {
13765
+ const match = addedIds.get(id);
13766
+ if (group.length === 1 && match?.length === 1 && group[0] !== void 0 && match[0] !== void 0) {
13767
+ moved.push({ before: group[0], after: match[0] });
13768
+ }
13769
+ }
13770
+ return moved;
13771
+ }
13772
+ function byLocation(a, b) {
13773
+ return (a.line ?? 0) - (b.line ?? 0) || a.id.localeCompare(b.id) || a.ruleId.localeCompare(b.ruleId);
13774
+ }
13775
+ function groupFindings(findings) {
13776
+ const groups = /* @__PURE__ */ new Map();
13777
+ for (const finding6 of findings) {
13778
+ const key = diffKey(finding6);
13779
+ const list = groups.get(key) ?? [];
13780
+ list.push(finding6);
13781
+ groups.set(key, list);
13782
+ }
13783
+ return groups;
13784
+ }
13785
+ function diffScans(before, after) {
13786
+ const beforeGroups = groupFindings(before);
13787
+ const afterGroups = groupFindings(after);
13788
+ const unchanged = [];
13789
+ const added = [];
13790
+ const removed = [];
13791
+ const moved = [];
13792
+ const keys = [];
13793
+ for (const key of beforeGroups.keys()) keys.push(key);
13794
+ for (const key of afterGroups.keys()) {
13795
+ if (!beforeGroups.has(key)) keys.push(key);
13796
+ }
13797
+ for (const key of keys) {
13798
+ const left = [...beforeGroups.get(key) ?? []].sort(byLocation);
13799
+ const right = [...afterGroups.get(key) ?? []].sort(byLocation);
13800
+ const paired = Math.min(left.length, right.length);
13801
+ for (let i = 0; i < paired; i += 1) {
13802
+ const earlier = left[i];
13803
+ const later = right[i];
13804
+ if (earlier === void 0 || later === void 0) continue;
13805
+ if ((earlier.line ?? 0) === (later.line ?? 0)) unchanged.push(later);
13806
+ else moved.push({ before: earlier, after: later });
13807
+ }
13808
+ for (const finding6 of left.slice(paired)) removed.push(finding6);
13809
+ for (const finding6 of right.slice(paired)) added.push(finding6);
13810
+ }
13811
+ const idMoves = pairMoves(removed, added);
13812
+ const movedBefore = new Set(idMoves.map((item) => item.before));
13813
+ const movedAfter = new Set(idMoves.map((item) => item.after));
13814
+ return {
13815
+ unchanged,
13816
+ added: added.filter((finding6) => !movedAfter.has(finding6)),
13817
+ removed: removed.filter((finding6) => !movedBefore.has(finding6)),
13818
+ moved: [...moved, ...idMoves]
13819
+ };
13820
+ }
13821
+ function parseScanDocument(text) {
13822
+ let parsed;
13823
+ try {
13824
+ parsed = JSON.parse(text);
13825
+ } catch {
13826
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
13827
+ }
13828
+ const list = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings) ? parsed.findings : void 0;
13829
+ if (list === void 0) {
13830
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
13831
+ }
13832
+ return list.map((item) => {
13833
+ if (typeof item !== "object" || item === null) {
13834
+ throw new Error("scan JSON finding is missing ruleId or severity");
13835
+ }
13836
+ const record = item;
13837
+ const ruleId = record.ruleId;
13838
+ const severity = record.severity;
13839
+ if (typeof ruleId !== "string" || ruleId.length === 0 || typeof severity !== "string") {
13840
+ throw new Error("scan JSON finding is missing ruleId or severity");
13841
+ }
13842
+ if (!isSeverity(severity)) {
13843
+ throw new Error(`scan JSON finding has an unknown severity "${severity}"`);
13844
+ }
13845
+ if (record.line !== void 0 && (typeof record.line !== "number" || !Number.isInteger(record.line) || record.line < 0)) {
13846
+ throw new Error("scan JSON finding line must be an integer >= 0");
13847
+ }
13848
+ return item;
13849
+ });
13850
+ }
13851
+ function addedFails(diff, failOn) {
13852
+ return diff.added.some((finding6) => finding6.source !== "ai" && meetsThreshold(finding6.severity, failOn));
13853
+ }
13854
+ var ADDED_CAP = 20;
13855
+ function addedLine(finding6) {
13856
+ const where = finding6.file !== void 0 ? ` ${posix3(finding6.file)}:${finding6.line ?? 0}` : "";
13857
+ return `${finding6.severity} ${finding6.ruleId}${where}`;
13858
+ }
13859
+ function renderDiffText(diff) {
13860
+ const lines = [
13861
+ `fullshield diff: unchanged ${diff.unchanged.length}, added ${diff.added.length}, removed ${diff.removed.length}, moved ${diff.moved.length}`
13862
+ ];
13863
+ if (diff.added.length === 0) return lines.join("\n");
13864
+ lines.push("added:");
13865
+ for (const finding6 of diff.added.slice(0, ADDED_CAP)) lines.push(addedLine(finding6));
13866
+ if (diff.added.length > ADDED_CAP) lines.push(`${diff.added.length - ADDED_CAP} more \u2014 use --json`);
13867
+ return lines.join("\n");
13868
+ }
13869
+
11454
13870
  // src/cli/program.ts
13871
+ init_experimental();
11455
13872
  init_policy();
11456
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
+
11457
14013
  // src/sbom/index.ts
11458
14014
  init_engine2();
11459
14015
  init_lockfile();
11460
14016
  init_pins();
11461
14017
  init_cyclonedx();
11462
14018
  var LOCKFILE_NAMES2 = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
11463
- async function buildSbom(cwd) {
14019
+ async function buildSbom(cwd, options = {}) {
11464
14020
  const result = await scan({ cwd, modules: ["deps"] });
11465
14021
  let name = "app";
11466
14022
  let version = "0.0.0";
@@ -11486,7 +14042,13 @@ async function buildSbom(cwd) {
11486
14042
  if (components.length === 0) {
11487
14043
  components = exactPinsFromManifest(manifest).map((p) => ({ name: p.name, version: p.version }));
11488
14044
  }
11489
- 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
+ });
11490
14052
  }
11491
14053
 
11492
14054
  // src/cli/program.ts
@@ -12139,6 +14701,9 @@ var FLAGS = [
12139
14701
  "FULLSHIELD_EXPERIMENTAL_POLICY",
12140
14702
  "FULLSHIELD_EXPERIMENTAL_HOST",
12141
14703
  "FULLSHIELD_EXPERIMENTAL_TAINT_V2",
14704
+ "FULLSHIELD_EXPERIMENTAL_CI_REPORTS",
14705
+ "FULLSHIELD_EXPERIMENTAL_DIFF",
14706
+ "FULLSHIELD_EXPERIMENTAL_LICENSE",
12142
14707
  "FULLSHIELD_PARSE_POOL"
12143
14708
  ];
12144
14709
  function nodeOk(version) {
@@ -12200,6 +14765,31 @@ var KNOWN = [
12200
14765
  test: /cannot read config/,
12201
14766
  code: "FS-E004",
12202
14767
  hint: "Check the path passed to --config."
14768
+ },
14769
+ {
14770
+ test: /FULLSHIELD_EXPERIMENTAL_CI_REPORTS/,
14771
+ code: "FS-E005",
14772
+ hint: "Set FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1 or pass --experimental-ci-reports."
14773
+ },
14774
+ {
14775
+ test: /FULLSHIELD_EXPERIMENTAL_DIFF|scan JSON|cannot read diff/,
14776
+ code: "FS-E006",
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."
12203
14793
  }
12204
14794
  ];
12205
14795
  var DOCS = "https://github.com/kathanp/fullshield/blob/main/docs/errors.md";
@@ -12251,7 +14841,16 @@ function parseFailOn(value) {
12251
14841
  function createProgram(io = defaultIO) {
12252
14842
  const program = new Command();
12253
14843
  program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion()).option("--debug", "print stack traces on CLI errors");
12254
- 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("--update-baseline", "record current findings as accepted (CI fails only on new ones)").option("--ai [mode]", "experimental: explain findings (off = zero AI network calls)").option("--emit-virtual-patches [file]", "experimental: write .fullshield/vpatches.json").option("--changed", "experimental: scan git staged/changed files only").option("--since <ref>", "experimental: scan files changed since a git ref").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(
14845
+ "--experimental-ci-reports",
14846
+ "experimental: GitHub annotations, JUnit, and GitLab Code Quality (FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
14847
+ ).option(
14848
+ "--github-annotations <file>",
14849
+ "experimental: GitHub Actions workflow commands (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
14850
+ ).option("--junit <file>", "experimental: JUnit XML (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)").option(
14851
+ "--gitlab <file>",
14852
+ "experimental: GitLab Code Quality JSON (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
14853
+ ).option("--update-baseline", "record current findings as accepted (CI fails only on new ones)").option("--ai [mode]", "experimental: explain findings (off = zero AI network calls)").option("--emit-virtual-patches [file]", "experimental: write .fullshield/vpatches.json").option("--changed", "experimental: scan git staged/changed files only").option("--since <ref>", "experimental: scan files changed since a git ref").option(
12255
14854
  "--experimental-sast-gaps",
12256
14855
  "experimental: ORM raw / sh -c / execa / zx sinks (FULLSHIELD_EXPERIMENTAL_SAST_GAPS=1)"
12257
14856
  ).option(
@@ -12276,6 +14875,27 @@ function createProgram(io = defaultIO) {
12276
14875
  "--experimental-vex",
12277
14876
  "experimental: worm/workflow indicators, overrides, provenance, VEX (FULLSHIELD_EXPERIMENTAL_VEX=1)"
12278
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(
12279
14899
  "--experimental-mcpcfg",
12280
14900
  "experimental: MCP client/server config rules (FULLSHIELD_EXPERIMENTAL_MCPCFG=1)"
12281
14901
  ).option(
@@ -12304,12 +14924,45 @@ function createProgram(io = defaultIO) {
12304
14924
  if (opts.experimentalVex === true) {
12305
14925
  process.env.FULLSHIELD_EXPERIMENTAL_VEX = "1";
12306
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
+ }
12307
14951
  if (opts.experimentalMcpcfg === true) {
12308
14952
  process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG = "1";
12309
14953
  }
12310
14954
  if (opts.prSummary === true && process.env[POLICY_FLAG] !== "1") {
12311
14955
  throw new Error(`--pr-summary requires ${POLICY_FLAG}=1`);
12312
14956
  }
14957
+ if (opts.experimentalCiReports === true) {
14958
+ process.env[CI_REPORTS_FLAG] = "1";
14959
+ }
14960
+ const wantsCiReport = opts.githubAnnotations !== void 0 || opts.junit !== void 0 || opts.gitlab !== void 0;
14961
+ if (wantsCiReport && process.env[CI_REPORTS_FLAG] !== "1") {
14962
+ throw new Error(
14963
+ `--github-annotations, --junit, and --gitlab require ${CI_REPORTS_FLAG}=1`
14964
+ );
14965
+ }
12313
14966
  const result = await scan({
12314
14967
  cwd: resolve(path),
12315
14968
  modules,
@@ -12337,6 +14990,15 @@ function createProgram(io = defaultIO) {
12337
14990
  if (opts.html !== void 0) {
12338
14991
  await writeFile(resolve(opts.html), renderHtmlReport(result), "utf8");
12339
14992
  }
14993
+ if (opts.githubAnnotations !== void 0) {
14994
+ await writeFile(resolve(opts.githubAnnotations), renderGithubAnnotations(result), "utf8");
14995
+ }
14996
+ if (opts.junit !== void 0) {
14997
+ await writeFile(resolve(opts.junit), renderJunitReport(result), "utf8");
14998
+ }
14999
+ if (opts.gitlab !== void 0) {
15000
+ await writeFile(resolve(opts.gitlab), renderGitlabCodeQuality(result), "utf8");
15001
+ }
12340
15002
  await writeLastScan(resolve(path), result);
12341
15003
  if (opts.emitVirtualPatches !== void 0) {
12342
15004
  if (process.env.FULLSHIELD_EXPERIMENTAL_VPATCH !== "1") {
@@ -12350,7 +15012,9 @@ function createProgram(io = defaultIO) {
12350
15012
  if (opts.prSummary === true) {
12351
15013
  io.out(renderPrSummary(result.findings));
12352
15014
  } else {
12353
- 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
+ );
12354
15018
  }
12355
15019
  if (result.policy !== void 0 && result.policy.errors.length > 0) {
12356
15020
  for (const message of result.policy.errors) {
@@ -12358,6 +15022,12 @@ function createProgram(io = defaultIO) {
12358
15022
  }
12359
15023
  process.exitCode = 1;
12360
15024
  }
15025
+ if (result.suppressionErrors !== void 0 && result.suppressionErrors.length > 0) {
15026
+ for (const message of result.suppressionErrors) {
15027
+ io.err(`fullshield: ${message}`);
15028
+ }
15029
+ process.exitCode = 1;
15030
+ }
12361
15031
  const failOn = failOnOverride ?? result.policy?.failOn ?? result.config.failOn;
12362
15032
  if (result.findings.some((f) => failsCi(f, failOn))) {
12363
15033
  process.exitCode = 1;
@@ -12459,7 +15129,9 @@ function createProgram(io = defaultIO) {
12459
15129
  if (opts.format !== void 0 && opts.format !== "cyclonedx") {
12460
15130
  throw new Error(`unsupported SBOM format "${opts.format}" (valid: cyclonedx)`);
12461
15131
  }
12462
- const doc = await buildSbom(resolve(path));
15132
+ const doc = await buildSbom(resolve(path), {
15133
+ includeReach: process.env.FULLSHIELD_EXPERIMENTAL_SBOM_REACH === "1"
15134
+ });
12463
15135
  const errors = validateCycloneDx15(doc);
12464
15136
  if (errors.length > 0) throw new Error(`invalid SBOM: ${errors.join("; ")}`);
12465
15137
  const json = JSON.stringify(doc, null, 2);
@@ -12613,6 +15285,87 @@ function createProgram(io = defaultIO) {
12613
15285
  process.exitCode = 2;
12614
15286
  }
12615
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
+ });
12616
15369
  program.command("confirm").description("Experimental exploit confirm (localhost/127.0.0.1/FullShield container only)").argument("<url>", "target base URL").action(async (url) => {
12617
15370
  try {
12618
15371
  if (process.env.FULLSHIELD_EXPERIMENTAL_CONFIRM !== "1") {
@@ -12748,8 +15501,8 @@ function createProgram(io = defaultIO) {
12748
15501
  let name = "app";
12749
15502
  let version = "0.0.0";
12750
15503
  try {
12751
- const { readFile: readFile19 } = await import('fs/promises');
12752
- const pkg = JSON.parse(await readFile19(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"));
12753
15506
  if (typeof pkg.name === "string") name = pkg.name;
12754
15507
  if (typeof pkg.version === "string") version = pkg.version;
12755
15508
  } catch {
@@ -12770,6 +15523,31 @@ function createProgram(io = defaultIO) {
12770
15523
  process.exitCode = 2;
12771
15524
  }
12772
15525
  });
15526
+ program.command("diff").description("Experimental comparison of two scan --json files (FULLSHIELD_EXPERIMENTAL_DIFF=1)").argument("<before>", "earlier scan JSON").argument("<after>", "later scan JSON").option("--experimental-diff", "enable this command (FULLSHIELD_EXPERIMENTAL_DIFF=1)").option("--json", "print the machine diff").option("--out <file>", "write the machine diff to this file").option("--fail-on <severity>", "exit 1 when an added finding is at or above this severity", "high").action(
15527
+ async (beforePath, afterPath, opts) => {
15528
+ try {
15529
+ if (opts.experimentalDiff === true) process.env[DIFF_FLAG] = "1";
15530
+ if (!diffEnabled()) throw new Error(`diff requires ${DIFF_FLAG}=1`);
15531
+ const failOn = parseFailOn(opts.failOn ?? "high");
15532
+ let beforeText;
15533
+ let afterText;
15534
+ try {
15535
+ beforeText = await readFile(resolve(beforePath), "utf8");
15536
+ afterText = await readFile(resolve(afterPath), "utf8");
15537
+ } catch {
15538
+ throw new Error("cannot read diff input");
15539
+ }
15540
+ const diff = diffScans(parseScanDocument(beforeText), parseScanDocument(afterText));
15541
+ const json = JSON.stringify(diff, null, 2);
15542
+ if (opts.out !== void 0) await writeFile(resolve(opts.out), json, "utf8");
15543
+ io.out(opts.json === true ? json : renderDiffText(diff));
15544
+ if (addedFails(diff, failOn)) process.exitCode = 1;
15545
+ } catch (error) {
15546
+ io.err(formatCliError(error));
15547
+ process.exitCode = 2;
15548
+ }
15549
+ }
15550
+ );
12773
15551
  program.command("doctor").description("Report Node, lockfile, cache, flags, config, and whether OSV is offline").argument("[path]", "project directory", ".").option("--offline", "do not describe OSV as allowed to use the network").option("--config <file>", "explicit config file").action(async (path, opts) => {
12774
15552
  try {
12775
15553
  io.out(await runDoctor(resolve(path), opts));