fullshield 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'crypto';
2
- import { readFile, mkdir, writeFile, stat, unlink, readdir, mkdtemp } from 'fs/promises';
2
+ import { readFile, mkdir, writeFile, readdir, stat, unlink, mkdtemp } from 'fs/promises';
3
3
  import { resolve, join, basename, dirname, posix, relative } from 'path';
4
4
  import { execFile, spawnSync, spawn, execFileSync } from 'child_process';
5
5
  import { createInterface } from 'readline';
6
6
  import { promisify } from 'util';
7
7
  import { performance } from 'perf_hooks';
8
+ import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync, rmSync, readdirSync, cpSync } from 'fs';
8
9
  import { homedir, availableParallelism, tmpdir } from 'os';
9
- import { existsSync, statSync, readFileSync, readdirSync, cpSync, mkdirSync, rmSync } from 'fs';
10
10
  import { Worker } from 'worker_threads';
11
11
  import { fileURLToPath } from 'url';
12
12
  import ignoreModule from 'ignore';
@@ -461,7 +461,7 @@ function scanTextForSecretHits(path, text) {
461
461
  return hits;
462
462
  }
463
463
  function scanTextForSecrets(path, text) {
464
- return scanTextForSecretHits(path, text).map((hit) => hit.finding);
464
+ return scanTextForSecretHits(path, text).map((hit2) => hit2.finding);
465
465
  }
466
466
  var secretsRules, REMEDIATION, MAX_FILE_SIZE, secretsModule;
467
467
  var init_secrets = __esm({
@@ -599,13 +599,13 @@ async function readBlob(cwd, hash) {
599
599
  return void 0;
600
600
  }
601
601
  }
602
- function remapHit(hit, file) {
603
- const snippet = hit.finding.snippet;
604
- const line = hit.finding.line ?? 0;
605
- const id = makeFindingId(hit.finding.ruleId, file, `${line}|${snippet ?? "file"}`);
602
+ function remapHit(hit2, file) {
603
+ const snippet = hit2.finding.snippet;
604
+ const line = hit2.finding.line ?? 0;
605
+ const id = makeFindingId(hit2.finding.ruleId, file, `${line}|${snippet ?? "file"}`);
606
606
  return {
607
- finding: { ...hit.finding, id, file },
608
- secret: hit.secret
607
+ finding: { ...hit2.finding, id, file },
608
+ secret: hit2.secret
609
609
  };
610
610
  }
611
611
  function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
@@ -695,8 +695,8 @@ async function headSecretKeys(cwd) {
695
695
  hits = scanTextForSecretHits(path, text);
696
696
  seen.set(hash, hits);
697
697
  }
698
- for (const hit of hits) {
699
- keys.add(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
698
+ for (const hit2 of hits) {
699
+ keys.add(`${hit2.finding.ruleId}\0${hit2.finding.snippet ?? ""}`);
700
700
  }
701
701
  }
702
702
  return keys;
@@ -729,8 +729,8 @@ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
729
729
  hits2 = rawHits.filter((h) => allowed.has(h.finding.id));
730
730
  blobHits.set(change.hash, hits2);
731
731
  }
732
- for (const hit of hits2) {
733
- const mapped = remapHit(hit, change.path);
732
+ for (const hit2 of hits2) {
733
+ const mapped = remapHit(hit2, change.path);
734
734
  if (!first.has(mapped.finding.id)) {
735
735
  first.set(mapped.finding.id, { hit: mapped, commit: change.commit });
736
736
  }
@@ -738,11 +738,11 @@ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
738
738
  }
739
739
  const atHead = await headSecretKeys(cwd);
740
740
  const hits = [];
741
- for (const { hit, commit } of first.values()) {
742
- const stillAtHead = atHead.has(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
741
+ for (const { hit: hit2, commit } of first.values()) {
742
+ const stillAtHead = atHead.has(`${hit2.finding.ruleId}\0${hit2.finding.snippet ?? ""}`);
743
743
  hits.push({
744
- finding: { ...hit.finding, introducedIn: commit, stillAtHead },
745
- secret: hit.secret
744
+ finding: { ...hit2.finding, introducedIn: commit, stillAtHead },
745
+ secret: hit2.secret
746
746
  });
747
747
  }
748
748
  const scanned = Math.min(maxCommits, total);
@@ -984,15 +984,15 @@ function providerFor(ruleId, secret) {
984
984
  async function verifySecretHits(hits, options = {}) {
985
985
  const fetchImpl = options.fetchImpl ?? fetch;
986
986
  const out = [];
987
- for (const hit of hits) {
988
- const secret = hit.secret;
987
+ for (const hit2 of hits) {
988
+ const secret = hit2.secret;
989
989
  if (secret === void 0 || secret.length === 0) {
990
- out.push({ finding: { ...hit.finding, verification: "unknown" }, secret: hit.secret });
990
+ out.push({ finding: { ...hit2.finding, verification: "unknown" }, secret: hit2.secret });
991
991
  continue;
992
992
  }
993
- const run = providerFor(hit.finding.ruleId, secret);
993
+ const run = providerFor(hit2.finding.ruleId, secret);
994
994
  const verification = run === void 0 ? "unknown" : await run(secret, fetchImpl);
995
- out.push({ finding: { ...hit.finding, verification }, secret: hit.secret });
995
+ out.push({ finding: { ...hit2.finding, verification }, secret: hit2.secret });
996
996
  }
997
997
  return out;
998
998
  }
@@ -1553,6 +1553,169 @@ var configModule = {
1553
1553
  // src/modules/deps/index.ts
1554
1554
  init_finding();
1555
1555
 
1556
+ // src/modules/deps/lockfile-modern.ts
1557
+ function addEdge(edges, from, to) {
1558
+ if (from.length === 0 || to.length === 0 || from === to) return;
1559
+ const list = edges.get(from) ?? /* @__PURE__ */ new Set();
1560
+ list.add(to);
1561
+ edges.set(from, list);
1562
+ }
1563
+ function graph(packages, edges, directProd, directDev, edgesKnown) {
1564
+ return { packages, edges, directProd, directDev, edgesKnown };
1565
+ }
1566
+ function parseYarnBerry(contents) {
1567
+ const packages = [];
1568
+ const edges = /* @__PURE__ */ new Map();
1569
+ const directProd = /* @__PURE__ */ new Set();
1570
+ const directDev = /* @__PURE__ */ new Set();
1571
+ let current;
1572
+ let inDeps = false;
1573
+ let devBlock = false;
1574
+ for (const raw of contents.split(/\r?\n/)) {
1575
+ if (!raw.startsWith(" ") && raw.trimEnd().endsWith(":")) {
1576
+ const key = raw.slice(0, -1).trim().replace(/^"|"$/g, "");
1577
+ current = key.includes("@workspace:.") ? "" : nameFromBerryKey(key);
1578
+ inDeps = false;
1579
+ devBlock = false;
1580
+ continue;
1581
+ }
1582
+ const version = /^\s+version:\s+(.+)$/.exec(raw)?.[1]?.replace(/["']/g, "").trim();
1583
+ if (version !== void 0 && current !== void 0 && current.length > 0 && !inDeps) {
1584
+ packages.push({ name: current, version: versionFromProtocol(version), path: `yarn:${current}` });
1585
+ continue;
1586
+ }
1587
+ if (/^\s+dependencies:\s*$/.test(raw)) {
1588
+ inDeps = true;
1589
+ devBlock = false;
1590
+ continue;
1591
+ }
1592
+ if (/^\s+devDependencies:\s*$/.test(raw)) {
1593
+ inDeps = true;
1594
+ devBlock = true;
1595
+ continue;
1596
+ }
1597
+ if (!inDeps || current === void 0) continue;
1598
+ const dep = /^\s{4}"?([^":]+)"?:\s+(.+)$/.exec(raw);
1599
+ if (dep?.[1] === void 0) continue;
1600
+ const depName = dep[1];
1601
+ if (current.length === 0) {
1602
+ if (devBlock) directDev.add(depName);
1603
+ else directProd.add(depName);
1604
+ } else {
1605
+ addEdge(edges, current, depName);
1606
+ }
1607
+ }
1608
+ return graph(packages, edges, directProd, directDev, true);
1609
+ }
1610
+ function nameFromBerryKey(key) {
1611
+ const first = key.split(",")[0]?.trim() ?? key;
1612
+ if (first.startsWith("@")) {
1613
+ const match = /^(@[^@/]+\/[^@/]+)@/.exec(first);
1614
+ return match?.[1];
1615
+ }
1616
+ const at = first.indexOf("@");
1617
+ return at <= 0 ? first : first.slice(0, at);
1618
+ }
1619
+ function versionFromProtocol(raw) {
1620
+ const patch = /^patch:(.+)#/.exec(raw);
1621
+ const body = patch?.[1] ?? raw;
1622
+ const npm = /^(?:npm:)?(@?[^@]+)@(.+)$/.exec(body);
1623
+ if (npm?.[2] !== void 0) return npm[2];
1624
+ return body;
1625
+ }
1626
+ function parsePnpmV9(contents) {
1627
+ const packages = [];
1628
+ const edges = /* @__PURE__ */ new Map();
1629
+ const directProd = /* @__PURE__ */ new Set();
1630
+ const directDev = /* @__PURE__ */ new Set();
1631
+ let block = "none";
1632
+ let snapshot;
1633
+ let inSnapshotDeps = false;
1634
+ for (const raw of contents.split(/\r?\n/)) {
1635
+ if (raw === "packages:") {
1636
+ block = "packages";
1637
+ snapshot = void 0;
1638
+ continue;
1639
+ }
1640
+ if (raw === "snapshots:") {
1641
+ block = "snapshots";
1642
+ snapshot = void 0;
1643
+ continue;
1644
+ }
1645
+ if (raw === "importers:" || raw.startsWith("lockfileVersion:")) {
1646
+ block = "none";
1647
+ continue;
1648
+ }
1649
+ if (block !== "snapshots" && /^\s{4}dependencies:\s*$/.test(raw)) {
1650
+ block = "importer-deps";
1651
+ continue;
1652
+ }
1653
+ if (/^\s{4}devDependencies:\s*$/.test(raw)) {
1654
+ block = "importer-dev";
1655
+ continue;
1656
+ }
1657
+ if (block === "packages") {
1658
+ const header = /^\s{2}['"]?(@?[^@'\s]+)@([^:'"]+)['"]?:/.exec(raw);
1659
+ if (header?.[1] !== void 0 && header[2] !== void 0) {
1660
+ packages.push({ name: header[1], version: header[2], path: `pnpm:${header[1]}` });
1661
+ }
1662
+ continue;
1663
+ }
1664
+ if (block === "snapshots") {
1665
+ const header = /^\s{2}['"]?(@?[^@'\s]+)@([^:'"]+)['"]?:/.exec(raw);
1666
+ if (header?.[1] !== void 0) {
1667
+ snapshot = header[1];
1668
+ inSnapshotDeps = false;
1669
+ continue;
1670
+ }
1671
+ if (/^\s+dependencies:\s*$/.test(raw)) {
1672
+ inSnapshotDeps = true;
1673
+ continue;
1674
+ }
1675
+ if (inSnapshotDeps && snapshot !== void 0) {
1676
+ const dep = /^\s{6}['"]?([^'":]+)['"]?:/.exec(raw);
1677
+ if (dep?.[1] !== void 0) addEdge(edges, snapshot, dep[1]);
1678
+ }
1679
+ continue;
1680
+ }
1681
+ if (block === "importer-deps" || block === "importer-dev") {
1682
+ const dep = /^\s{6}['"]?([^'":]+)['"]?:/.exec(raw);
1683
+ if (dep?.[1] !== void 0 && !raw.trim().startsWith("specifier") && !raw.trim().startsWith("version")) {
1684
+ if (block === "importer-dev") directDev.add(dep[1]);
1685
+ else directProd.add(dep[1]);
1686
+ }
1687
+ }
1688
+ }
1689
+ return graph(packages, edges, directProd, directDev, true);
1690
+ }
1691
+ function parseBunLock(contents) {
1692
+ const stripped = contents.replace(/^\s*\/\/.*$/gm, "");
1693
+ const packages = [];
1694
+ const edges = /* @__PURE__ */ new Map();
1695
+ const directProd = /* @__PURE__ */ new Set();
1696
+ const directDev = /* @__PURE__ */ new Set();
1697
+ try {
1698
+ const json = JSON.parse(stripped);
1699
+ for (const [name, value] of Object.entries(json.packages ?? {})) {
1700
+ if (!Array.isArray(value)) continue;
1701
+ const entry = Array.from(value);
1702
+ const spec = typeof entry[0] === "string" ? entry[0] : "";
1703
+ const version = spec.includes("@") ? spec.slice(spec.lastIndexOf("@") + 1) : spec;
1704
+ packages.push({ name, version, path: `bun:${name}` });
1705
+ const meta = entry[1];
1706
+ if (meta !== null && typeof meta === "object" && "dependencies" in meta) {
1707
+ const deps = meta.dependencies;
1708
+ if (deps !== null && typeof deps === "object") {
1709
+ for (const dep of Object.keys(deps)) addEdge(edges, name, dep);
1710
+ }
1711
+ }
1712
+ }
1713
+ } catch {
1714
+ return graph([], /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set(), /* @__PURE__ */ new Set(), false);
1715
+ }
1716
+ return graph(packages, edges, directProd, directDev, packages.length > 0);
1717
+ }
1718
+
1556
1719
  // src/modules/deps/lockfile.ts
1557
1720
  function basename2(filename) {
1558
1721
  const parts = filename.replace(/\\/g, "/").split("/");
@@ -1582,7 +1745,7 @@ function objectKeys(value) {
1582
1745
  }
1583
1746
  return Object.keys(value);
1584
1747
  }
1585
- function addEdge(edges, from, to) {
1748
+ function addEdge2(edges, from, to) {
1586
1749
  const set = edges.get(from) ?? /* @__PURE__ */ new Set();
1587
1750
  set.add(to);
1588
1751
  edges.set(from, set);
@@ -1662,7 +1825,7 @@ function parseYarnGraph(contents) {
1662
1825
  const depBerry = /^\s{4}(?:"([^"]+)"|(@?[\w./-]+)):/.exec(raw);
1663
1826
  const depName = depClassic?.[1] ?? depClassic?.[2] ?? depBerry?.[1] ?? depBerry?.[2];
1664
1827
  if (depName !== void 0) {
1665
- addEdge(edges, currentName, depName);
1828
+ addEdge2(edges, currentName, depName);
1666
1829
  continue;
1667
1830
  }
1668
1831
  if (/^\s{2}\S/.test(raw)) inDeps = false;
@@ -1757,7 +1920,7 @@ function parsePnpmGraph(contents) {
1757
1920
  const dep = /^ (?:'([^']+)'|"([^"]+)"|([^:]+)):/.exec(raw);
1758
1921
  const depName = dep?.[1] ?? dep?.[2] ?? dep?.[3];
1759
1922
  if (depName !== void 0) {
1760
- addEdge(edges, currentPkg, depName);
1923
+ addEdge2(edges, currentPkg, depName);
1761
1924
  continue;
1762
1925
  }
1763
1926
  if (/^ \S/.test(raw)) inPkgDeps = false;
@@ -1792,7 +1955,7 @@ function graphFromPackagesTable(packages) {
1792
1955
  const name = key === "" ? "" : nameFromPackagesKey(key);
1793
1956
  if (name === void 0 || name.length === 0) continue;
1794
1957
  for (const dep of [...objectKeys(value.dependencies), ...objectKeys(value.optionalDependencies)]) {
1795
- addEdge(edges, name, dep);
1958
+ addEdge2(edges, name, dep);
1796
1959
  }
1797
1960
  }
1798
1961
  return { edges, directProd, directDev };
@@ -1802,7 +1965,7 @@ function graphFromDependenciesTree(deps, edges) {
1802
1965
  const child = value.dependencies;
1803
1966
  if (child === void 0 || typeof child !== "object" || child === null) continue;
1804
1967
  const nested = child;
1805
- for (const childName of Object.keys(nested)) addEdge(edges, name, childName);
1968
+ for (const childName of Object.keys(nested)) addEdge2(edges, name, childName);
1806
1969
  graphFromDependenciesTree(nested, edges);
1807
1970
  }
1808
1971
  }
@@ -1815,8 +1978,16 @@ function parseLockfileGraph(filename, contents) {
1815
1978
  directDev: /* @__PURE__ */ new Set(),
1816
1979
  edgesKnown: false
1817
1980
  };
1818
- if (name === "yarn.lock") return parseYarnGraph(contents);
1819
- if (name === "pnpm-lock.yaml") return parsePnpmGraph(contents);
1981
+ const modern = process.env.FULLSHIELD_EXPERIMENTAL_LOCKFILES === "1";
1982
+ if (name === "yarn.lock") {
1983
+ if (modern && contents.includes("__metadata")) return parseYarnBerry(contents);
1984
+ return parseYarnGraph(contents);
1985
+ }
1986
+ if (name === "pnpm-lock.yaml") {
1987
+ if (modern && /lockfileVersion:\s*['"]?9/.test(contents)) return parsePnpmV9(contents);
1988
+ return parsePnpmGraph(contents);
1989
+ }
1990
+ if (modern && name === "bun.lock") return parseBunLock(contents);
1820
1991
  if (name !== "package-lock.json") return empty;
1821
1992
  try {
1822
1993
  const json = JSON.parse(contents);
@@ -1985,6 +2156,86 @@ function exactPinsFromManifest(pkg) {
1985
2156
  return out;
1986
2157
  }
1987
2158
 
2159
+ // src/modules/deps/evidence.ts
2160
+ var IMPORT_RE = /(?:require\(\s*['"]([^'"]+)['"]\s*\)|from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\))/g;
2161
+ function packageNameFromSpec(spec) {
2162
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return void 0;
2163
+ if (spec.startsWith("@")) {
2164
+ const slash2 = spec.indexOf("/");
2165
+ if (slash2 < 0) return void 0;
2166
+ const rest = spec.slice(slash2 + 1);
2167
+ const cut = rest.indexOf("/");
2168
+ return cut === -1 ? spec : spec.slice(0, slash2 + 1 + cut);
2169
+ }
2170
+ const slash = spec.indexOf("/");
2171
+ return slash === -1 ? spec : spec.slice(0, slash);
2172
+ }
2173
+ function collectImportSites(sources) {
2174
+ const byPkg = /* @__PURE__ */ new Map();
2175
+ const files = [...sources.keys()].sort();
2176
+ for (const file of files) {
2177
+ const text = sources.get(file) ?? "";
2178
+ const lines = text.split(/\r?\n/);
2179
+ for (let i = 0; i < lines.length; i += 1) {
2180
+ const line = lines[i] ?? "";
2181
+ IMPORT_RE.lastIndex = 0;
2182
+ let match;
2183
+ while ((match = IMPORT_RE.exec(line)) !== null) {
2184
+ const spec = match[1] ?? match[2] ?? match[3];
2185
+ if (spec === void 0) continue;
2186
+ const pkg = packageNameFromSpec(spec);
2187
+ if (pkg === void 0) continue;
2188
+ const list = byPkg.get(pkg) ?? [];
2189
+ list.push({ file, line: i + 1, specifier: spec, pkg });
2190
+ byPkg.set(pkg, list);
2191
+ }
2192
+ }
2193
+ }
2194
+ for (const [pkg, list] of byPkg) {
2195
+ list.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
2196
+ byPkg.set(pkg, list.slice(0, 5));
2197
+ }
2198
+ return byPkg;
2199
+ }
2200
+ function evidenceForFinding(finding3, sites, productionFiles, edgesKnown) {
2201
+ if (finding3.ruleId !== "DEPS-VULN-001" && finding3.ruleId !== "DEPS-MALICIOUS-001") return void 0;
2202
+ const snippet = finding3.snippet ?? "";
2203
+ const at = snippet.startsWith("@") ? snippet.indexOf("@", 1) : snippet.indexOf("@");
2204
+ const pkg = at <= 0 ? snippet : snippet.slice(0, at);
2205
+ if (pkg.length === 0) return void 0;
2206
+ const imports = (sites.get(pkg) ?? []).map((site) => ({
2207
+ file: site.file,
2208
+ line: site.line,
2209
+ specifier: site.specifier
2210
+ }));
2211
+ const first = imports[0];
2212
+ const path = finding3.dependencyPath;
2213
+ let reason;
2214
+ switch (finding3.reachability) {
2215
+ case "not-imported":
2216
+ reason = `No production file imports ${pkg}. Checked ${productionFiles.length} production file(s)${productionFiles.length > 0 ? `: ${productionFiles.slice(0, 8).join(", ")}` : ""}.`;
2217
+ return { imports: [], reason };
2218
+ case "reachability-unknown":
2219
+ reason = edgesKnown ? `Reachability for ${pkg} is unknown.` : `Lockfile edges are missing, so ${pkg} is reachability-unknown.`;
2220
+ return { imports, ...first !== void 0 ? {} : {}, reason };
2221
+ case "reachable-via-dependency":
2222
+ reason = path !== void 0 && path.length > 0 ? `Required through ${path.join(" > ")}.` : `Required by an imported package.`;
2223
+ return { imports, ...path !== void 0 ? { dependencyPath: path } : {}, reason };
2224
+ case "imported-not-called":
2225
+ reason = first !== void 0 ? `Imported from ${first.file}:${first.line} and not called.` : `Imported and not called.`;
2226
+ return { imports, reason };
2227
+ case "symbol-reachable":
2228
+ reason = first !== void 0 ? `A named vulnerable export is called from ${first.file}:${first.line}.` : `A named vulnerable export is called.`;
2229
+ return { imports, reason };
2230
+ case "symbol-not-called":
2231
+ reason = `A named vulnerable export is not called.`;
2232
+ return { imports, reason };
2233
+ default:
2234
+ reason = first !== void 0 ? `Imported from ${first.file}:${first.line}.` : `Imported by production code.`;
2235
+ return { imports, ...path !== void 0 ? { dependencyPath: path } : {}, reason };
2236
+ }
2237
+ }
2238
+
1988
2239
  // src/modules/deps/reachability.ts
1989
2240
  init_finding();
1990
2241
 
@@ -2092,7 +2343,7 @@ function packageNameFromSnippet(snippet) {
2092
2343
  const at = snippet.indexOf("@");
2093
2344
  return at <= 0 ? snippet : snippet.slice(0, at);
2094
2345
  }
2095
- function packageNameFromSpec(spec) {
2346
+ function packageNameFromSpec2(spec) {
2096
2347
  if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return void 0;
2097
2348
  if (spec.startsWith("@")) {
2098
2349
  const parts = spec.split("/");
@@ -2128,7 +2379,7 @@ function indexPackageUsage(sources) {
2128
2379
  const local = m[1];
2129
2380
  const spec = m[2];
2130
2381
  if (local === void 0 || spec === void 0) continue;
2131
- const pkg = packageNameFromSpec(spec);
2382
+ const pkg = packageNameFromSpec2(spec);
2132
2383
  if (pkg === void 0) continue;
2133
2384
  bump(pkg, localIsCalled(source, local));
2134
2385
  }
@@ -2138,7 +2389,7 @@ function indexPackageUsage(sources) {
2138
2389
  while ((named = namedRe.exec(source)) !== null) {
2139
2390
  const spec = named[2];
2140
2391
  if (spec === void 0) continue;
2141
- const pkg = packageNameFromSpec(spec);
2392
+ const pkg = packageNameFromSpec2(spec);
2142
2393
  if (pkg === void 0) continue;
2143
2394
  const locals = (named[1] ?? "").split(",").map((s) => s.trim().split(/\s+as\s+/).pop() ?? "");
2144
2395
  bump(
@@ -2197,34 +2448,55 @@ function reachableFrom(target, roots, edges) {
2197
2448
  if (roots.has(target)) return true;
2198
2449
  return shortestDependencyPath(target, roots, edges) !== void 0;
2199
2450
  }
2200
- function importedDirectNames(usage, graph) {
2201
- const directs = /* @__PURE__ */ new Set([...graph.directProd, ...graph.directDev]);
2451
+ function importedDirectNames(usage, graph2) {
2452
+ const directs = /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]);
2202
2453
  const out = /* @__PURE__ */ new Set();
2203
- for (const [name, hit] of usage) {
2204
- if (!hit.imported) continue;
2454
+ for (const [name, hit2] of usage) {
2455
+ if (!hit2.imported) continue;
2205
2456
  if (directs.size === 0 || directs.has(name)) out.add(name);
2206
2457
  }
2207
2458
  return out;
2208
2459
  }
2209
- function classifyReachability(pkgName, productionUsage, graph) {
2210
- const hit = productionUsage.get(pkgName);
2211
- if (hit?.imported === true) {
2212
- return { reachability: hit.called ? "reachable" : "imported-not-called" };
2460
+ function classifyReachability(pkgName, productionUsage, graph2) {
2461
+ const hit2 = productionUsage.get(pkgName);
2462
+ if (hit2?.imported === true) {
2463
+ return { reachability: hit2.called ? "reachable" : "imported-not-called" };
2213
2464
  }
2214
- if (graph !== void 0 && graph.edgesKnown) {
2215
- const roots = importedDirectNames(productionUsage, graph);
2216
- const paths = collectDependencyPaths(pkgName, roots, graph.edges);
2465
+ if (graph2 !== void 0 && graph2.edgesKnown) {
2466
+ const roots = importedDirectNames(productionUsage, graph2);
2467
+ const paths = collectDependencyPaths(pkgName, roots, graph2.edges);
2217
2468
  const path = paths[0];
2218
2469
  if (path !== void 0 && path.length >= 2) {
2219
2470
  return { reachability: "reachable-via-dependency", path, paths };
2220
2471
  }
2221
2472
  return { reachability: "not-imported" };
2222
2473
  }
2223
- if (graph !== void 0 && !graph.edgesKnown) {
2474
+ if (graph2 !== void 0 && !graph2.edgesKnown) {
2224
2475
  return { reachability: "reachability-unknown" };
2225
2476
  }
2226
2477
  return { reachability: "not-imported" };
2227
2478
  }
2479
+ function unusedDirectPackages(ctx) {
2480
+ if (!ctx.graph.edgesKnown) return [];
2481
+ const imported = importedDirectNames(ctx.productionUsage, ctx.graph);
2482
+ return [...ctx.graph.directProd].filter((name) => {
2483
+ if (ctx.productionUsage.get(name)?.imported === true) return false;
2484
+ return !reachableFrom(name, imported, ctx.graph.edges);
2485
+ }).sort();
2486
+ }
2487
+ function unusedDirectPlan(findings, ctx) {
2488
+ const packages = unusedDirectPackages(ctx);
2489
+ if (packages.length === 0) return void 0;
2490
+ const names = new Set(packages);
2491
+ let advisories = 0;
2492
+ for (const finding3 of findings) {
2493
+ if (finding3.ruleId !== "DEPS-VULN-001" && finding3.ruleId !== "DEPS-MALICIOUS-001") continue;
2494
+ if (finding3.snippet === void 0) continue;
2495
+ const pkg = packageNameFromSnippet(finding3.snippet);
2496
+ if (pkg !== void 0 && names.has(pkg)) advisories += 1;
2497
+ }
2498
+ return { packages, advisories };
2499
+ }
2228
2500
  function reachabilitySteps(reachability) {
2229
2501
  if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
2230
2502
  return 0;
@@ -2447,6 +2719,176 @@ function osvSeverity(vuln) {
2447
2719
  return "high";
2448
2720
  }
2449
2721
 
2722
+ // src/modules/deps/upgrade-plan.ts
2723
+ function sentenceFor(bumps, unresolved) {
2724
+ const parts = [];
2725
+ if (bumps.length === 1) {
2726
+ const bump = bumps[0];
2727
+ if (bump !== void 0) {
2728
+ const noun = bump.findings === 1 ? "finding" : "findings";
2729
+ parts.push(`1 bump (${bump.name} ${bump.from} \u2192 ${bump.to}) resolves ${bump.findings} ${noun}.`);
2730
+ }
2731
+ } else if (bumps.length > 1) {
2732
+ const listed = bumps.map((bump) => `${bump.name} ${bump.from} \u2192 ${bump.to}`).join(", ");
2733
+ const total = bumps.reduce((sum, bump) => sum + bump.findings, 0);
2734
+ const noun = total === 1 ? "finding" : "findings";
2735
+ parts.push(`${bumps.length} bumps (${listed}) resolve ${total} ${noun}.`);
2736
+ }
2737
+ if (unresolved.length > 0) {
2738
+ parts.push(`No fixed version in the OSV cache for ${unresolved.join(", ")}.`);
2739
+ }
2740
+ return parts.join(" ");
2741
+ }
2742
+ function chooseUpgradePlan(advisories) {
2743
+ const byName = /* @__PURE__ */ new Map();
2744
+ for (const advisory of advisories) {
2745
+ const list = byName.get(advisory.name);
2746
+ if (list === void 0) byName.set(advisory.name, [advisory]);
2747
+ else list.push(advisory);
2748
+ }
2749
+ const unresolved = [];
2750
+ const candidates = [];
2751
+ for (const [name, list] of byName) {
2752
+ const from = list[0]?.from ?? "0.0.0";
2753
+ if (list.some((advisory) => advisory.fixed === void 0)) {
2754
+ unresolved.push(name);
2755
+ continue;
2756
+ }
2757
+ let to = list[0]?.fixed;
2758
+ for (const advisory of list) {
2759
+ if (advisory.fixed !== void 0 && (to === void 0 || compareSemver(advisory.fixed, to) > 0)) {
2760
+ to = advisory.fixed;
2761
+ }
2762
+ }
2763
+ if (to === void 0 || compareSemver(to, from) <= 0) continue;
2764
+ candidates.push({
2765
+ name,
2766
+ from,
2767
+ to,
2768
+ ids: list.map((advisory) => advisory.id)
2769
+ });
2770
+ }
2771
+ unresolved.sort();
2772
+ const chosen = smallestMaxCover(candidates);
2773
+ if (chosen.length === 0 && unresolved.length === 0) return void 0;
2774
+ const bumps = chosen.map((candidate) => ({
2775
+ name: candidate.name,
2776
+ from: candidate.from,
2777
+ to: candidate.to,
2778
+ safe: sameMajor(candidate.from, candidate.to),
2779
+ findings: candidate.ids.length
2780
+ }));
2781
+ return { bumps, unresolved, sentence: sentenceFor(bumps, unresolved) };
2782
+ }
2783
+ function smallestMaxCover(candidates) {
2784
+ const limit = Math.min(candidates.length, 12);
2785
+ let best = [];
2786
+ let bestCount = -1;
2787
+ const walk2 = (index, picked, covered) => {
2788
+ if (index === limit) {
2789
+ if (covered.size > bestCount || covered.size === bestCount && betterTie(picked, best)) {
2790
+ best = picked;
2791
+ bestCount = covered.size;
2792
+ }
2793
+ return;
2794
+ }
2795
+ const next = candidates[index];
2796
+ if (next === void 0) return;
2797
+ walk2(index + 1, picked, covered);
2798
+ const grown = new Set(covered);
2799
+ for (const id of next.ids) grown.add(id);
2800
+ walk2(index + 1, [...picked, next], grown);
2801
+ };
2802
+ walk2(0, [], /* @__PURE__ */ new Set());
2803
+ return [...best].sort((a, b) => a.name.localeCompare(b.name));
2804
+ }
2805
+ function betterTie(picked, best) {
2806
+ if (picked.length !== best.length) return picked.length < best.length;
2807
+ const left = picked.map((item) => item.name).sort().join("\0");
2808
+ const right = best.map((item) => item.name).sort().join("\0");
2809
+ return left < right;
2810
+ }
2811
+
2812
+ // src/supply/campaign.ts
2813
+ init_finding();
2814
+ var RPC_RE = /getSignaturesForAddress|eth_getLogs/;
2815
+ var NET_RE = /https\.request|http\.request|fetch\s*\(|axios\./;
2816
+ var EVAL_RE = /\beval\s*\(/;
2817
+ var STEAL_RE = /Login Data|leveldb|discord[\\/]Local Storage|wallet-extension|Ethereum\/keystore/i;
2818
+ var PLATFORM_RE = /process\.platform\s*===\s*['"]win32['"]|os\.platform\s*\(\s*\)/;
2819
+ var DOWNLOAD_RE = /https?:\/\/example\.invalid|createWriteStream|https\.get/;
2820
+ var SPAWN_RE = /child_process|spawn\s*\(|execFile\s*\(|\.exe['"]/;
2821
+ function hit(ruleId, title, message, file, severity) {
2822
+ return {
2823
+ id: makeFindingId(ruleId, file, message),
2824
+ ruleId,
2825
+ module: "deps",
2826
+ title,
2827
+ severity,
2828
+ confidence: "high",
2829
+ file,
2830
+ message,
2831
+ remediation: "Do not install this package. Remove it, delete node_modules, and reinstall from a clean lockfile."
2832
+ };
2833
+ }
2834
+ function campaignFindings(files) {
2835
+ const findings = [];
2836
+ for (const source of files) {
2837
+ const hasChain = RPC_RE.test(source.text);
2838
+ const hasNet = NET_RE.test(source.text) || EVAL_RE.test(source.text);
2839
+ if (hasChain && hasNet) {
2840
+ findings.push(
2841
+ hit(
2842
+ "SUPPLY-CHAINC2-001",
2843
+ "Package reads a blockchain RPC and then fetches or evaluates the result",
2844
+ "static: RPC method combined with fetch or eval",
2845
+ source.file,
2846
+ "high"
2847
+ )
2848
+ );
2849
+ }
2850
+ const steals = STEAL_RE.test(source.text);
2851
+ const callsNet = NET_RE.test(source.text);
2852
+ if (steals && callsNet) {
2853
+ findings.push(
2854
+ hit(
2855
+ "SUPPLY-STEALER-001",
2856
+ "Package reads a local credential store and makes a network call",
2857
+ "static: browser, chat, or wallet path combined with a network call",
2858
+ source.file,
2859
+ "critical"
2860
+ )
2861
+ );
2862
+ }
2863
+ const gated2 = PLATFORM_RE.test(source.text);
2864
+ const downloads = DOWNLOAD_RE.test(source.text);
2865
+ const spawns = SPAWN_RE.test(source.text);
2866
+ if (gated2 && downloads && spawns) {
2867
+ findings.push(
2868
+ hit(
2869
+ "SUPPLY-PLATFORMGATE-001",
2870
+ "Package downloads and launches a file only on one platform",
2871
+ "static: platform check combined with a download and a process spawn",
2872
+ source.file,
2873
+ "high"
2874
+ )
2875
+ );
2876
+ }
2877
+ }
2878
+ return findings;
2879
+ }
2880
+ function securityPlaceholderFinding(name, locked, latest) {
2881
+ if (latest !== "0.0.1-security") return void 0;
2882
+ if (locked === "0.0.1-security") return void 0;
2883
+ return hit(
2884
+ "SUPPLY-SECHOLDER-001",
2885
+ `Locked ${name}@${locked} was replaced on the registry by the security placeholder`,
2886
+ `${name} latest is 0.0.1-security while the lockfile pins ${locked}`,
2887
+ "package-lock.json",
2888
+ "critical"
2889
+ );
2890
+ }
2891
+
2450
2892
  // src/modules/deps/popular.ts
2451
2893
  var POPULAR_PACKAGES = [
2452
2894
  "express",
@@ -4494,7 +4936,7 @@ ${scripts}`;
4494
4936
  if (!TOKEN_RE.test(text)) return [];
4495
4937
  const replicates = PUBLISH_RE.test(text) || REGISTRY_PUT_RE.test(text) || GITHUB_CREATE_RE.test(text) || WORKFLOW_PATH_RE.test(text);
4496
4938
  if (!replicates) return [];
4497
- const hit = sources.find(
4939
+ const hit2 = sources.find(
4498
4940
  (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))
4499
4941
  ) ?? sources.find((s) => TOKEN_RE.test(s.text));
4500
4942
  return [
@@ -4502,7 +4944,7 @@ ${scripts}`;
4502
4944
  "SUPPLY-WORM-001",
4503
4945
  "Self-replicating worm indicators in dependency code",
4504
4946
  "static: npm/GitHub token read combined with publish, registry PUT, GitHub repo/workflow creation, or writing .github/workflows",
4505
- hit?.file,
4947
+ hit2?.file,
4506
4948
  "critical"
4507
4949
  )
4508
4950
  ];
@@ -4512,13 +4954,13 @@ function analyzeWorkflowTampering(dir) {
4512
4954
  const text = `${sources.map((s) => s.text).join("\n")}
4513
4955
  ${scripts}`;
4514
4956
  if (!CI_WRITE_RE.test(text)) return [];
4515
- const hit = sources.find((s) => CI_WRITE_RE.test(s.text));
4957
+ const hit2 = sources.find((s) => CI_WRITE_RE.test(s.text));
4516
4958
  return [
4517
4959
  finding(
4518
4960
  "SUPPLY-WORKFLOW-001",
4519
4961
  "Dependency writes GitHub workflow, git hooks, or CI config",
4520
4962
  "static: install script or package code writes .github/, .git/hooks/, or CI configuration",
4521
- hit?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
4963
+ hit2?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
4522
4964
  "critical"
4523
4965
  )
4524
4966
  ];
@@ -4527,9 +4969,9 @@ function provenanceCoverage(packages, known) {
4527
4969
  let withProvenance = 0;
4528
4970
  let unknown = 0;
4529
4971
  for (const pkg of packages) {
4530
- const hit = known.get(`${pkg.name}@${pkg.version}`);
4531
- if (hit === true) withProvenance += 1;
4532
- else if (hit === void 0) unknown += 1;
4972
+ const hit2 = known.get(`${pkg.name}@${pkg.version}`);
4973
+ if (hit2 === true) withProvenance += 1;
4974
+ else if (hit2 === void 0) unknown += 1;
4533
4975
  }
4534
4976
  const total = packages.length;
4535
4977
  return {
@@ -4556,8 +4998,8 @@ function parentsOf(name, edges) {
4556
4998
  }
4557
4999
  return out;
4558
5000
  }
4559
- function isDirect(name, graph) {
4560
- return graph.directProd.has(name) || graph.directDev.has(name);
5001
+ function isDirect(name, graph2) {
5002
+ return graph2.directProd.has(name) || graph2.directDev.has(name);
4561
5003
  }
4562
5004
  function packagesWithAFixedRelease(findings) {
4563
5005
  const names = /* @__PURE__ */ new Set();
@@ -4575,17 +5017,17 @@ function targetVersion(finding3) {
4575
5017
  function kindFromLockfile(lockFile) {
4576
5018
  return lockfileKind(lockFile) ?? "npm";
4577
5019
  }
4578
- function attachOverrideProposals(findings, graph, lockFile, manifestRaw) {
4579
- if (!graph.edgesKnown || manifestRaw === void 0) return findings;
5020
+ function attachOverrideProposals(findings, graph2, lockFile, manifestRaw) {
5021
+ if (!graph2.edgesKnown || manifestRaw === void 0) return findings;
4580
5022
  const withFix = packagesWithAFixedRelease(findings);
4581
5023
  const kind = kindFromLockfile(lockFile);
4582
5024
  return findings.map((finding3) => {
4583
5025
  if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
4584
5026
  const name = packageNameFromSnippet2(finding3.snippet);
4585
- if (name.length === 0 || isDirect(name, graph)) return finding3;
5027
+ if (name.length === 0 || isDirect(name, graph2)) return finding3;
4586
5028
  const version = targetVersion(finding3);
4587
5029
  if (version === void 0) return finding3;
4588
- const parentFixed = parentsOf(name, graph.edges).some((parent) => withFix.has(parent));
5030
+ const parentFixed = parentsOf(name, graph2.edges).some((parent) => withFix.has(parent));
4589
5031
  if (parentFixed) return finding3;
4590
5032
  const after = withManifestOverride(manifestRaw, kind, name, version);
4591
5033
  const diff = unifiedDiff("package.json", manifestRaw, after);
@@ -4606,10 +5048,10 @@ function scanInstalledPackages(cwd, packages) {
4606
5048
  const dir = packageDir(cwd, pkg);
4607
5049
  if (dir === void 0) continue;
4608
5050
  const prefix = pkg.path.startsWith("node_modules/") ? pkg.path : `node_modules/${pkg.name}`;
4609
- for (const hit of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
5051
+ for (const hit2 of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
4610
5052
  out.push({
4611
- ...hit,
4612
- file: hit.file !== void 0 && hit.file.length > 0 ? `${prefix}/${hit.file}` : prefix
5053
+ ...hit2,
5054
+ file: hit2.file !== void 0 && hit2.file.length > 0 ? `${prefix}/${hit2.file}` : prefix
4613
5055
  });
4614
5056
  }
4615
5057
  }
@@ -4688,19 +5130,19 @@ function maliciousFinding(lockFile, pkg, vuln) {
4688
5130
  references: [vuln.id, ...vuln.aliases ?? []]
4689
5131
  };
4690
5132
  }
4691
- function typosquatFinding(lockFile, hit) {
4692
- const snippet = `${hit.name}@${hit.version}`;
5133
+ function typosquatFinding(lockFile, hit2) {
5134
+ const snippet = `${hit2.name}@${hit2.version}`;
4693
5135
  return {
4694
5136
  id: makeFindingId("DEPS-TYPOSQUAT-001", lockFile, snippet),
4695
5137
  ruleId: "DEPS-TYPOSQUAT-001",
4696
5138
  module: "deps",
4697
- title: `Possible typosquat: ${hit.name}`,
5139
+ title: `Possible typosquat: ${hit2.name}`,
4698
5140
  severity: "medium",
4699
5141
  confidence: "low",
4700
5142
  file: lockFile,
4701
5143
  snippet,
4702
- message: `Package "${hit.name}" is 1 character from popular package "${hit.lookalike}" (possible typosquat).`,
4703
- remediation: `Confirm you intended to install "${hit.name}". If not, remove it and depend on "${hit.lookalike}" instead.`
5144
+ message: `Package "${hit2.name}" is 1 character from popular package "${hit2.lookalike}" (possible typosquat).`,
5145
+ remediation: `Confirm you intended to install "${hit2.name}". If not, remove it and depend on "${hit2.lookalike}" instead.`
4704
5146
  };
4705
5147
  }
4706
5148
  function noLockFinding() {
@@ -4734,6 +5176,8 @@ function deprecatedFinding(lockFile, pkg) {
4734
5176
  var depsModule = {
4735
5177
  name: "deps",
4736
5178
  async run(ctx) {
5179
+ depsModule.unusedDirect = void 0;
5180
+ depsModule.upgradePlan = void 0;
4737
5181
  const lockFile = LOCKFILE_NAMES.find((name) => ctx.files.includes(name));
4738
5182
  const cacheDir = ctx.config.deps.cacheDir !== void 0 ? resolve(ctx.cwd, ctx.config.deps.cacheDir) : defaultCacheDir();
4739
5183
  const osvOpts = { cacheDir, offline: ctx.config.deps.offline };
@@ -4759,21 +5203,21 @@ var depsModule = {
4759
5203
  }
4760
5204
  const pins = exactPinsFromManifest(manifest);
4761
5205
  const directNames2 = await readDirectDependencyNames(ctx.cwd);
4762
- for (const hit of findTyposquats(pins, { directNames: directNames2 })) {
4763
- findings2.push(typosquatFinding("package.json", hit));
5206
+ for (const hit2 of findTyposquats(pins, { directNames: directNames2 })) {
5207
+ findings2.push(typosquatFinding("package.json", hit2));
4764
5208
  }
4765
5209
  const osvHits2 = await queryOsv(pins, osvOpts);
4766
5210
  const symbols2 = /* @__PURE__ */ new Map();
4767
- for (const hit of osvHits2) {
4768
- for (const vuln of hit.vulns) {
4769
- const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
5211
+ for (const hit2 of osvHits2) {
5212
+ for (const vuln of hit2.vulns) {
5213
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit2.pkg, vuln) : vulnFinding("package.json", hit2.pkg, vuln, depLoc(hit2.pkg.name));
4770
5214
  const names = affectedFunctionNames(vuln);
4771
5215
  if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols2.set(finding3.id, names);
4772
5216
  findings2.push(finding3);
4773
5217
  }
4774
5218
  }
4775
5219
  const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4776
- return applyReachability(findings2, noLockCtx, symbols2);
5220
+ return withWorkspaceAdvisories(ctx.cwd, manifestRaw, osvOpts, applyReachability(findings2, noLockCtx, symbols2));
4777
5221
  }
4778
5222
  let contents;
4779
5223
  try {
@@ -4781,12 +5225,12 @@ var depsModule = {
4781
5225
  } catch {
4782
5226
  return [];
4783
5227
  }
4784
- const graph = parseLockfileGraph(lockFile, contents);
4785
- const packages = graph.packages;
5228
+ const graph2 = parseLockfileGraph(lockFile, contents);
5229
+ const packages = graph2.packages;
4786
5230
  const findings = [];
4787
5231
  const directNames = await readDirectDependencyNames(ctx.cwd);
4788
- for (const hit of findTyposquats(packages, { directNames })) {
4789
- findings.push(typosquatFinding(lockFile, hit));
5232
+ for (const hit2 of findTyposquats(packages, { directNames })) {
5233
+ findings.push(typosquatFinding(lockFile, hit2));
4790
5234
  }
4791
5235
  for (const pkg of packages) {
4792
5236
  if (pkg.deprecated !== void 0) {
@@ -4795,23 +5239,129 @@ var depsModule = {
4795
5239
  }
4796
5240
  const osvHits = await queryOsv(packages, osvOpts);
4797
5241
  const symbols = /* @__PURE__ */ new Map();
4798
- for (const hit of osvHits) {
4799
- for (const vuln of hit.vulns) {
4800
- const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
5242
+ for (const hit2 of osvHits) {
5243
+ for (const vuln of hit2.vulns) {
5244
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit2.pkg, vuln) : vulnFinding(lockFile, hit2.pkg, vuln, depLoc(hit2.pkg.name));
4801
5245
  const names = affectedFunctionNames(vuln);
4802
5246
  if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols.set(finding3.id, names);
4803
5247
  findings.push(finding3);
4804
5248
  }
4805
5249
  }
4806
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
5250
+ const reachCtx = await reachabilityCtx(ctx.cwd, ctx.files, graph2);
5251
+ let reached = applyReachability(findings, reachCtx, symbols);
4807
5252
  if (experimentalVex()) {
4808
- reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
5253
+ reached = attachOverrideProposals(reached, graph2, lockFile, manifestRaw);
4809
5254
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4810
5255
  }
4811
5256
  if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
5257
+ depsModule.unusedDirect = unusedDirectPlan(reached, reachCtx);
5258
+ const direct = /* @__PURE__ */ new Set([...graph2.directProd, ...graph2.directDev]);
5259
+ const advisories = [];
5260
+ for (const hit2 of osvHits) {
5261
+ if (!direct.has(hit2.pkg.name)) continue;
5262
+ for (const vuln of hit2.vulns) {
5263
+ if (isMaliciousAdvisory(vuln)) continue;
5264
+ advisories.push({
5265
+ name: hit2.pkg.name,
5266
+ from: hit2.pkg.version,
5267
+ id: vuln.id,
5268
+ fixed: minFixedVersion(vuln.affected ?? [])
5269
+ });
5270
+ }
5271
+ }
5272
+ depsModule.upgradePlan = chooseUpgradePlan(advisories);
5273
+ if (process.env.FULLSHIELD_EXPERIMENTAL_REACH_EVIDENCE === "1") {
5274
+ const sites = collectImportSites(reachCtx.sources);
5275
+ const files = [...reachCtx.sources.keys()].sort();
5276
+ reached = reached.map((finding3) => {
5277
+ const evidence = evidenceForFinding(finding3, sites, files, graph2.edgesKnown);
5278
+ return evidence === void 0 ? finding3 : { ...finding3, reachabilityEvidence: evidence };
5279
+ });
5280
+ }
5281
+ reached = await withWorkspaceAdvisories(ctx.cwd, manifestRaw, osvOpts, reached);
5282
+ if (process.env.FULLSHIELD_EXPERIMENTAL_SUPPLY === "1") {
5283
+ reached.push(...campaignFindings([...reachCtx.sources.entries()].map(([file, text]) => ({ file, text }))));
5284
+ for (const name of [...direct].sort()) {
5285
+ const locked = packages.find((pkg) => pkg.name === name)?.version;
5286
+ if (locked === void 0) continue;
5287
+ const latest = await readPlaceholderLatest(join(cacheDir, "registry", `${name.replaceAll("/", "__")}.json`));
5288
+ const placeholder = securityPlaceholderFinding(name, locked, latest);
5289
+ if (placeholder !== void 0) reached.push(placeholder);
5290
+ }
5291
+ }
4812
5292
  return reached;
4813
5293
  }
4814
5294
  };
5295
+ function workspacesEnabled() {
5296
+ return process.env.FULLSHIELD_EXPERIMENTAL_WORKSPACES === "1";
5297
+ }
5298
+ function workspacePatterns(manifestRaw) {
5299
+ try {
5300
+ const manifest = JSON.parse(manifestRaw);
5301
+ const field = manifest.workspaces;
5302
+ if (Array.isArray(field)) return field.filter((item) => typeof item === "string");
5303
+ if (field !== null && typeof field === "object" && Array.isArray(field.packages)) {
5304
+ return field.packages.filter((item) => typeof item === "string");
5305
+ }
5306
+ } catch {
5307
+ return [];
5308
+ }
5309
+ return [];
5310
+ }
5311
+ async function expandWorkspace(cwd, pattern) {
5312
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
5313
+ if (!normalized.includes("*")) return [normalized];
5314
+ const slash = normalized.indexOf("/*");
5315
+ if (slash < 0) return [];
5316
+ const parent = normalized.slice(0, slash);
5317
+ try {
5318
+ const entries = await readdir(join(cwd, parent), { withFileTypes: true });
5319
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => `${parent}/${entry.name}`).sort();
5320
+ } catch {
5321
+ return [];
5322
+ }
5323
+ }
5324
+ async function workspaceAdvisoryFindings(cwd, manifestRaw, osvOpts) {
5325
+ const dirs = /* @__PURE__ */ new Set();
5326
+ for (const pattern of workspacePatterns(manifestRaw)) {
5327
+ for (const dir of await expandWorkspace(cwd, pattern)) dirs.add(dir);
5328
+ }
5329
+ const findings = [];
5330
+ for (const dir of [...dirs].sort()) {
5331
+ if (dir === "" || dir === ".") continue;
5332
+ const lockName = LOCKFILE_NAMES.find((name) => existsSync(join(cwd, dir, name)));
5333
+ if (lockName === void 0) continue;
5334
+ let text;
5335
+ try {
5336
+ text = await readFile(join(cwd, dir, lockName), "utf8");
5337
+ } catch {
5338
+ continue;
5339
+ }
5340
+ const graph2 = parseLockfileGraph(lockName, text);
5341
+ const hits = await queryOsv(graph2.packages, osvOpts);
5342
+ const file = `${dir}/${lockName}`;
5343
+ for (const hit2 of hits) {
5344
+ for (const vuln of hit2.vulns) {
5345
+ findings.push(
5346
+ isMaliciousAdvisory(vuln) ? maliciousFinding(file, hit2.pkg, vuln) : vulnFinding(file, hit2.pkg, vuln)
5347
+ );
5348
+ }
5349
+ }
5350
+ }
5351
+ return findings;
5352
+ }
5353
+ async function withWorkspaceAdvisories(cwd, manifestRaw, osvOpts, findings) {
5354
+ if (!workspacesEnabled() || manifestRaw === void 0) return findings;
5355
+ return [...findings, ...await workspaceAdvisoryFindings(cwd, manifestRaw, osvOpts)];
5356
+ }
5357
+ async function readPlaceholderLatest(file) {
5358
+ try {
5359
+ const raw = JSON.parse(await readFile(file, "utf8"));
5360
+ return typeof raw.latest === "string" ? raw.latest : void 0;
5361
+ } catch {
5362
+ return void 0;
5363
+ }
5364
+ }
4815
5365
  function graphFromManifest(manifest) {
4816
5366
  const keys = (field) => {
4817
5367
  const block = manifest[field];
@@ -4828,7 +5378,7 @@ function graphFromManifest(manifest) {
4828
5378
  edgesKnown: false
4829
5379
  };
4830
5380
  }
4831
- async function reachabilityCtx(cwd, files, graph) {
5381
+ async function reachabilityCtx(cwd, files, graph2) {
4832
5382
  const prodSources = /* @__PURE__ */ new Map();
4833
5383
  for (const rel of files) {
4834
5384
  if (!/\.[cm]?[jt]sx?$/.test(rel)) continue;
@@ -4838,7 +5388,7 @@ async function reachabilityCtx(cwd, files, graph) {
4838
5388
  } catch {
4839
5389
  }
4840
5390
  }
4841
- return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
5391
+ return { productionUsage: indexPackageUsage(prodSources), graph: graph2, sources: prodSources };
4842
5392
  }
4843
5393
 
4844
5394
  // src/modules/mcp/index.ts
@@ -5213,67 +5763,200 @@ init_finding();
5213
5763
  // src/modules/sast/interproc.ts
5214
5764
  init_finding();
5215
5765
 
5216
- // src/modules/sast/frameworks.ts
5217
- var NEXT_HANDLERS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
5218
- var NEST_HTTP = /^(Get|Post|Put|Patch|Delete|All|Head|Options)$/;
5219
- var NEST_INPUT = /^(Body|Query|Param|Headers)$/;
5220
- var REQ_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer"]);
5221
- var HONO_READERS = /* @__PURE__ */ new Set(["query", "param", "header", "json", "parseBody", "text"]);
5222
- function decoratorExpr(dec) {
5223
- if (dec.type !== "Decorator") return void 0;
5224
- return dec.expression;
5225
- }
5226
- function decoratorName(dec) {
5227
- const expr = decoratorExpr(dec);
5228
- if (expr === void 0) return void 0;
5229
- if (expr.type === "Identifier") return expr.name;
5230
- if (expr.type === "CallExpression") return calleeName(expr);
5231
- return void 0;
5232
- }
5233
- function nodeDecorators(node) {
5234
- const decs = node.decorators;
5235
- return Array.isArray(decs) ? decs : [];
5236
- }
5237
- function nestInputDecorator(param) {
5238
- if (!frameworksEnabled()) return void 0;
5239
- for (const dec of nodeDecorators(param)) {
5240
- const name = decoratorName(dec);
5241
- if (name !== void 0 && NEST_INPUT.test(name)) return name;
5766
+ // src/runtime/lru.ts
5767
+ var LruMap = class {
5768
+ constructor(max) {
5769
+ this.max = max;
5770
+ if (max < 1) throw new Error("LruMap max must be >= 1");
5242
5771
  }
5243
- return void 0;
5244
- }
5245
- function nestDecoratorArgCount(param, name) {
5246
- for (const dec of nodeDecorators(param)) {
5247
- if (decoratorName(dec) !== name) continue;
5248
- const expr = decoratorExpr(dec);
5249
- if (expr?.type === "CallExpression") return expr.arguments.length;
5250
- return 0;
5772
+ max;
5773
+ map = /* @__PURE__ */ new Map();
5774
+ get size() {
5775
+ return this.map.size;
5251
5776
  }
5252
- return 0;
5253
- }
5254
- function isCtxIdent(node) {
5255
- return node.type === "Identifier" && node.name === "ctx";
5256
- }
5257
- function isCReq(node) {
5258
- return node.type === "MemberExpression" && node.object.type === "Identifier" && node.object.name === "c" && propName(node) === "req";
5259
- }
5260
- function isCtxRequest(node) {
5261
- return node.type === "MemberExpression" && isCtxIdent(node.object) && propName(node) === "request";
5262
- }
5263
- function isFrameworkMemberTaint(node) {
5264
- if (!frameworksEnabled() || node.type !== "MemberExpression") return false;
5265
- const name = propName(node);
5266
- if (name === void 0) return false;
5267
- if (isCtxIdent(node.object) && (REQUEST_SOURCE_PROPS.has(name) || name === "query")) return true;
5268
- if (isCtxRequest(node.object) && REQUEST_SOURCE_PROPS.has(name)) return true;
5269
- return false;
5270
- }
5271
- function isFrameworkTaintCall(node) {
5272
- if (!frameworksEnabled() || node.type !== "CallExpression") return false;
5273
- const name = calleeName(node);
5274
- if (name === void 0 || node.callee.type !== "MemberExpression") return false;
5275
- const obj = node.callee.object;
5276
- if (obj.type === "Identifier" && obj.name === "request" && REQ_READERS.has(name)) return true;
5777
+ has(key) {
5778
+ return this.map.has(key);
5779
+ }
5780
+ get(key) {
5781
+ if (!this.map.has(key)) return void 0;
5782
+ const value = this.map.get(key);
5783
+ this.map.delete(key);
5784
+ this.map.set(key, value);
5785
+ return value;
5786
+ }
5787
+ set(key, value) {
5788
+ if (this.map.has(key)) this.map.delete(key);
5789
+ this.map.set(key, value);
5790
+ while (this.map.size > this.max) {
5791
+ const oldest = this.map.keys().next().value;
5792
+ if (oldest === void 0) break;
5793
+ this.map.delete(oldest);
5794
+ }
5795
+ return this;
5796
+ }
5797
+ };
5798
+ function packageVersion() {
5799
+ let dir = dirname(fileURLToPath(import.meta.url));
5800
+ for (let depth = 0; depth < 6; depth += 1) {
5801
+ try {
5802
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
5803
+ const pkg = JSON.parse(raw);
5804
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
5805
+ return pkg.version;
5806
+ }
5807
+ } catch {
5808
+ }
5809
+ const parent = dirname(dir);
5810
+ if (parent === dir) break;
5811
+ dir = parent;
5812
+ }
5813
+ throw new Error("fullshield package.json not found");
5814
+ }
5815
+
5816
+ // src/core/parse-pool.ts
5817
+ var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
5818
+ var cache = new LruMap(64);
5819
+ function parsePoolEnabled() {
5820
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
5821
+ }
5822
+ function parsePoolSize() {
5823
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
5824
+ if (raw !== void 0 && raw !== "") {
5825
+ const parsed = Number(raw);
5826
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
5827
+ }
5828
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
5829
+ }
5830
+ function workerMemoryMb() {
5831
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
5832
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
5833
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
5834
+ return 512;
5835
+ }
5836
+ function parseCacheDir() {
5837
+ return join(homedir(), ".cache", "fullshield", "parse");
5838
+ }
5839
+ function parseCacheKey(code) {
5840
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
5841
+ }
5842
+ function parseOne(code) {
5843
+ const key = parseCacheKey(code);
5844
+ const hit2 = cache.get(key);
5845
+ if (hit2 !== void 0) return Promise.resolve(hit2);
5846
+ return new Promise((resolve5, reject) => {
5847
+ const worker = new Worker(fileURLToPath(workerUrl), {
5848
+ workerData: { code },
5849
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
5850
+ });
5851
+ let settled = false;
5852
+ const finish = () => {
5853
+ void worker.terminate();
5854
+ };
5855
+ const succeed = (ast) => {
5856
+ if (settled) return;
5857
+ settled = true;
5858
+ cache.set(key, ast);
5859
+ finish();
5860
+ resolve5(ast);
5861
+ };
5862
+ const fail = (error) => {
5863
+ if (settled) return;
5864
+ settled = true;
5865
+ finish();
5866
+ reject(error);
5867
+ };
5868
+ worker.once("message", (ast) => {
5869
+ succeed(ast);
5870
+ });
5871
+ worker.once("error", (error) => {
5872
+ fail(error);
5873
+ });
5874
+ worker.once("exit", (code2) => {
5875
+ if (settled) return;
5876
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
5877
+ });
5878
+ });
5879
+ }
5880
+ async function parseWithPool(codes) {
5881
+ const size = parsePoolSize();
5882
+ const out = new Array(codes.length);
5883
+ let next = 0;
5884
+ async function run() {
5885
+ for (; ; ) {
5886
+ const index = next;
5887
+ next += 1;
5888
+ if (index >= codes.length) return;
5889
+ const code = codes[index];
5890
+ if (code === void 0) return;
5891
+ out[index] = await parseOne(code);
5892
+ }
5893
+ }
5894
+ const workers = Math.min(size, Math.max(1, codes.length));
5895
+ await Promise.all(Array.from({ length: workers }, () => run()));
5896
+ return out;
5897
+ }
5898
+
5899
+ // src/modules/sast/frameworks.ts
5900
+ var NEXT_HANDLERS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
5901
+ var NEST_HTTP = /^(Get|Post|Put|Patch|Delete|All|Head|Options)$/;
5902
+ var NEST_INPUT = /^(Body|Query|Param|Headers)$/;
5903
+ var REQ_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer"]);
5904
+ var HONO_READERS = /* @__PURE__ */ new Set(["query", "param", "header", "json", "parseBody", "text"]);
5905
+ function decoratorExpr(dec) {
5906
+ if (dec.type !== "Decorator") return void 0;
5907
+ return dec.expression;
5908
+ }
5909
+ function decoratorName(dec) {
5910
+ const expr = decoratorExpr(dec);
5911
+ if (expr === void 0) return void 0;
5912
+ if (expr.type === "Identifier") return expr.name;
5913
+ if (expr.type === "CallExpression") return calleeName(expr);
5914
+ return void 0;
5915
+ }
5916
+ function nodeDecorators(node) {
5917
+ const decs = node.decorators;
5918
+ return Array.isArray(decs) ? decs : [];
5919
+ }
5920
+ function nestInputDecorator(param) {
5921
+ if (!frameworksEnabled()) return void 0;
5922
+ for (const dec of nodeDecorators(param)) {
5923
+ const name = decoratorName(dec);
5924
+ if (name !== void 0 && NEST_INPUT.test(name)) return name;
5925
+ }
5926
+ return void 0;
5927
+ }
5928
+ function nestDecoratorArgCount(param, name) {
5929
+ for (const dec of nodeDecorators(param)) {
5930
+ if (decoratorName(dec) !== name) continue;
5931
+ const expr = decoratorExpr(dec);
5932
+ if (expr?.type === "CallExpression") return expr.arguments.length;
5933
+ return 0;
5934
+ }
5935
+ return 0;
5936
+ }
5937
+ function isCtxIdent(node) {
5938
+ return node.type === "Identifier" && node.name === "ctx";
5939
+ }
5940
+ function isCReq(node) {
5941
+ return node.type === "MemberExpression" && node.object.type === "Identifier" && node.object.name === "c" && propName(node) === "req";
5942
+ }
5943
+ function isCtxRequest(node) {
5944
+ return node.type === "MemberExpression" && isCtxIdent(node.object) && propName(node) === "request";
5945
+ }
5946
+ function isFrameworkMemberTaint(node) {
5947
+ if (!frameworksEnabled() || node.type !== "MemberExpression") return false;
5948
+ const name = propName(node);
5949
+ if (name === void 0) return false;
5950
+ if (isCtxIdent(node.object) && (REQUEST_SOURCE_PROPS.has(name) || name === "query")) return true;
5951
+ if (isCtxRequest(node.object) && REQUEST_SOURCE_PROPS.has(name)) return true;
5952
+ return false;
5953
+ }
5954
+ function isFrameworkTaintCall(node) {
5955
+ if (!frameworksEnabled() || node.type !== "CallExpression") return false;
5956
+ const name = calleeName(node);
5957
+ if (name === void 0 || node.callee.type !== "MemberExpression") return false;
5958
+ const obj = node.callee.object;
5959
+ if (obj.type === "Identifier" && obj.name === "request" && REQ_READERS.has(name)) return true;
5277
5960
  if (obj.type === "Identifier" && (obj.name === "formData" || obj.name === "searchParams") && name === "get") {
5278
5961
  return true;
5279
5962
  }
@@ -5348,6 +6031,10 @@ function isMcpArgsTaintSource(node) {
5348
6031
  }
5349
6032
 
5350
6033
  // src/modules/sast/taint.ts
6034
+ var customTaint;
6035
+ function setCustomTaint(config) {
6036
+ customTaint = config;
6037
+ }
5351
6038
  var SCHEMA_ROOT = /^(z|zod|joi|yup|schema)$/i;
5352
6039
  function objectChainMentions(node, pattern) {
5353
6040
  let current = node;
@@ -5493,6 +6180,61 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5493
6180
  return false;
5494
6181
  }
5495
6182
  }
6183
+ function valueFeedsFilter(node, tainted, returners, nosqlSanitized, built, filterReturners) {
6184
+ if (node === void 0) return false;
6185
+ if (isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) return false;
6186
+ if (node.type === "Identifier") {
6187
+ if (nosqlSanitized.has(node.name)) return false;
6188
+ return built.has(node.name) || tainted.has(node.name);
6189
+ }
6190
+ if (isRequestObjectNode(node)) return true;
6191
+ if (node.type === "AwaitExpression") {
6192
+ return valueFeedsFilter(node.argument, tainted, returners, nosqlSanitized, built, filterReturners);
6193
+ }
6194
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && filterReturners.has(node.callee.name)) {
6195
+ return true;
6196
+ }
6197
+ if (node.type === "ObjectExpression") {
6198
+ return node.properties.some((prop) => {
6199
+ if (prop.type === "SpreadElement") {
6200
+ return valueFeedsFilter(prop.argument, tainted, returners, nosqlSanitized, built, filterReturners);
6201
+ }
6202
+ if (prop.type !== "Property") return false;
6203
+ return valueFeedsFilter(prop.value, tainted, returners, nosqlSanitized, built, filterReturners);
6204
+ });
6205
+ }
6206
+ if (node.type === "ArrayExpression") {
6207
+ return node.elements.some((el) => {
6208
+ if (el === null) return false;
6209
+ if (el.type === "SpreadElement") {
6210
+ return valueFeedsFilter(el.argument, tainted, returners, nosqlSanitized, built, filterReturners);
6211
+ }
6212
+ return valueFeedsFilter(el, tainted, returners, nosqlSanitized, built, filterReturners);
6213
+ });
6214
+ }
6215
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
6216
+ return node.arguments.some(
6217
+ (arg) => arg.type !== "SpreadElement" && valueFeedsFilter(arg, tainted, returners, nosqlSanitized, built, filterReturners)
6218
+ );
6219
+ }
6220
+ if (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "MemberExpression") {
6221
+ return exprIsTainted(node, tainted, returners);
6222
+ }
6223
+ return false;
6224
+ }
6225
+ function initIsBuiltFilter(node, tainted, returners, nosqlSanitized, built, filterReturners) {
6226
+ if (node.type === "AwaitExpression") {
6227
+ return initIsBuiltFilter(node.argument, tainted, returners, nosqlSanitized, built, filterReturners);
6228
+ }
6229
+ if (node.type === "Identifier") return built.has(node.name) && !nosqlSanitized.has(node.name);
6230
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && filterReturners.has(node.callee.name)) {
6231
+ return true;
6232
+ }
6233
+ if (node.type === "ObjectExpression" || node.type === "ArrayExpression") {
6234
+ return valueFeedsFilter(node, tainted, returners, nosqlSanitized, built, filterReturners);
6235
+ }
6236
+ 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);
6237
+ }
5496
6238
  function exprIsSanitized(node, tainted, names, isCall, returners = NO_RETURNERS) {
5497
6239
  if (node === void 0) return true;
5498
6240
  if (isCall(node)) return true;
@@ -5556,7 +6298,7 @@ function seedMcpToolParams(scope, tainted) {
5556
6298
  for (const name of boundNames(param)) tainted.add(name);
5557
6299
  }
5558
6300
  }
5559
- function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
6301
+ function analyzeScope(scope, seed = [], returners = NO_RETURNERS, filterReturners = NO_RETURNERS, initialBuilt = NO_RETURNERS) {
5560
6302
  const tainted = new Set(seed);
5561
6303
  seedDestructuredRequestParams(scope, tainted);
5562
6304
  seedFrameworkParams(scope, tainted);
@@ -5564,6 +6306,8 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5564
6306
  const urlSanitized = /* @__PURE__ */ new Set();
5565
6307
  const xssSanitized = /* @__PURE__ */ new Set();
5566
6308
  const nosqlSanitized = /* @__PURE__ */ new Set();
6309
+ const sqlCleared = /* @__PURE__ */ new Set();
6310
+ const customPath = /* @__PURE__ */ new Set();
5567
6311
  let changed = true;
5568
6312
  while (changed) {
5569
6313
  changed = false;
@@ -5574,6 +6318,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5574
6318
  for (const name of boundNames(node.id)) nosqlSanitized.add(name);
5575
6319
  }
5576
6320
  if (isSchemaValidatorCall(init)) return;
6321
+ applyCustomCall(init, boundNames(node.id), tainted, sqlCleared, customPath);
5577
6322
  if (isUrlSanitizerCall(init)) {
5578
6323
  for (const name of boundNames(node.id)) urlSanitized.add(name);
5579
6324
  }
@@ -5594,6 +6339,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5594
6339
  for (const name of boundNames(node.left)) nosqlSanitized.add(name);
5595
6340
  }
5596
6341
  if (isSchemaValidatorCall(node.right)) return;
6342
+ applyCustomCall(node.right, boundNames(node.left), tainted, sqlCleared, customPath);
5597
6343
  if (isUrlSanitizerCall(node.right)) {
5598
6344
  for (const name of boundNames(node.left)) urlSanitized.add(name);
5599
6345
  }
@@ -5620,7 +6366,7 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5620
6366
  for (const name of boundNames(node.left)) resolved.add(name);
5621
6367
  }
5622
6368
  });
5623
- const pathSanitized = /* @__PURE__ */ new Set();
6369
+ const pathSanitized = new Set(customPath);
5624
6370
  walkScope(scope, (node) => {
5625
6371
  if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5626
6372
  if (propName(node.callee) !== "startsWith") return;
@@ -5662,17 +6408,92 @@ function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
5662
6408
  for (const name of boundNames(node.left)) requestObjects.add(name);
5663
6409
  }
5664
6410
  });
6411
+ const builtFilters = new Set(initialBuilt);
6412
+ let grew = true;
6413
+ while (grew) {
6414
+ grew = false;
6415
+ const add = (names) => {
6416
+ for (const name of names) {
6417
+ if (nosqlSanitized.has(name) || builtFilters.has(name)) continue;
6418
+ builtFilters.add(name);
6419
+ grew = true;
6420
+ }
6421
+ };
6422
+ walkScope(scope, (node) => {
6423
+ if (node.type === "VariableDeclarator" && node.init !== null) {
6424
+ if (initIsBuiltFilter(node.init, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
6425
+ add(boundNames(node.id));
6426
+ }
6427
+ }
6428
+ if (node.type !== "AssignmentExpression") return;
6429
+ if (node.left.type === "Identifier" && initIsBuiltFilter(node.right, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
6430
+ add([node.left.name]);
6431
+ }
6432
+ if (node.left.type === "MemberExpression" && node.left.object.type === "Identifier" && valueFeedsFilter(node.right, tainted, returners, nosqlSanitized, builtFilters, filterReturners)) {
6433
+ add([node.left.object.name]);
6434
+ }
6435
+ });
6436
+ }
5665
6437
  return {
5666
6438
  tainted,
5667
6439
  pathSanitized,
6440
+ sqlCleared,
5668
6441
  urlSanitized,
5669
6442
  xssSanitized,
5670
6443
  nosqlSanitized,
5671
6444
  hasPasswordName,
5672
6445
  requestObjects,
6446
+ builtFilters,
6447
+ filterReturners: new Set(filterReturners),
5673
6448
  returners: new Set(returners)
5674
6449
  };
5675
6450
  }
6451
+ function locallyDeclared(scope) {
6452
+ const names = /* @__PURE__ */ new Set();
6453
+ if (isFunctionNode(scope)) {
6454
+ for (const param of scope.params) {
6455
+ for (const name of boundNames(param)) names.add(name);
6456
+ }
6457
+ }
6458
+ walkScope(scope, (node) => {
6459
+ if (node.type === "VariableDeclarator") {
6460
+ for (const name of boundNames(node.id)) names.add(name);
6461
+ }
6462
+ if (node.type === "FunctionDeclaration" && node.id !== null) names.add(node.id.name);
6463
+ });
6464
+ return names;
6465
+ }
6466
+ function inheritedBuiltFilters(fn, facts) {
6467
+ const local = locallyDeclared(fn);
6468
+ const inherited = /* @__PURE__ */ new Set();
6469
+ let parent = fn.parent;
6470
+ while (parent !== void 0 && !isFunctionNode(parent)) {
6471
+ parent = parent.parent;
6472
+ }
6473
+ const outer = parent === void 0 ? void 0 : facts.get(parent);
6474
+ if (outer === void 0) return inherited;
6475
+ for (const name of outer.builtFilters) {
6476
+ if (!local.has(name)) inherited.add(name);
6477
+ }
6478
+ return inherited;
6479
+ }
6480
+ function scopeReturnsBuiltFilter(scope, facts) {
6481
+ let yes = false;
6482
+ walkScope(scope, (node) => {
6483
+ if (node.type !== "ReturnStatement" || node.argument === null) return;
6484
+ if (initIsBuiltFilter(
6485
+ node.argument,
6486
+ facts.tainted,
6487
+ facts.returners,
6488
+ facts.nosqlSanitized,
6489
+ facts.builtFilters,
6490
+ facts.filterReturners
6491
+ )) {
6492
+ yes = true;
6493
+ }
6494
+ });
6495
+ return yes;
6496
+ }
5676
6497
  function functionName(fn) {
5677
6498
  if (fn.type === "FunctionDeclaration" && fn.id !== null) return fn.id.name;
5678
6499
  if (isFunctionNode(fn) && fn.id !== null && fn.id.type === "Identifier") return fn.id.name;
@@ -5762,11 +6583,12 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5762
6583
  const seeds = /* @__PURE__ */ new Map();
5763
6584
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5764
6585
  const returners = new Set(extraReturners2);
6586
+ const filterReturners = /* @__PURE__ */ new Set();
5765
6587
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5766
6588
  let changed = false;
5767
- facts.set(ast, analyzeScope(ast, [], returners));
6589
+ facts.set(ast, analyzeScope(ast, [], returners, filterReturners));
5768
6590
  for (const fn of functions) {
5769
- facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners));
6591
+ facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners, filterReturners, inheritedBuiltFilters(fn, facts)));
5770
6592
  }
5771
6593
  for (const fn of functions) {
5772
6594
  const name = functionName(fn);
@@ -5776,6 +6598,10 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5776
6598
  returners.add(name);
5777
6599
  changed = true;
5778
6600
  }
6601
+ if (scopeReturnsBuiltFilter(fn, f) && !filterReturners.has(name)) {
6602
+ filterReturners.add(name);
6603
+ changed = true;
6604
+ }
5779
6605
  }
5780
6606
  for (const fn of functions) {
5781
6607
  const f = facts.get(fn);
@@ -5808,6 +6634,54 @@ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5808
6634
  }
5809
6635
  return facts;
5810
6636
  }
6637
+ var SQL_SINK_NAMES = /* @__PURE__ */ new Set(["query", "execute"]);
6638
+ var CMD_SINK_NAMES = /* @__PURE__ */ new Set(["exec", "execSync", "spawn", "spawnSync"]);
6639
+ var PATH_SINK_NAMES = /* @__PURE__ */ new Set(["readFile", "readFileSync", "writeFile", "writeFileSync"]);
6640
+ function sinkClasses(scope, tainted, returners) {
6641
+ const found = /* @__PURE__ */ new Set();
6642
+ walkScope(scope, (node) => {
6643
+ if (node.type !== "CallExpression") return;
6644
+ const name = calleeName(node);
6645
+ const first = node.arguments[0];
6646
+ if (name === void 0 || first === void 0 || first.type === "SpreadElement") return;
6647
+ if (!exprIsTainted(first, tainted, returners)) return;
6648
+ if (SQL_SINK_NAMES.has(name)) found.add("sqli");
6649
+ if (CMD_SINK_NAMES.has(name)) found.add("cmdi");
6650
+ if (PATH_SINK_NAMES.has(name)) found.add("path");
6651
+ });
6652
+ return [...found].sort();
6653
+ }
6654
+ function fileTaintSummary(ast) {
6655
+ attachParents(ast);
6656
+ const facts = analyzeFile(ast);
6657
+ const returnsTaint = [];
6658
+ const functions = [];
6659
+ walk(ast, (node) => {
6660
+ if (!isFunctionNode(node)) return;
6661
+ const name = functionName(node);
6662
+ const own = facts.get(node);
6663
+ if (name === void 0 || own === void 0) return;
6664
+ if (scopeReturnsTaint(node, own.tainted, own.returners)) returnsTaint.push(name);
6665
+ const paramsToReturn = [];
6666
+ const sinks = [];
6667
+ node.params.forEach((param, index) => {
6668
+ const pname = boundNames(param)[0];
6669
+ if (pname === void 0) return;
6670
+ const seeded = analyzeScope(node, [pname]);
6671
+ if (scopeReturnsTaint(node, seeded.tainted, seeded.returners)) paramsToReturn.push(index);
6672
+ for (const cls of sinkClasses(node, seeded.tainted, seeded.returners)) sinks.push({ param: index, class: cls });
6673
+ });
6674
+ functions.push({
6675
+ name,
6676
+ line: node.loc?.start.line ?? 0,
6677
+ paramsToReturn,
6678
+ sinks
6679
+ });
6680
+ });
6681
+ returnsTaint.sort();
6682
+ functions.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
6683
+ return { returnsTaint, functions };
6684
+ }
5811
6685
  function functionsThatReturnTaint(ast) {
5812
6686
  attachParents(ast);
5813
6687
  const names = /* @__PURE__ */ new Set();
@@ -5829,18 +6703,50 @@ function factsFor(node, map) {
5829
6703
  return map.get(enclosingScope(node)) ?? {
5830
6704
  tainted: /* @__PURE__ */ new Set(),
5831
6705
  pathSanitized: /* @__PURE__ */ new Set(),
6706
+ sqlCleared: /* @__PURE__ */ new Set(),
5832
6707
  urlSanitized: /* @__PURE__ */ new Set(),
5833
6708
  xssSanitized: /* @__PURE__ */ new Set(),
5834
6709
  nosqlSanitized: /* @__PURE__ */ new Set(),
5835
6710
  hasPasswordName: false,
5836
6711
  requestObjects: /* @__PURE__ */ new Set(),
6712
+ builtFilters: /* @__PURE__ */ new Set(),
6713
+ filterReturners: /* @__PURE__ */ new Set(),
5837
6714
  returners: /* @__PURE__ */ new Set()
5838
6715
  };
5839
6716
  }
6717
+ function nodeHoldsBuiltFilter(node, map) {
6718
+ const facts = factsFor(node, map);
6719
+ if (node.type === "Identifier") return facts.builtFilters.has(node.name);
6720
+ if (node.type === "AwaitExpression") return nodeHoldsBuiltFilter(node.argument, map);
6721
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
6722
+ return facts.filterReturners.has(node.callee.name);
6723
+ }
6724
+ return false;
6725
+ }
5840
6726
  function nodeIsTainted(node, map) {
5841
6727
  const facts = factsFor(node, map);
5842
6728
  return exprIsTainted(node, facts.tainted, facts.returners);
5843
6729
  }
6730
+ function nodeIsSqlCleared(node, map) {
6731
+ if (node.type === "Identifier") return factsFor(node, map).sqlCleared.has(node.name);
6732
+ return false;
6733
+ }
6734
+ function applyCustomCall(init, names, tainted, sqlCleared, customPath) {
6735
+ if (customTaint === void 0 || init === void 0 || init.type !== "CallExpression") return;
6736
+ const called2 = calleeName(init);
6737
+ if (called2 === void 0) return;
6738
+ if (customTaint.sources.has(called2)) {
6739
+ for (const name of names) tainted.add(name);
6740
+ }
6741
+ const clears = customTaint.sanitizers.get(called2);
6742
+ if (clears === void 0) return;
6743
+ if (clears.has("sqli")) {
6744
+ for (const name of names) sqlCleared.add(name);
6745
+ }
6746
+ if (clears.has("path")) {
6747
+ for (const name of names) customPath.add(name);
6748
+ }
6749
+ }
5844
6750
  function nodeIsPathSanitized(node, map) {
5845
6751
  if (node.type !== "Identifier") return false;
5846
6752
  return factsFor(node, map).pathSanitized.has(node.name);
@@ -5863,6 +6769,147 @@ function nodeIsNosqlSanitized(node, map) {
5863
6769
  return false;
5864
6770
  }
5865
6771
 
6772
+ // src/modules/sast/summaries.ts
6773
+ function taintSummariesEnabled() {
6774
+ return process.env.FULLSHIELD_TAINT_SUMMARIES === "1";
6775
+ }
6776
+ var configHash = "";
6777
+ function setTaintSummaryConfig(config) {
6778
+ fileMemo.clear();
6779
+ if (config === void 0) {
6780
+ configHash = "";
6781
+ return;
6782
+ }
6783
+ configHash = createHash("sha256").update(
6784
+ JSON.stringify({
6785
+ maxFileBytes: config.sast.maxFileBytes,
6786
+ sources: config.sast.sources ?? [],
6787
+ sanitizers: config.sast.sanitizers ?? [],
6788
+ taintV2: taintV2Enabled(),
6789
+ frameworks: frameworksEnabled(),
6790
+ gaps: sastGapsEnabled(),
6791
+ classes: classesEnabled(),
6792
+ custom: process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1"
6793
+ })
6794
+ ).digest("hex");
6795
+ }
6796
+ function summaryDir() {
6797
+ return process.env.FULLSHIELD_TAINT_SUMMARY_DIR ?? join(parseCacheDir(), "taint");
6798
+ }
6799
+ function taintSummaryKey(source, extra = "") {
6800
+ return createHash("sha256").update(packageVersion()).update("\0").update(configHash).update("\0").update(extra).update("\0").update(source).digest("hex");
6801
+ }
6802
+ function summaryPath(key) {
6803
+ return join(summaryDir(), `${key}.json`);
6804
+ }
6805
+ function isStringArray(value) {
6806
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
6807
+ }
6808
+ function readJson3(key) {
6809
+ let text;
6810
+ try {
6811
+ text = readFileSync(summaryPath(key), "utf8");
6812
+ } catch {
6813
+ return void 0;
6814
+ }
6815
+ try {
6816
+ return JSON.parse(text);
6817
+ } catch {
6818
+ discard(key);
6819
+ return void 0;
6820
+ }
6821
+ }
6822
+ function discard(key) {
6823
+ rmSync(summaryPath(key), { force: true });
6824
+ }
6825
+ function writeJson(key, value) {
6826
+ const dir = summaryDir();
6827
+ mkdirSync(dir, { recursive: true });
6828
+ writeFileSync(summaryPath(key), JSON.stringify(value));
6829
+ }
6830
+ function readFileSummary(key) {
6831
+ const raw = readJson3(key);
6832
+ if (typeof raw !== "object" || raw === null) {
6833
+ if (raw !== void 0) discard(key);
6834
+ return void 0;
6835
+ }
6836
+ const row = raw;
6837
+ if (row.v !== 1 || !isStringArray(row.returnsTaint) || !Array.isArray(row.functions)) {
6838
+ discard(key);
6839
+ return void 0;
6840
+ }
6841
+ return row;
6842
+ }
6843
+ var fileMemo = /* @__PURE__ */ new Map();
6844
+ function cachedReturners(source, ast) {
6845
+ const key = taintSummaryKey(source, "file");
6846
+ const memo = fileMemo.get(key);
6847
+ if (memo !== void 0) return new Set(memo.returnsTaint);
6848
+ const cached = readFileSummary(key);
6849
+ if (cached !== void 0) {
6850
+ fileMemo.set(key, cached);
6851
+ return new Set(cached.returnsTaint);
6852
+ }
6853
+ const summary = fileTaintSummary(ast);
6854
+ const stored = { v: 1, returnsTaint: summary.returnsTaint, functions: summary.functions };
6855
+ fileMemo.set(key, stored);
6856
+ writeJson(key, stored);
6857
+ return new Set(stored.returnsTaint);
6858
+ }
6859
+ function lists(facts) {
6860
+ const names = (set) => [...set].sort();
6861
+ return {
6862
+ v: 1,
6863
+ tainted: names(facts.tainted),
6864
+ pathSanitized: names(facts.pathSanitized),
6865
+ sqlCleared: names(facts.sqlCleared),
6866
+ urlSanitized: names(facts.urlSanitized),
6867
+ xssSanitized: names(facts.xssSanitized),
6868
+ nosqlSanitized: names(facts.nosqlSanitized),
6869
+ requestObjects: names(facts.requestObjects),
6870
+ builtFilters: names(facts.builtFilters),
6871
+ filterReturners: names(facts.filterReturners),
6872
+ returners: names(facts.returners),
6873
+ hasPasswordName: facts.hasPasswordName
6874
+ };
6875
+ }
6876
+ function factsFrom(row) {
6877
+ return {
6878
+ tainted: new Set(row.tainted),
6879
+ pathSanitized: new Set(row.pathSanitized),
6880
+ sqlCleared: new Set(row.sqlCleared),
6881
+ urlSanitized: new Set(row.urlSanitized),
6882
+ xssSanitized: new Set(row.xssSanitized),
6883
+ nosqlSanitized: new Set(row.nosqlSanitized),
6884
+ requestObjects: new Set(row.requestObjects),
6885
+ builtFilters: new Set(row.builtFilters),
6886
+ filterReturners: new Set(row.filterReturners),
6887
+ returners: new Set(row.returners),
6888
+ hasPasswordName: row.hasPasswordName
6889
+ };
6890
+ }
6891
+ function readFacts(key) {
6892
+ const raw = readJson3(key);
6893
+ if (typeof raw !== "object" || raw === null) {
6894
+ if (raw !== void 0) discard(key);
6895
+ return void 0;
6896
+ }
6897
+ const row = raw;
6898
+ 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") {
6899
+ discard(key);
6900
+ return void 0;
6901
+ }
6902
+ return factsFrom(row);
6903
+ }
6904
+ function cachedFunctionFacts(fileSource, fn, seed) {
6905
+ const key = taintSummaryKey(fileSource, `seed:${fn.loc?.start.line ?? 0}:${seed.join(",")}`);
6906
+ const cached = readFacts(key);
6907
+ if (cached !== void 0) return cached;
6908
+ const facts = analyzeFunctionWithSeed(fn, seed);
6909
+ writeJson(key, lists(facts));
6910
+ return facts;
6911
+ }
6912
+
5866
6913
  // src/modules/sast/interproc.ts
5867
6914
  var MAX_HOPS = 5;
5868
6915
  function collectFunctions(file, ast) {
@@ -5973,22 +7020,22 @@ function collectInstances(ast, ns) {
5973
7020
  if (node.type !== "VariableDeclarator" || node.init === null) return;
5974
7021
  if (node.id.type !== "Identifier" || node.init.type !== "NewExpression") return;
5975
7022
  if (node.init.callee.type !== "Identifier") return;
5976
- const hit = ns.get(node.init.callee.name);
5977
- if (hit === void 0) return;
5978
- instances.set(node.id.name, hit.includes("#") ? hit.split("#")[0] ?? hit : hit);
7023
+ const hit2 = ns.get(node.init.callee.name);
7024
+ if (hit2 === void 0) return;
7025
+ instances.set(node.id.name, hit2.includes("#") ? hit2.split("#")[0] ?? hit2 : hit2);
5979
7026
  });
5980
7027
  return instances;
5981
7028
  }
5982
7029
  function resolveCallee(node, ns, instances, currentFile, functions) {
5983
7030
  if (node.type !== "CallExpression") return void 0;
5984
7031
  if (node.callee.type === "Identifier") {
5985
- const hit = ns.get(node.callee.name);
5986
- if (hit !== void 0) {
5987
- if (hit.includes("#")) {
5988
- const [file, name] = hit.split("#");
7032
+ const hit2 = ns.get(node.callee.name);
7033
+ if (hit2 !== void 0) {
7034
+ if (hit2.includes("#")) {
7035
+ const [file, name] = hit2.split("#");
5989
7036
  if (file !== void 0 && name !== void 0) return { file, name };
5990
7037
  }
5991
- return { file: hit, name: node.callee.name };
7038
+ return { file: hit2, name: node.callee.name };
5992
7039
  }
5993
7040
  if (functions.has(`${currentFile}#${node.callee.name}`)) {
5994
7041
  return { file: currentFile, name: node.callee.name };
@@ -6095,9 +7142,9 @@ function runInterproc(parsed) {
6095
7142
  if (fn === void 0) continue;
6096
7143
  const param = fn.params[seed.paramIndex];
6097
7144
  if (param === void 0) continue;
6098
- const facts = analyzeFunctionWithSeed(fn.node, [param]);
6099
7145
  const p = byFile.get(fn.file);
6100
7146
  if (p === void 0) continue;
7147
+ const facts = taintSummariesEnabled() ? cachedFunctionFacts(p.source, fn.node, [param]) : analyzeFunctionWithSeed(fn.node, [param]);
6101
7148
  const ns = requires.get(fn.file) ?? /* @__PURE__ */ new Map();
6102
7149
  const inst = instances.get(fn.file) ?? /* @__PURE__ */ new Map();
6103
7150
  walk(fn.node, (node) => {
@@ -6520,6 +7567,178 @@ var jwtRule = {
6520
7567
  }
6521
7568
  };
6522
7569
 
7570
+ // src/modules/sast/validation.ts
7571
+ var PRIMITIVE = /* @__PURE__ */ new Set(["string", "number", "boolean", "IsString", "IsNumber", "IsInt", "IsBoolean"]);
7572
+ var OPEN = /* @__PURE__ */ new Set(["any", "unknown", "object", "array", "record", "lazy", "union", "tuple"]);
7573
+ var SCHEMA_ROOT2 = /* @__PURE__ */ new Set(["z", "zod", "Joi", "joi"]);
7574
+ var ROUTE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
7575
+ function validationEnabled() {
7576
+ return process.env.FULLSHIELD_EXPERIMENTAL_VALIDATION === "1";
7577
+ }
7578
+ function parentOf(node) {
7579
+ return node.parent;
7580
+ }
7581
+ function mentions(node, names) {
7582
+ let current = node;
7583
+ for (let i = 0; i < 12 && current !== void 0; i += 1) {
7584
+ if (current.type === "CallExpression") {
7585
+ const name = calleeName(current);
7586
+ if (name !== void 0 && names.has(name)) return true;
7587
+ current = current.callee.type === "MemberExpression" ? current.callee.object : void 0;
7588
+ continue;
7589
+ }
7590
+ if (current.type === "MemberExpression") {
7591
+ const name = propName(current);
7592
+ if (name !== void 0 && names.has(name)) return true;
7593
+ current = current.object;
7594
+ continue;
7595
+ }
7596
+ if (current.type === "Identifier" && names.has(current.name)) return true;
7597
+ break;
7598
+ }
7599
+ return false;
7600
+ }
7601
+ function rootName(node) {
7602
+ let current = node;
7603
+ for (let i = 0; i < 8 && current !== void 0; i += 1) {
7604
+ if (current.type === "Identifier") return current.name;
7605
+ if (current.type === "CallExpression") {
7606
+ current = current.callee;
7607
+ continue;
7608
+ }
7609
+ if (current.type === "MemberExpression") {
7610
+ current = current.object;
7611
+ continue;
7612
+ }
7613
+ break;
7614
+ }
7615
+ return void 0;
7616
+ }
7617
+ function propertyName(prop) {
7618
+ if (prop.type !== "Property") return void 0;
7619
+ const key = prop.key;
7620
+ if (key.type === "Identifier") return key.name;
7621
+ if (key.type === "Literal" && typeof key.value === "string") return key.value;
7622
+ return void 0;
7623
+ }
7624
+ function fieldsOfObjectSchema(node) {
7625
+ if (node.type !== "CallExpression" || calleeName(node) !== "object") return /* @__PURE__ */ new Set();
7626
+ const root = rootName(node.callee);
7627
+ if (root === void 0 || !SCHEMA_ROOT2.has(root)) return /* @__PURE__ */ new Set();
7628
+ const arg = node.arguments[0];
7629
+ if (arg === void 0 || arg.type !== "ObjectExpression") return /* @__PURE__ */ new Set();
7630
+ const fields = /* @__PURE__ */ new Set();
7631
+ for (const prop of arg.properties) {
7632
+ if (prop.type !== "Property") continue;
7633
+ const name = propertyName(prop);
7634
+ if (name === void 0) continue;
7635
+ if (mentions(prop.value, OPEN)) continue;
7636
+ if (mentions(prop.value, PRIMITIVE)) fields.add(name);
7637
+ }
7638
+ return fields;
7639
+ }
7640
+ function decoratorName2(decorator) {
7641
+ const expr = decorator.type === "Decorator" ? decorator.expression : decorator;
7642
+ if (expr === void 0) return void 0;
7643
+ if (expr.type === "CallExpression") return calleeName(expr);
7644
+ if (expr.type === "Identifier") return expr.name;
7645
+ return void 0;
7646
+ }
7647
+ function fieldsOfClass(node) {
7648
+ if (node.type !== "ClassDeclaration" && node.type !== "ClassExpression") return /* @__PURE__ */ new Set();
7649
+ if (node.body.type !== "ClassBody") return /* @__PURE__ */ new Set();
7650
+ const fields = /* @__PURE__ */ new Set();
7651
+ for (const element of node.body.body) {
7652
+ if (element.type !== "PropertyDefinition") continue;
7653
+ const key = element.key;
7654
+ const name = key.type === "Identifier" ? key.name : void 0;
7655
+ if (name === void 0) continue;
7656
+ const decorators = element.decorators ?? [];
7657
+ if (decorators.some((decorator) => PRIMITIVE.has(decoratorName2(decorator) ?? ""))) fields.add(name);
7658
+ }
7659
+ return fields;
7660
+ }
7661
+ function programOf(node) {
7662
+ let current = node;
7663
+ while (current !== void 0 && current.type !== "Program") current = parentOf(current);
7664
+ return current;
7665
+ }
7666
+ function bindings(program) {
7667
+ const map = /* @__PURE__ */ new Map();
7668
+ walk(program, (node) => {
7669
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init !== null && node.init !== void 0) {
7670
+ const fields = fieldsOfObjectSchema(node.init);
7671
+ if (fields.size > 0) map.set(node.id.name, fields);
7672
+ }
7673
+ if (node.type === "ClassDeclaration" && node.id !== null) {
7674
+ const fields = fieldsOfClass(node);
7675
+ if (fields.size > 0) map.set(node.id.name, fields);
7676
+ }
7677
+ });
7678
+ return map;
7679
+ }
7680
+ var bindingCache = /* @__PURE__ */ new WeakMap();
7681
+ function fieldsOfExpr(expr, known) {
7682
+ if (expr.type === "Identifier") return new Set(known.get(expr.name) ?? []);
7683
+ if (expr.type === "ClassExpression" || expr.type === "ClassDeclaration") return fieldsOfClass(expr);
7684
+ const direct = fieldsOfObjectSchema(expr);
7685
+ if (expr.type !== "CallExpression") return direct;
7686
+ for (const arg of expr.arguments) {
7687
+ if (arg.type === "SpreadElement") continue;
7688
+ for (const name of fieldsOfExpr(arg, known)) direct.add(name);
7689
+ }
7690
+ return direct;
7691
+ }
7692
+ function isRouteCall2(node) {
7693
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false;
7694
+ const method = propName(node.callee);
7695
+ if (method === void 0 || !ROUTE_METHODS.has(method)) return false;
7696
+ return node.callee.object.type === "Identifier" && /^(app|router)$/i.test(node.callee.object.name);
7697
+ }
7698
+ function routeOf(node) {
7699
+ let current = node;
7700
+ while (current !== void 0) {
7701
+ const parent = parentOf(current);
7702
+ if (isFunctionNode(current) && parent !== void 0 && isRouteCall2(parent)) return parent;
7703
+ current = parent;
7704
+ }
7705
+ return void 0;
7706
+ }
7707
+ function requestField(node) {
7708
+ if (node.type !== "MemberExpression" || node.computed === true) return void 0;
7709
+ const field = node.property.type === "Identifier" ? node.property.name : void 0;
7710
+ if (field === void 0 || node.object.type !== "MemberExpression" || node.object.computed === true) return void 0;
7711
+ const bag = node.object.property.type === "Identifier" ? node.object.property.name : void 0;
7712
+ if (bag !== "body" && bag !== "query" && bag !== "params") return void 0;
7713
+ if (node.object.object.type !== "Identifier" || node.object.object.name !== "req") return void 0;
7714
+ return field;
7715
+ }
7716
+ var routeCache = /* @__PURE__ */ new WeakMap();
7717
+ function fieldsForRoute(route) {
7718
+ const cached = routeCache.get(route);
7719
+ if (cached !== void 0) return cached;
7720
+ const program = programOf(route);
7721
+ const known = program === void 0 ? /* @__PURE__ */ new Map() : bindingCache.get(program) ?? bindings(program);
7722
+ if (program !== void 0) bindingCache.set(program, known);
7723
+ const fields = /* @__PURE__ */ new Set();
7724
+ if (route.type === "CallExpression") {
7725
+ for (const arg of route.arguments) {
7726
+ if (arg.type === "SpreadElement" || isFunctionNode(arg)) continue;
7727
+ for (const name of fieldsOfExpr(arg, known)) fields.add(name);
7728
+ }
7729
+ }
7730
+ routeCache.set(route, fields);
7731
+ return fields;
7732
+ }
7733
+ function isValidatedRouteField(node) {
7734
+ if (!validationEnabled()) return false;
7735
+ const field = requestField(node);
7736
+ if (field === void 0) return false;
7737
+ const route = routeOf(node);
7738
+ if (route === void 0) return false;
7739
+ return fieldsForRoute(route).has(field);
7740
+ }
7741
+
6523
7742
  // src/modules/sast/rules/nosqli.ts
6524
7743
  var MONGO_SINKS = /* @__PURE__ */ new Set([
6525
7744
  "find",
@@ -6604,6 +7823,7 @@ function isNosqlSafeValue(node, source, isNosqlSanitized) {
6604
7823
  if (isNosqlSanitized(node) || isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) {
6605
7824
  return true;
6606
7825
  }
7826
+ if (isValidatedRouteField(node)) return true;
6607
7827
  return isSanitizeWrap(node, source);
6608
7828
  }
6609
7829
  function objectHasUnsafeTaintedValue(node, isTainted, isSafe) {
@@ -6667,6 +7887,14 @@ var nosqliRule = {
6667
7887
  return;
6668
7888
  }
6669
7889
  if (middleware) return;
7890
+ if (ctx.holdsBuiltFilter(filter)) {
7891
+ ctx.report({
7892
+ node,
7893
+ message: "A Mongo filter built from request input is passed to the query.",
7894
+ confidence: "medium"
7895
+ });
7896
+ return;
7897
+ }
6670
7898
  if (objectHasUnsafeTaintedValue(filter, (n) => ctx.isTainted(n), isSafe)) {
6671
7899
  ctx.report({
6672
7900
  node,
@@ -6843,7 +8071,7 @@ var sqliRule = {
6843
8071
  CallExpression(node) {
6844
8072
  if (node.type !== "CallExpression" || !isSqlSink2(node)) return;
6845
8073
  const sql = node.arguments[0];
6846
- if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql)) {
8074
+ if (sql !== void 0 && sql.type !== "SpreadElement" && ctx.isTainted(sql) && !ctx.isSqlCleared(sql)) {
6847
8075
  ctx.report({
6848
8076
  node,
6849
8077
  message: "Tainted input is concatenated into a SQL string passed to query/execute."
@@ -6993,27 +8221,27 @@ function numberLiteral(node) {
6993
8221
  return node.value;
6994
8222
  }
6995
8223
  function containsIdentMember(node, objectName) {
6996
- let hit = false;
8224
+ let hit2 = false;
6997
8225
  walk(node, (child) => {
6998
8226
  if (child.type === "MemberExpression" && child.object.type === "Identifier" && child.object.name === objectName) {
6999
- hit = true;
8227
+ hit2 = true;
7000
8228
  }
7001
8229
  });
7002
- return hit;
8230
+ return hit2;
7003
8231
  }
7004
8232
  function containsCallNamed(node, name) {
7005
- let hit = false;
8233
+ let hit2 = false;
7006
8234
  walk(node, (child) => {
7007
- if (child.type === "CallExpression" && calleeName(child) === name) hit = true;
8235
+ if (child.type === "CallExpression" && calleeName(child) === name) hit2 = true;
7008
8236
  });
7009
- return hit;
8237
+ return hit2;
7010
8238
  }
7011
8239
  function containsOriginalName(node) {
7012
- let hit = false;
8240
+ let hit2 = false;
7013
8241
  walk(node, (child) => {
7014
- if (child.type === "MemberExpression" && propName(child) === "originalname") hit = true;
8242
+ if (child.type === "MemberExpression" && propName(child) === "originalname") hit2 = true;
7015
8243
  });
7016
- return hit;
8244
+ return hit2;
7017
8245
  }
7018
8246
  function schemaLooksSafe(schema) {
7019
8247
  if (schema === void 0) return false;
@@ -7580,188 +8808,58 @@ function reportGqlServer(ctx, node, optsArg, mentionsProduction) {
7580
8808
  const opts = optsArg !== void 0 && optsArg.type !== "SpreadElement" ? optsArg : void 0;
7581
8809
  const introspection = objectProp2(opts, "introspection");
7582
8810
  const rules = objectProp2(opts, "validationRules");
7583
- const hasDepth = rules !== void 0 && containsCallNamed(rules, "depthLimit") || objectProp2(opts, "depthLimit") !== void 0 || /depthLimit|createComplexity|queryComplexity|costAnalysis/.test(ctx.source);
7584
- const introOn = boolLiteral(introspection) === true;
7585
- if (introOn && mentionsProduction) {
7586
- ctx.report({ node, message: "GraphQL introspection is enabled in production." });
7587
- }
7588
- if (!hasDepth) {
7589
- ctx.report({ node, message: "GraphQL server has no depth or complexity limit." });
7590
- }
7591
- }
7592
- var phase29Rules = [
7593
- gated(redosRule),
7594
- gated(deserRule),
7595
- gated(xxeRule),
7596
- gated(uploadRule),
7597
- gated(cookieRule),
7598
- gated(csrfRule),
7599
- gated(massAssignRule),
7600
- gated(timingRule),
7601
- gated(hashRule),
7602
- gated(gqlRule)
7603
- ];
7604
-
7605
- // src/ai/injection.ts
7606
- init_finding();
7607
- var PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
7608
- function isCommentLine(line) {
7609
- const trimmed = line.trim();
7610
- return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
7611
- }
7612
- function scanPromptInjection(file, source) {
7613
- const findings = [];
7614
- const lines = source.split(/\r?\n/);
7615
- lines.forEach((line, index) => {
7616
- if (!isCommentLine(line)) return;
7617
- if (!PHRASES.some((p) => p.test(line))) return;
7618
- findings.push({
7619
- id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
7620
- ruleId: "AI-INJECT-001",
7621
- module: "sast",
7622
- title: "Prompt-injection comment aimed at AI reviewers",
7623
- severity: "medium",
7624
- confidence: "high",
7625
- file,
7626
- line: index + 1,
7627
- snippet: line.trim().slice(0, 120),
7628
- message: "Code contains text attempting to manipulate AI reviewers.",
7629
- remediation: "Remove comments that try to instruct an AI reviewer.",
7630
- references: ["AI-INJECT-001"],
7631
- source: "deterministic"
7632
- });
7633
- });
7634
- return findings;
7635
- }
7636
-
7637
- // src/runtime/lru.ts
7638
- var LruMap = class {
7639
- constructor(max) {
7640
- this.max = max;
7641
- if (max < 1) throw new Error("LruMap max must be >= 1");
7642
- }
7643
- max;
7644
- map = /* @__PURE__ */ new Map();
7645
- get size() {
7646
- return this.map.size;
7647
- }
7648
- has(key) {
7649
- return this.map.has(key);
7650
- }
7651
- get(key) {
7652
- if (!this.map.has(key)) return void 0;
7653
- const value = this.map.get(key);
7654
- this.map.delete(key);
7655
- this.map.set(key, value);
7656
- return value;
7657
- }
7658
- set(key, value) {
7659
- if (this.map.has(key)) this.map.delete(key);
7660
- this.map.set(key, value);
7661
- while (this.map.size > this.max) {
7662
- const oldest = this.map.keys().next().value;
7663
- if (oldest === void 0) break;
7664
- this.map.delete(oldest);
7665
- }
7666
- return this;
7667
- }
7668
- };
7669
- function packageVersion() {
7670
- let dir = dirname(fileURLToPath(import.meta.url));
7671
- for (let depth = 0; depth < 6; depth += 1) {
7672
- try {
7673
- const raw = readFileSync(join(dir, "package.json"), "utf8");
7674
- const pkg = JSON.parse(raw);
7675
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7676
- return pkg.version;
7677
- }
7678
- } catch {
7679
- }
7680
- const parent = dirname(dir);
7681
- if (parent === dir) break;
7682
- dir = parent;
7683
- }
7684
- throw new Error("fullshield package.json not found");
7685
- }
7686
-
7687
- // src/core/parse-pool.ts
7688
- var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7689
- var cache = new LruMap(64);
7690
- function parsePoolEnabled() {
7691
- return process.env.FULLSHIELD_PARSE_POOL === "1";
7692
- }
7693
- function parsePoolSize() {
7694
- const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7695
- if (raw !== void 0 && raw !== "") {
7696
- const parsed = Number(raw);
7697
- if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
8811
+ const hasDepth = rules !== void 0 && containsCallNamed(rules, "depthLimit") || objectProp2(opts, "depthLimit") !== void 0 || /depthLimit|createComplexity|queryComplexity|costAnalysis/.test(ctx.source);
8812
+ const introOn = boolLiteral(introspection) === true;
8813
+ if (introOn && mentionsProduction) {
8814
+ ctx.report({ node, message: "GraphQL introspection is enabled in production." });
8815
+ }
8816
+ if (!hasDepth) {
8817
+ ctx.report({ node, message: "GraphQL server has no depth or complexity limit." });
7698
8818
  }
7699
- return Math.min(2, Math.max(1, availableParallelism() - 1));
7700
- }
7701
- function workerMemoryMb() {
7702
- const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7703
- const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7704
- if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7705
- return 512;
7706
8819
  }
7707
- function parseCacheKey(code) {
7708
- return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
8820
+ var phase29Rules = [
8821
+ gated(redosRule),
8822
+ gated(deserRule),
8823
+ gated(xxeRule),
8824
+ gated(uploadRule),
8825
+ gated(cookieRule),
8826
+ gated(csrfRule),
8827
+ gated(massAssignRule),
8828
+ gated(timingRule),
8829
+ gated(hashRule),
8830
+ gated(gqlRule)
8831
+ ];
8832
+
8833
+ // src/ai/injection.ts
8834
+ init_finding();
8835
+ var PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
8836
+ function isCommentLine(line) {
8837
+ const trimmed = line.trim();
8838
+ return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
7709
8839
  }
7710
- function parseOne(code) {
7711
- const key = parseCacheKey(code);
7712
- const hit = cache.get(key);
7713
- if (hit !== void 0) return Promise.resolve(hit);
7714
- return new Promise((resolve5, reject) => {
7715
- const worker = new Worker(fileURLToPath(workerUrl), {
7716
- workerData: { code },
7717
- resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7718
- });
7719
- let settled = false;
7720
- const finish = () => {
7721
- void worker.terminate();
7722
- };
7723
- const succeed = (ast) => {
7724
- if (settled) return;
7725
- settled = true;
7726
- cache.set(key, ast);
7727
- finish();
7728
- resolve5(ast);
7729
- };
7730
- const fail = (error) => {
7731
- if (settled) return;
7732
- settled = true;
7733
- finish();
7734
- reject(error);
7735
- };
7736
- worker.once("message", (ast) => {
7737
- succeed(ast);
7738
- });
7739
- worker.once("error", (error) => {
7740
- fail(error);
7741
- });
7742
- worker.once("exit", (code2) => {
7743
- if (settled) return;
7744
- fail(new Error(`parse worker exited ${code2 ?? "null"}`));
8840
+ function scanPromptInjection(file, source) {
8841
+ const findings = [];
8842
+ const lines = source.split(/\r?\n/);
8843
+ lines.forEach((line, index) => {
8844
+ if (!isCommentLine(line)) return;
8845
+ if (!PHRASES.some((p) => p.test(line))) return;
8846
+ findings.push({
8847
+ id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
8848
+ ruleId: "AI-INJECT-001",
8849
+ module: "sast",
8850
+ title: "Prompt-injection comment aimed at AI reviewers",
8851
+ severity: "medium",
8852
+ confidence: "high",
8853
+ file,
8854
+ line: index + 1,
8855
+ snippet: line.trim().slice(0, 120),
8856
+ message: "Code contains text attempting to manipulate AI reviewers.",
8857
+ remediation: "Remove comments that try to instruct an AI reviewer.",
8858
+ references: ["AI-INJECT-001"],
8859
+ source: "deterministic"
7745
8860
  });
7746
8861
  });
7747
- }
7748
- async function parseWithPool(codes) {
7749
- const size = parsePoolSize();
7750
- const out = new Array(codes.length);
7751
- let next = 0;
7752
- async function run() {
7753
- for (; ; ) {
7754
- const index = next;
7755
- next += 1;
7756
- if (index >= codes.length) return;
7757
- const code = codes[index];
7758
- if (code === void 0) return;
7759
- out[index] = await parseOne(code);
7760
- }
7761
- }
7762
- const workers = Math.min(size, Math.max(1, codes.length));
7763
- await Promise.all(Array.from({ length: workers }, () => run()));
7764
- return out;
8862
+ return findings;
7765
8863
  }
7766
8864
 
7767
8865
  // src/modules/sast/engine.ts
@@ -7823,10 +8921,12 @@ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ *
7823
8921
  });
7824
8922
  },
7825
8923
  isTainted: (node) => nodeIsTainted(node, facts),
8924
+ isSqlCleared: (node) => nodeIsSqlCleared(node, facts),
7826
8925
  isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
7827
8926
  isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
7828
8927
  isNosqlSanitized: (node) => nodeIsNosqlSanitized(node, facts),
7829
8928
  isRequestObject: (node) => nodeIsRequestObject(node, facts),
8929
+ holdsBuiltFilter: (node) => nodeHoldsBuiltFilter(node, facts),
7830
8930
  functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
7831
8931
  assignedName: (node) => assignedNameOf(node),
7832
8932
  projectCsrfProtected
@@ -7864,10 +8964,12 @@ function findingsFromTemplate(filename, source) {
7864
8964
  });
7865
8965
  },
7866
8966
  isTainted: () => false,
8967
+ isSqlCleared: () => false,
7867
8968
  isPathSanitized: () => false,
7868
8969
  isSanitizedFor: () => false,
7869
8970
  isNosqlSanitized: () => false,
7870
8971
  isRequestObject: () => false,
8972
+ holdsBuiltFilter: () => false,
7871
8973
  functionHasPasswordName: () => false,
7872
8974
  assignedName: () => void 0,
7873
8975
  projectCsrfProtected: false
@@ -7999,7 +9101,7 @@ async function runSastStreaming(ctx) {
7999
9101
  }
8000
9102
  const ast = await parseCode(source);
8001
9103
  if (ast === void 0) continue;
8002
- returning.set(rel, functionsThatReturnTaint(ast));
9104
+ returning.set(rel, taintSummariesEnabled() ? cachedReturners(source, ast) : functionsThatReturnTaint(ast));
8003
9105
  parseable.push(rel);
8004
9106
  fileSet.add(rel);
8005
9107
  }
@@ -8030,48 +9132,69 @@ async function runSastStreaming(ctx) {
8030
9132
  var sastModule = {
8031
9133
  name: "sast",
8032
9134
  async run(ctx) {
8033
- const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
8034
- if (candidates.length > sastStreamThreshold()) return runSastStreaming(ctx);
8035
- const maxBytes = ctx.config.sast.maxFileBytes;
8036
- const findings = [];
8037
- const parsed = [];
8038
- for (const rel of candidates) {
8039
- const source = await readScanSource(ctx.cwd, rel);
8040
- if (source === void 0) continue;
8041
- const why = skipReason(source, maxBytes);
8042
- if (why !== void 0) {
8043
- findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
8044
- continue;
8045
- }
8046
- if (TEMPLATE_EXT.test(rel)) {
8047
- findings.push(...findingsFromTemplate(rel, source));
8048
- continue;
8049
- }
8050
- const ast = await parseCode(source);
8051
- if (ast === void 0) continue;
8052
- parsed.push({ file: rel, source, ast });
8053
- }
8054
- const files = new Set(parsed.map((p) => p.file));
8055
- const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
8056
- const returning = /* @__PURE__ */ new Map();
8057
- for (const entry of parsed) {
8058
- returning.set(entry.file, functionsThatReturnTaint(entry.ast));
9135
+ if (process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1") {
9136
+ const sources = new Set((ctx.config.sast.sources ?? []).map((row) => row.callee));
9137
+ const sanitizers = /* @__PURE__ */ new Map();
9138
+ for (const row of ctx.config.sast.sanitizers ?? []) {
9139
+ const classes = sanitizers.get(row.callee) ?? /* @__PURE__ */ new Set();
9140
+ for (const name of row.clears) classes.add(name);
9141
+ sanitizers.set(row.callee, classes);
9142
+ }
9143
+ setCustomTaint({ sources, sanitizers });
9144
+ } else {
9145
+ setCustomTaint(void 0);
8059
9146
  }
8060
- for (const entry of parsed) {
8061
- findings.push(
8062
- ...findingsFromAst(
9147
+ setTaintSummaryConfig(taintSummariesEnabled() ? ctx.config : void 0);
9148
+ try {
9149
+ const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
9150
+ if (candidates.length > sastStreamThreshold()) return await runSastStreaming(ctx);
9151
+ const maxBytes = ctx.config.sast.maxFileBytes;
9152
+ const findings = [];
9153
+ const parsed = [];
9154
+ for (const rel of candidates) {
9155
+ const source = await readScanSource(ctx.cwd, rel);
9156
+ if (source === void 0) continue;
9157
+ const why = skipReason(source, maxBytes);
9158
+ if (why !== void 0) {
9159
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
9160
+ continue;
9161
+ }
9162
+ if (TEMPLATE_EXT.test(rel)) {
9163
+ findings.push(...findingsFromTemplate(rel, source));
9164
+ continue;
9165
+ }
9166
+ const ast = await parseCode(source);
9167
+ if (ast === void 0) continue;
9168
+ parsed.push({ file: rel, source, ast });
9169
+ }
9170
+ const files = new Set(parsed.map((p) => p.file));
9171
+ const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
9172
+ const returning = /* @__PURE__ */ new Map();
9173
+ for (const entry of parsed) {
9174
+ returning.set(
8063
9175
  entry.file,
8064
- entry.source,
8065
- entry.ast,
8066
- extraReturners(entry.file, entry.ast, files, returning),
8067
- projectCsrf
8068
- )
8069
- );
9176
+ taintSummariesEnabled() ? cachedReturners(entry.source, entry.ast) : functionsThatReturnTaint(entry.ast)
9177
+ );
9178
+ }
9179
+ for (const entry of parsed) {
9180
+ findings.push(
9181
+ ...findingsFromAst(
9182
+ entry.file,
9183
+ entry.source,
9184
+ entry.ast,
9185
+ extraReturners(entry.file, entry.ast, files, returning),
9186
+ projectCsrf
9187
+ )
9188
+ );
9189
+ }
9190
+ findings.push(...runInterproc(parsed));
9191
+ const out = dedupeSastSinks(findings);
9192
+ releaseParsed(parsed);
9193
+ return out;
9194
+ } finally {
9195
+ setCustomTaint(void 0);
9196
+ setTaintSummaryConfig(void 0);
8070
9197
  }
8071
- findings.push(...runInterproc(parsed));
8072
- const out = dedupeSastSinks(findings);
8073
- releaseParsed(parsed);
8074
- return out;
8075
9198
  }
8076
9199
  };
8077
9200
 
@@ -8365,12 +9488,12 @@ ${text}`);
8365
9488
  const seeds = fixtureLogic(prompt);
8366
9489
  const findings = [];
8367
9490
  for (const seed of seeds) {
8368
- const hit = fileTexts.find((f) => f.file.includes(seed.fileHint) && f.text.includes(seed.fileHint === "user" ? "findById" : "Object.assign")) ?? fileTexts.find(
9491
+ const hit2 = fileTexts.find((f) => f.file.includes(seed.fileHint) && f.text.includes(seed.fileHint === "user" ? "findById" : "Object.assign")) ?? fileTexts.find(
8369
9492
  (f) => f.text.includes(seed.ruleId === "AI-IDOR-001" ? "findById(req.params.id)" : "Object.assign(user, req.body)")
8370
9493
  );
8371
- if (hit === void 0) continue;
8372
- const line = hit.text.split(/\r?\n/).findIndex((l) => l.includes(seed.ruleId === "AI-IDOR-001" ? "findById" : "Object.assign")) + 1;
8373
- findings.push(asAiFinding(seed, hit.file, line > 0 ? line : 1));
9494
+ if (hit2 === void 0) continue;
9495
+ const line = hit2.text.split(/\r?\n/).findIndex((l) => l.includes(seed.ruleId === "AI-IDOR-001" ? "findById" : "Object.assign")) + 1;
9496
+ findings.push(asAiFinding(seed, hit2.file, line > 0 ? line : 1));
8374
9497
  }
8375
9498
  return findings;
8376
9499
  }
@@ -8634,6 +9757,56 @@ var KNOWN_KEYS = /* @__PURE__ */ new Set([
8634
9757
  "ai",
8635
9758
  "sast"
8636
9759
  ]);
9760
+ var SAST_CLASSES = /* @__PURE__ */ new Set([
9761
+ "sqli",
9762
+ "nosqli",
9763
+ "cmdi",
9764
+ "path",
9765
+ "xss",
9766
+ "ssrf",
9767
+ "redirect",
9768
+ "eval",
9769
+ "jwt",
9770
+ "crypto",
9771
+ "proto",
9772
+ "secrets",
9773
+ "deps",
9774
+ "config"
9775
+ ]);
9776
+ function parseSastSources(value, source) {
9777
+ if (value === void 0) return void 0;
9778
+ if (!Array.isArray(value)) throw new ConfigError(`${source}: "sast.sources" must be an array`);
9779
+ return value.map((item) => {
9780
+ if (typeof item !== "object" || item === null) {
9781
+ throw new ConfigError(`${source}: "sast.sources" entries must be objects`);
9782
+ }
9783
+ const row = item;
9784
+ if (typeof row.callee !== "string" || row.callee.length === 0) {
9785
+ throw new ConfigError(`${source}: "sast.sources[].callee" must be a string`);
9786
+ }
9787
+ if (row.returns !== "tainted") {
9788
+ throw new ConfigError(`${source}: "sast.sources[].returns" must be "tainted"`);
9789
+ }
9790
+ return { callee: row.callee, returns: "tainted" };
9791
+ });
9792
+ }
9793
+ function parseSastSanitizers(value, source) {
9794
+ if (value === void 0) return void 0;
9795
+ if (!Array.isArray(value)) throw new ConfigError(`${source}: "sast.sanitizers" must be an array`);
9796
+ return value.map((item) => {
9797
+ if (typeof item !== "object" || item === null) {
9798
+ throw new ConfigError(`${source}: "sast.sanitizers" entries must be objects`);
9799
+ }
9800
+ const row = item;
9801
+ if (typeof row.callee !== "string" || row.callee.length === 0) {
9802
+ throw new ConfigError(`${source}: "sast.sanitizers[].callee" must be a string`);
9803
+ }
9804
+ if (!Array.isArray(row.clears) || row.clears.some((name) => typeof name !== "string" || !SAST_CLASSES.has(name))) {
9805
+ throw new ConfigError(`${source}: "sast.sanitizers[].clears" must be B4 class names`);
9806
+ }
9807
+ return { callee: row.callee, clears: row.clears.filter((name) => name !== "secrets" && name !== "deps") };
9808
+ });
9809
+ }
8637
9810
  function validate(raw, source) {
8638
9811
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
8639
9812
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -8759,6 +9932,16 @@ function validate(raw, source) {
8759
9932
  }
8760
9933
  sast.maxFileBytes = rawSast.maxFileBytes;
8761
9934
  }
9935
+ const custom = process.env.FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS === "1";
9936
+ if (!custom && (rawSast.sources !== void 0 || rawSast.sanitizers !== void 0)) {
9937
+ throw new ConfigError(
9938
+ `${source}: unknown key "sast.sources" (FULLSHIELD_EXPERIMENTAL_CUSTOM_SANITIZERS)`
9939
+ );
9940
+ }
9941
+ if (custom) {
9942
+ sast.sources = parseSastSources(rawSast.sources, source);
9943
+ sast.sanitizers = parseSastSanitizers(rawSast.sanitizers, source);
9944
+ }
8762
9945
  out.sast = sast;
8763
9946
  }
8764
9947
  return out;
@@ -9094,6 +10277,8 @@ function dedupe2(findings) {
9094
10277
  async function runScan(options = {}, modules = builtinModules) {
9095
10278
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9096
10279
  const t0 = performance.now();
10280
+ depsModule.unusedDirect = void 0;
10281
+ depsModule.upgradePlan = void 0;
9097
10282
  const cwd = resolve(options.cwd ?? process.cwd());
9098
10283
  const stats = await stat(cwd).catch(() => null);
9099
10284
  if (stats === null || !stats.isDirectory()) {
@@ -9192,8 +10377,8 @@ async function runScan(options = {}, modules = builtinModules) {
9192
10377
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
9193
10378
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
9194
10379
  const secretById = /* @__PURE__ */ new Map();
9195
- for (const hit of extraHits) {
9196
- if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
10380
+ for (const hit2 of extraHits) {
10381
+ if (hit2.secret !== void 0) secretById.set(hit2.finding.id, hit2.secret);
9197
10382
  }
9198
10383
  for (const finding3 of findings) {
9199
10384
  if (finding3.module !== "secrets" || finding3.file === void 0 || secretById.has(finding3.id)) {
@@ -9207,8 +10392,8 @@ async function runScan(options = {}, modules = builtinModules) {
9207
10392
  continue;
9208
10393
  }
9209
10394
  }
9210
- for (const hit of scanTextForSecretHits2(finding3.file, text)) {
9211
- if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
10395
+ for (const hit2 of scanTextForSecretHits2(finding3.file, text)) {
10396
+ if (hit2.secret !== void 0) secretById.set(hit2.finding.id, hit2.secret);
9212
10397
  }
9213
10398
  }
9214
10399
  findings = sortFindings(
@@ -9260,7 +10445,9 @@ async function runScan(options = {}, modules = builtinModules) {
9260
10445
  filesScanned: files.length,
9261
10446
  modulesRequested: [...modulesRequested],
9262
10447
  modulesRun,
9263
- ...historyNotice !== void 0 ? { history: historyNotice } : {}
10448
+ ...historyNotice !== void 0 ? { history: historyNotice } : {},
10449
+ ...depsModule.unusedDirect !== void 0 ? { unusedDirect: depsModule.unusedDirect } : {},
10450
+ ...depsModule.upgradePlan !== void 0 ? { upgradePlan: depsModule.upgradePlan } : {}
9264
10451
  },
9265
10452
  config,
9266
10453
  policy: policyResult,
@@ -9474,6 +10661,28 @@ async function runFix(options = {}) {
9474
10661
  const after = applyUnifiedDiff(source, proposed.diff);
9475
10662
  byFile.set(sourceFile, after);
9476
10663
  }
10664
+ const unused = result.metadata.unusedDirect?.packages ?? [];
10665
+ if (unused.length > 0) {
10666
+ let manifest = byFile.get("package.json");
10667
+ if (manifest === void 0) {
10668
+ try {
10669
+ manifest = await readFile(join(cwd, "package.json"), "utf8");
10670
+ } catch {
10671
+ manifest = void 0;
10672
+ }
10673
+ }
10674
+ if (manifest !== void 0) {
10675
+ const next = removeDirectDependencies(manifest, unused);
10676
+ const diff = dependencyRemovalDiff(manifest, unused);
10677
+ if (diff.length > 0) {
10678
+ patches.push({ file: "package.json", ruleId: "DEPS-VULN-001", safe: false, diff });
10679
+ if (options.unsafe === true) {
10680
+ byFile.set("package.json", next);
10681
+ appliedOverride = true;
10682
+ }
10683
+ }
10684
+ }
10685
+ }
9477
10686
  const notice = appliedOverride ? "run npm install" : void 0;
9478
10687
  if (options.apply !== true) {
9479
10688
  return { applied: false, reverted: false, patches, findings: result.findings, notice };
@@ -9521,6 +10730,29 @@ async function restoreSnapshots(cwd, snapshots) {
9521
10730
  }
9522
10731
  }
9523
10732
  }
10733
+ function dependencyRemovalDiff(manifest, names) {
10734
+ const drop = names.map((name) => `"${name}"`);
10735
+ const removed = manifest.split("\n").filter((line) => drop.some((token) => line.includes(token)));
10736
+ if (removed.length === 0) return "";
10737
+ return ["--- a/package.json", "+++ b/package.json", ...removed.map((line) => `-${line}`)].join("\n");
10738
+ }
10739
+ function removeDirectDependencies(manifest, names) {
10740
+ let parsed;
10741
+ try {
10742
+ parsed = JSON.parse(manifest);
10743
+ } catch {
10744
+ return manifest;
10745
+ }
10746
+ if (parsed.dependencies === void 0 || typeof parsed.dependencies !== "object") return manifest;
10747
+ const drop = new Set(names);
10748
+ const next = {};
10749
+ for (const [name, version] of Object.entries(parsed.dependencies)) {
10750
+ if (!drop.has(name)) next[name] = version;
10751
+ }
10752
+ parsed.dependencies = next;
10753
+ const trailing = manifest.endsWith("\n") ? "\n" : "";
10754
+ return `${JSON.stringify(parsed, null, 2)}${trailing}`;
10755
+ }
9524
10756
  function runProjectTests(cwd) {
9525
10757
  let pkg;
9526
10758
  try {
@@ -9660,7 +10892,159 @@ function renderFinding(finding3) {
9660
10892
  }
9661
10893
  return lines;
9662
10894
  }
9663
- function renderConsoleReport(input) {
10895
+ var SEVERITY_RANK = {
10896
+ critical: 0,
10897
+ high: 1,
10898
+ medium: 2,
10899
+ low: 3,
10900
+ info: 4
10901
+ };
10902
+ var SHOWN_VULN_REACH = /* @__PURE__ */ new Set([
10903
+ "reachable",
10904
+ "symbol-reachable",
10905
+ "reachable-via-dependency",
10906
+ "reachability-unknown"
10907
+ ]);
10908
+ function packageNameFromSnippet3(snippet) {
10909
+ if (snippet === void 0 || snippet.length === 0) return void 0;
10910
+ if (snippet.startsWith("@")) {
10911
+ const second = snippet.indexOf("@", 1);
10912
+ return second === -1 ? snippet : snippet.slice(0, second);
10913
+ }
10914
+ const at = snippet.indexOf("@");
10915
+ return at <= 0 ? snippet : snippet.slice(0, at);
10916
+ }
10917
+ function versionsFromSnippet(snippet) {
10918
+ const name = packageNameFromSnippet3(snippet);
10919
+ if (name === void 0 || snippet === void 0) return [];
10920
+ const rest = snippet.slice(name.length + 1);
10921
+ return rest.length === 0 ? [] : rest.split(",").filter((version) => version.length > 0);
10922
+ }
10923
+ function isDevOnly2(finding3) {
10924
+ return finding3.message.includes("(dev-only)");
10925
+ }
10926
+ function isActionable(finding3) {
10927
+ if (finding3.ruleId === "DEPS-MALICIOUS-001") return true;
10928
+ if (finding3.ruleId === "DEPS-VULN-001") {
10929
+ if (isDevOnly2(finding3)) return false;
10930
+ return finding3.reachability !== void 0 && SHOWN_VULN_REACH.has(finding3.reachability);
10931
+ }
10932
+ if (finding3.module === "sast" || finding3.module === "secrets" || finding3.module === "config") {
10933
+ return finding3.severity === "high" || finding3.severity === "critical" || finding3.confidence === "high";
10934
+ }
10935
+ return false;
10936
+ }
10937
+ function reachRank(finding3) {
10938
+ const reachability = finding3.reachability;
10939
+ if (reachability === "reachable" || reachability === "symbol-reachable") return 0;
10940
+ if (reachability === "reachable-via-dependency") return 1;
10941
+ if (reachability === "reachability-unknown") return 2;
10942
+ if (reachability === void 0) return 4;
10943
+ return 3;
10944
+ }
10945
+ function compareActionable(a, b) {
10946
+ const maliciousA = a.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
10947
+ const maliciousB = b.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
10948
+ 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);
10949
+ }
10950
+ function highestSeverity(findings) {
10951
+ return findings.reduce(
10952
+ (best, finding3) => SEVERITY_RANK[finding3.severity] < SEVERITY_RANK[best] ? finding3.severity : best,
10953
+ findings[0]?.severity ?? "info"
10954
+ );
10955
+ }
10956
+ function bestReachability(findings) {
10957
+ let best;
10958
+ let bestRank = 5;
10959
+ for (const finding3 of findings) {
10960
+ const rank = reachRank(finding3);
10961
+ if (finding3.reachability !== void 0 && rank < bestRank) {
10962
+ best = finding3.reachability;
10963
+ bestRank = rank;
10964
+ }
10965
+ }
10966
+ return best;
10967
+ }
10968
+ function shownConsoleRows(findings) {
10969
+ return dependencyRows(findings.filter(isActionable));
10970
+ }
10971
+ function dependencyRows(findings) {
10972
+ const groups = /* @__PURE__ */ new Map();
10973
+ const singles = [];
10974
+ for (const finding3 of findings) {
10975
+ const name = packageNameFromSnippet3(finding3.snippet);
10976
+ if ((finding3.ruleId === "DEPS-VULN-001" || finding3.ruleId === "DEPS-MALICIOUS-001") && name !== void 0) {
10977
+ const list = groups.get(name);
10978
+ if (list === void 0) groups.set(name, [finding3]);
10979
+ else list.push(finding3);
10980
+ } else {
10981
+ singles.push(finding3);
10982
+ }
10983
+ }
10984
+ const rows = singles.map((finding3) => ({
10985
+ findings: [finding3],
10986
+ severity: finding3.severity,
10987
+ reachability: finding3.reachability,
10988
+ ruleId: finding3.ruleId,
10989
+ file: finding3.file,
10990
+ line: finding3.line,
10991
+ versions: [],
10992
+ grouped: false
10993
+ }));
10994
+ for (const [packageName, list] of groups) {
10995
+ const ordered = [...list].sort(compareActionable);
10996
+ const first = ordered[0];
10997
+ if (first === void 0) continue;
10998
+ const versions = [...new Set(list.flatMap((finding3) => versionsFromSnippet(finding3.snippet)))].sort();
10999
+ const malicious = list.find((finding3) => finding3.ruleId === "DEPS-MALICIOUS-001");
11000
+ rows.push({
11001
+ findings: ordered,
11002
+ severity: highestSeverity(list),
11003
+ reachability: bestReachability(list),
11004
+ ruleId: malicious?.ruleId ?? "DEPS-VULN-001",
11005
+ file: first.file,
11006
+ line: first.line,
11007
+ packageName,
11008
+ versions,
11009
+ path: first.dependencyPath,
11010
+ grouped: true
11011
+ });
11012
+ }
11013
+ rows.sort((a, b) => {
11014
+ const probeA = a.findings[0];
11015
+ const probeB = b.findings[0];
11016
+ if (probeA === void 0 || probeB === void 0) return 0;
11017
+ const maliciousA = a.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
11018
+ const maliciousB = b.ruleId === "DEPS-MALICIOUS-001" ? 0 : 1;
11019
+ 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);
11020
+ });
11021
+ return rows;
11022
+ }
11023
+ function renderRow(row) {
11024
+ if (!row.grouped) {
11025
+ const only = row.findings[0];
11026
+ if (only === void 0) return [];
11027
+ return [...renderFinding(only), ` docs: rules/${only.ruleId}.md`];
11028
+ }
11029
+ const reach = row.reachability ?? "unspecified";
11030
+ const lines = [
11031
+ ` ${pc.bold(`[${row.ruleId}]`)} ${row.packageName} \u2014 ${row.findings.length} advisories, ${row.severity}, ${reach}`,
11032
+ ` versions: ${row.versions.join(", ") || "unknown"}`
11033
+ ];
11034
+ if (row.path !== void 0 && row.path.length > 0) {
11035
+ lines.push(` path: ${row.path.join(" > ")}`);
11036
+ }
11037
+ if (row.file !== void 0) {
11038
+ lines.push(` ${row.file}${row.line !== void 0 ? `:${row.line}` : ""}`);
11039
+ }
11040
+ const evidenceImport = row.findings[0]?.reachabilityEvidence?.imports[0];
11041
+ if (evidenceImport !== void 0) {
11042
+ lines.push(` import: ${evidenceImport.file}:${evidenceImport.line} ${evidenceImport.specifier}`);
11043
+ }
11044
+ lines.push(` docs: rules/${row.ruleId}.md`);
11045
+ return lines;
11046
+ }
11047
+ function renderConsoleReport(input, options = {}) {
9664
11048
  const result = redactScan(input);
9665
11049
  const { findings, summary, metadata } = result;
9666
11050
  const lines = [];
@@ -9670,34 +11054,63 @@ function renderConsoleReport(input) {
9670
11054
  )
9671
11055
  );
9672
11056
  if (metadata.history !== void 0) lines.push(metadata.history);
11057
+ if (metadata.upgradePlan !== void 0 && metadata.upgradePlan.sentence.length > 0) {
11058
+ lines.push(metadata.upgradePlan.sentence);
11059
+ }
11060
+ if (metadata.unusedDirect !== void 0) {
11061
+ const count = metadata.unusedDirect.packages.length;
11062
+ const advisories = metadata.unusedDirect.advisories;
11063
+ const packageWord = count === 1 ? "package is" : "packages are";
11064
+ const advisoryWord = advisories === 1 ? "advisory" : "advisories";
11065
+ lines.push(
11066
+ `${count} ${packageWord} never imported; removing them clears ${advisories} ${advisoryWord}.`
11067
+ );
11068
+ }
9673
11069
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
9674
11070
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
9675
11071
  lines.push(
9676
11072
  pc.dim(`note: requested modules not implemented yet: ${pending.join(", ")}`)
9677
11073
  );
9678
11074
  }
9679
- const bySeverityDesc = [...SEVERITIES].reverse();
9680
- const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
9681
- const shown = ordered.slice(0, 10);
9682
- const hidden = ordered.length - shown.length;
9683
- for (const severity of bySeverityDesc) {
9684
- const group = shown.filter((f) => f.severity === severity);
9685
- if (group.length === 0) continue;
9686
- lines.push("");
9687
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
9688
- for (const finding3 of group) {
9689
- lines.push(...renderFinding(finding3));
9690
- lines.push(` docs: rules/${finding3.ruleId}.md`);
11075
+ const showAll = options.all === true;
11076
+ const folded = findings.filter((finding3) => !isActionable(finding3));
11077
+ const rows = showAll ? [...findings].sort(compareActionable).map((finding3) => ({
11078
+ findings: [finding3],
11079
+ severity: finding3.severity,
11080
+ reachability: finding3.reachability,
11081
+ ruleId: finding3.ruleId,
11082
+ file: finding3.file,
11083
+ line: finding3.line,
11084
+ versions: [],
11085
+ grouped: false
11086
+ })) : shownConsoleRows(findings);
11087
+ const printed = showAll ? rows : rows.slice(0, 20);
11088
+ const hiddenActionable = showAll ? 0 : rows.length - printed.length;
11089
+ let previous;
11090
+ for (const row of printed) {
11091
+ if (row.severity !== previous) {
11092
+ const count = printed.filter((item) => item.severity === row.severity).length;
11093
+ lines.push("");
11094
+ lines.push(SEVERITY_COLOR[row.severity](`${row.severity.toUpperCase()} (${count})`));
11095
+ previous = row.severity;
9691
11096
  }
11097
+ lines.push(...renderRow(row));
9692
11098
  }
9693
- if (hidden > 0) {
11099
+ if (hiddenActionable > 0) {
9694
11100
  lines.push("");
9695
- lines.push(`${hidden} more \u2014 use --json`);
11101
+ lines.push(`${hiddenActionable} more actionable \u2014 use --json`);
11102
+ }
11103
+ if (!showAll && folded.length > 0) {
11104
+ lines.push("");
11105
+ lines.push(
11106
+ `${folded.length} more (not directly reachable, or below high confidence) \u2014 --all or see JSON`
11107
+ );
9696
11108
  }
9697
11109
  lines.push("");
9698
11110
  if (summary.total === 0) {
9699
11111
  lines.push(pc.green("\u2714 No findings."));
9700
11112
  } else {
11113
+ const bySeverityDesc = [...SEVERITIES].reverse();
9701
11114
  const counts = bySeverityDesc.filter((s) => summary.bySeverity[s] > 0).map((s) => `${summary.bySeverity[s]} ${s}`).join(", ");
9702
11115
  lines.push(pc.red(`\u2716 ${summary.total} finding(s): ${counts}`));
9703
11116
  }
@@ -9828,6 +11241,14 @@ function renderMarkdownReport(input) {
9828
11241
  return lines.join("\n");
9829
11242
  }
9830
11243
 
11244
+ // src/core/fingerprint.ts
11245
+ init_finding();
11246
+ function lineIndependentFingerprint(finding3) {
11247
+ const file = (finding3.file ?? "").replace(/\\/g, "/");
11248
+ const text = (finding3.snippet ?? finding3.message ?? "").replace(/\s+/g, " ").trim();
11249
+ return makeFindingId(finding3.ruleId, file, `${finding3.severity}\0${text}`);
11250
+ }
11251
+
9831
11252
  // src/reporters/sarif.ts
9832
11253
  var SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json";
9833
11254
  function sarifLevel(severity) {
@@ -9838,15 +11259,37 @@ function sarifLevel(severity) {
9838
11259
  function uri(file) {
9839
11260
  return (file ?? "").replace(/\\/g, "/");
9840
11261
  }
11262
+ function sarifV2() {
11263
+ return process.env.FULLSHIELD_EXPERIMENTAL_SARIF_V2 === "1";
11264
+ }
11265
+ function ruleMarkdown(ruleId) {
11266
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "rules", `${ruleId}.md`);
11267
+ if (!existsSync(path)) return void 0;
11268
+ return readFileSync(path, "utf8").slice(0, 2048);
11269
+ }
11270
+ function relatedLocations(finding3) {
11271
+ if (finding3.dependencyPath === void 0 || finding3.dependencyPath.length === 0) return void 0;
11272
+ return finding3.dependencyPath.map((step) => ({
11273
+ message: { text: step },
11274
+ physicalLocation: {
11275
+ artifactLocation: { uri: uri(finding3.file) },
11276
+ ...finding3.line !== void 0 ? { region: { startLine: finding3.line } } : {}
11277
+ }
11278
+ }));
11279
+ }
9841
11280
  function renderSarifReport(input) {
9842
11281
  const result = redactScan(input);
11282
+ const v2 = sarifV2();
9843
11283
  const rules = /* @__PURE__ */ new Map();
9844
11284
  for (const finding3 of result.findings) {
9845
11285
  if (!rules.has(finding3.ruleId)) {
11286
+ const markdown = v2 ? ruleMarkdown(finding3.ruleId) : void 0;
9846
11287
  rules.set(finding3.ruleId, {
9847
11288
  id: finding3.ruleId,
9848
11289
  name: finding3.ruleId,
9849
- shortDescription: { text: finding3.title }
11290
+ shortDescription: { text: finding3.title },
11291
+ ...markdown !== void 0 ? { help: { markdown } } : {},
11292
+ ...v2 ? { properties: { precision: "unreviewed" } } : {}
9850
11293
  });
9851
11294
  }
9852
11295
  }
@@ -9875,6 +11318,7 @@ function renderSarifReport(input) {
9875
11318
  }
9876
11319
  }
9877
11320
  ] : [];
11321
+ const related = v2 ? relatedLocations(finding3) : void 0;
9878
11322
  const codeFlows = finding3.trace !== void 0 && finding3.trace.length > 0 ? [
9879
11323
  {
9880
11324
  threadFlows: [
@@ -9897,7 +11341,12 @@ function renderSarifReport(input) {
9897
11341
  level: sarifLevel(finding3.severity),
9898
11342
  message: { text: finding3.message },
9899
11343
  locations: location4,
9900
- ...codeFlows !== void 0 ? { codeFlows } : {}
11344
+ ...codeFlows !== void 0 ? { codeFlows } : {},
11345
+ ...v2 ? {
11346
+ partialFingerprints: { "fullshield/v1": lineIndependentFingerprint(finding3) },
11347
+ ...related !== void 0 ? { relatedLocations: related } : {},
11348
+ ...finding3.module === "deps" && finding3.reachability !== void 0 ? { properties: { reachability: finding3.reachability } } : {}
11349
+ } : {}
9901
11350
  };
9902
11351
  })
9903
11352
  }
@@ -9946,14 +11395,6 @@ function validateSarif21(doc) {
9946
11395
  return errors;
9947
11396
  }
9948
11397
 
9949
- // src/core/fingerprint.ts
9950
- init_finding();
9951
- function lineIndependentFingerprint(finding3) {
9952
- const file = (finding3.file ?? "").replace(/\\/g, "/");
9953
- const text = (finding3.snippet ?? finding3.message ?? "").replace(/\s+/g, " ").trim();
9954
- return makeFindingId(finding3.ruleId, file, `${finding3.severity}\0${text}`);
9955
- }
9956
-
9957
11398
  // src/reporters/ci.ts
9958
11399
  init_finding();
9959
11400
  var MESSAGE_CAP = 4e3;
@@ -10209,6 +11650,16 @@ function validateCycloneDx15(doc) {
10209
11650
  if (!Array.isArray(root.components)) errors.push("components must be an array");
10210
11651
  return errors;
10211
11652
  }
11653
+ function reachProperties(finding3) {
11654
+ const properties = [
11655
+ { name: "fullshield:reachability", value: finding3.reachability ?? "unspecified" },
11656
+ { name: "fullshield:devOnly", value: finding3.message.includes("(dev-only)") ? "true" : "false" }
11657
+ ];
11658
+ if (finding3.dependencyPath !== void 0 && finding3.dependencyPath.length > 0) {
11659
+ properties.splice(1, 0, { name: "fullshield:dependencyPath", value: finding3.dependencyPath.join(" > ") });
11660
+ }
11661
+ return properties;
11662
+ }
10212
11663
  function renderCycloneDx(input) {
10213
11664
  const vulns = (input.findings ?? []).filter((f) => f.ruleId === "DEPS-VULN-001");
10214
11665
  return {
@@ -10219,13 +11670,18 @@ function renderCycloneDx(input) {
10219
11670
  metadata: {
10220
11671
  component: { type: "application", name: input.name, version: input.version }
10221
11672
  },
10222
- components: input.components.map((c) => ({
10223
- type: "library",
10224
- name: c.name,
10225
- version: c.version,
10226
- purl: c.purl ?? `pkg:npm/${encodeURIComponent(c.name)}@${c.version}`,
10227
- ...c.license !== void 0 ? { licenses: [{ license: { id: c.license } }] } : {}
10228
- })),
11673
+ components: input.components.map((c) => {
11674
+ const match = (input.findings ?? []).find((finding3) => finding3.snippet?.startsWith(`${c.name}@`));
11675
+ const properties = input.includeReach === true && match !== void 0 ? reachProperties(match) : void 0;
11676
+ return {
11677
+ type: "library",
11678
+ name: c.name,
11679
+ version: c.version,
11680
+ purl: c.purl ?? `pkg:npm/${encodeURIComponent(c.name)}@${c.version}`,
11681
+ ...c.license !== void 0 ? { licenses: [{ license: { id: c.license } }] } : {},
11682
+ ...properties !== void 0 ? { properties } : {}
11683
+ };
11684
+ }),
10229
11685
  vulnerabilities: vulns.map((f) => ({
10230
11686
  id: f.references?.find((r) => r.startsWith("GHSA-") || r.startsWith("CVE-")) ?? f.id,
10231
11687
  description: f.message,
@@ -10236,7 +11692,7 @@ function renderCycloneDx(input) {
10236
11692
 
10237
11693
  // src/sbom/index.ts
10238
11694
  var LOCKFILE_NAMES2 = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
10239
- async function buildSbom(cwd) {
11695
+ async function buildSbom(cwd, options = {}) {
10240
11696
  const result = await scan({ cwd, modules: ["deps"] });
10241
11697
  let name = "app";
10242
11698
  let version = "0.0.0";
@@ -10262,7 +11718,13 @@ async function buildSbom(cwd) {
10262
11718
  if (components.length === 0) {
10263
11719
  components = exactPinsFromManifest(manifest).map((p) => ({ name: p.name, version: p.version }));
10264
11720
  }
10265
- return renderCycloneDx({ name, version, components, findings: result.findings });
11721
+ return renderCycloneDx({
11722
+ name,
11723
+ version,
11724
+ components,
11725
+ findings: result.findings,
11726
+ includeReach: options.includeReach === true
11727
+ });
10266
11728
  }
10267
11729
  var OPENVEX_CONTEXT = "https://openvex.dev/ns/v0.2.0";
10268
11730
  var OPENVEX_NOT_AFFECTED_JUSTIFICATION = "vulnerable_code_not_in_execute_path";
@@ -10297,14 +11759,16 @@ function openVexStatement(finding3) {
10297
11759
  vulnerability: { name: vulnName(finding3) },
10298
11760
  products: [{ "@id": npmPurl(finding3.snippet) }]
10299
11761
  };
11762
+ const detail = process.env.FULLSHIELD_EXPERIMENTAL_SBOM_REACH === "1" && finding3.reachabilityEvidence?.reason !== void 0 ? { detail: finding3.reachabilityEvidence.reason } : {};
10300
11763
  if (notImported(finding3)) {
10301
11764
  return {
10302
11765
  ...base,
10303
11766
  status: "not_affected",
10304
- justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION
11767
+ justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION,
11768
+ ...detail
10305
11769
  };
10306
11770
  }
10307
- return { ...base, status: "under_investigation" };
11771
+ return { ...base, status: "under_investigation", ...detail };
10308
11772
  }
10309
11773
  function buildOpenVex(findings) {
10310
11774
  const statements = depFindings(findings).map(openVexStatement);