fullshield 0.1.3 → 0.3.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/mcp.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { readFile, mkdir, writeFile, stat, readdir } from 'fs/promises';
3
- import { resolve, join, basename, dirname, relative, posix } from 'path';
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 { existsSync, statSync, readFileSync, readdirSync, mkdtempSync, cpSync, writeFileSync, rmSync } from 'fs';
8
- import { homedir, tmpdir } from 'os';
8
+ import { homedir, availableParallelism, tmpdir } from 'os';
9
9
  import { performance } from 'perf_hooks';
10
- import ignoreModule from 'ignore';
10
+ import { Worker } from 'worker_threads';
11
11
  import { fileURLToPath } from 'url';
12
+ import ignoreModule from 'ignore';
12
13
 
13
14
  var __defProp = Object.defineProperty;
14
15
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -556,6 +557,7 @@ var init_incremental = __esm({
556
557
  // src/modules/secrets/history.ts
557
558
  var history_exports = {};
558
559
  __export(history_exports, {
560
+ historyCommitLimit: () => historyCommitLimit,
559
561
  scanGitHistory: () => scanGitHistory
560
562
  });
561
563
  function gitError(error) {
@@ -598,7 +600,13 @@ function remapHit(hit, file) {
598
600
  secret: hit.secret
599
601
  };
600
602
  }
601
- async function streamLogChanges(cwd) {
603
+ function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
604
+ if (raw === void 0 || raw.trim() === "") return 500;
605
+ const parsed = Number(raw);
606
+ if (!Number.isInteger(parsed) || parsed < 1) return 500;
607
+ return Math.min(parsed, 5e3);
608
+ }
609
+ async function streamLogChanges(cwd, maxCommits) {
602
610
  const changes = [];
603
611
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
604
612
  const child = spawn(
@@ -615,6 +623,8 @@ async function streamLogChanges(cwd) {
615
623
  "--format=FSCOMMIT:%H",
616
624
  "--no-textconv",
617
625
  "--no-ext-diff",
626
+ "-n",
627
+ String(maxCommits),
618
628
  "HEAD"
619
629
  ],
620
630
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -683,8 +693,14 @@ async function headSecretKeys(cwd) {
683
693
  }
684
694
  return keys;
685
695
  }
686
- async function scanGitHistory(cwd) {
687
- const changes = await streamLogChanges(cwd);
696
+ async function commitCount(cwd) {
697
+ const raw = await gitOk(cwd, ["rev-list", "--count", "HEAD"]);
698
+ const total = Number(raw.trim());
699
+ return Number.isInteger(total) && total >= 0 ? total : 0;
700
+ }
701
+ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
702
+ const total = await commitCount(cwd);
703
+ const changes = await streamLogChanges(cwd, maxCommits);
688
704
  const blobHits = /* @__PURE__ */ new Map();
689
705
  const first = /* @__PURE__ */ new Map();
690
706
  for (const change of changes) {
@@ -721,7 +737,13 @@ async function scanGitHistory(cwd) {
721
737
  secret: hit.secret
722
738
  });
723
739
  }
724
- return { findings: hits.map((h) => h.finding), hits };
740
+ const scanned = Math.min(maxCommits, total);
741
+ const history = total > maxCommits ? `scanned ${scanned} of ${total} commits` : void 0;
742
+ return {
743
+ findings: hits.map((h) => h.finding),
744
+ hits,
745
+ ...history !== void 0 ? { history } : {}
746
+ };
725
747
  }
726
748
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
727
749
  var init_history = __esm({
@@ -1069,7 +1091,8 @@ function walk(node, visit) {
1069
1091
  }
1070
1092
  }
1071
1093
  function attachParents(node, parent) {
1072
- node.parent = parent;
1094
+ const current = node;
1095
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
1073
1096
  for (const key of Object.keys(node)) {
1074
1097
  if (SKIP_KEYS.has(String(key))) continue;
1075
1098
  const value = node[key];
@@ -1139,6 +1162,13 @@ function classesEnabled() {
1139
1162
  function mcpCfgEnabled() {
1140
1163
  return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
1141
1164
  }
1165
+ function taintV2Enabled() {
1166
+ return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
1167
+ }
1168
+ var LICENSE_FLAG = "FULLSHIELD_EXPERIMENTAL_LICENSE";
1169
+ function licenseEnabled() {
1170
+ return process.env[LICENSE_FLAG] === "1";
1171
+ }
1142
1172
 
1143
1173
  // src/modules/config/finding.ts
1144
1174
  init_finding();
@@ -1945,6 +1975,103 @@ function exactPinsFromManifest(pkg) {
1945
1975
 
1946
1976
  // src/modules/deps/reachability.ts
1947
1977
  init_finding();
1978
+
1979
+ // src/modules/deps/symbol-reachability.ts
1980
+ function affectedFunctionNames(vuln) {
1981
+ const names = /* @__PURE__ */ new Set();
1982
+ for (const affected of vuln.affected ?? []) {
1983
+ const list = affected.ecosystem_specific?.affected_functions;
1984
+ if (!Array.isArray(list)) continue;
1985
+ for (const item of list) {
1986
+ if (typeof item === "string" && item.length > 0) names.add(item);
1987
+ }
1988
+ }
1989
+ return [...names];
1990
+ }
1991
+ function escapeRegExp(value) {
1992
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1993
+ }
1994
+ function called(source, local) {
1995
+ return new RegExp(`\\b${escapeRegExp(local)}\\s*\\(`).test(source);
1996
+ }
1997
+ function memberCalled(source, object, member) {
1998
+ return new RegExp(`\\b${escapeRegExp(object)}\\s*\\.\\s*${escapeRegExp(member)}\\s*\\(`).test(source);
1999
+ }
2000
+ function sourceCallsAffectedSymbol(source, symbol) {
2001
+ const esc = escapeRegExp(symbol);
2002
+ const named = new RegExp(
2003
+ `import\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`,
2004
+ "g"
2005
+ );
2006
+ let match;
2007
+ while ((match = named.exec(source)) !== null) {
2008
+ const parts = (match[1] ?? "").split(",");
2009
+ for (const part of parts) {
2010
+ const bits = part.trim().split(/\s+as\s+/);
2011
+ const imported = bits[0]?.trim();
2012
+ const local = (bits[1] ?? bits[0])?.trim();
2013
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
2014
+ }
2015
+ }
2016
+ const star = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"][^'"]+['"]/g;
2017
+ while ((match = star.exec(source)) !== null) {
2018
+ const local = match[1];
2019
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
2020
+ }
2021
+ const subDefault = new RegExp(
2022
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"][^'"]+/${esc}['"]`
2023
+ );
2024
+ const sub = subDefault.exec(source);
2025
+ if (sub?.[1] !== void 0 && called(source, sub[1])) return true;
2026
+ const reqMember = new RegExp(
2027
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+['"]\\s*\\)\\s*\\.\\s*${esc}`
2028
+ );
2029
+ const reqHit = reqMember.exec(source);
2030
+ if (reqHit?.[1] !== void 0 && called(source, reqHit[1])) return true;
2031
+ const reqNs = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2032
+ while ((match = reqNs.exec(source)) !== null) {
2033
+ const local = match[1];
2034
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
2035
+ }
2036
+ const reqNamed = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2037
+ while ((match = reqNamed.exec(source)) !== null) {
2038
+ const parts = (match[1] ?? "").split(",");
2039
+ for (const part of parts) {
2040
+ const bits = part.trim().split(/\s*:\s*/);
2041
+ const imported = bits[0]?.trim();
2042
+ const local = (bits[1] ?? bits[0])?.trim();
2043
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
2044
+ }
2045
+ }
2046
+ const reqSub = new RegExp(
2047
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+/${esc}['"]\\s*\\)`
2048
+ );
2049
+ const subReq = reqSub.exec(source);
2050
+ if (subReq?.[1] !== void 0 && called(source, subReq[1])) return true;
2051
+ const barrel = new RegExp(`export\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`, "g");
2052
+ while ((match = barrel.exec(source)) !== null) {
2053
+ const parts = (match[1] ?? "").split(",");
2054
+ for (const part of parts) {
2055
+ const bits = part.trim().split(/\s+as\s+/);
2056
+ const exported = (bits[1] ?? bits[0])?.trim();
2057
+ if (exported === symbol) {
2058
+ const local = bits[0]?.trim();
2059
+ if (local !== void 0 && called(source, local)) return true;
2060
+ }
2061
+ }
2062
+ }
2063
+ return false;
2064
+ }
2065
+ function classifySymbolUse(symbols, sources) {
2066
+ for (const symbol of symbols) {
2067
+ for (const source of sources.values()) {
2068
+ if (sourceCallsAffectedSymbol(source, symbol)) return "symbol-reachable";
2069
+ }
2070
+ }
2071
+ return "symbol-not-called";
2072
+ }
2073
+
2074
+ // src/modules/deps/reachability.ts
1948
2075
  function packageNameFromSnippet(snippet) {
1949
2076
  if (snippet.startsWith("@")) {
1950
2077
  const second = snippet.indexOf("@", 1);
@@ -1973,9 +2100,9 @@ function isProductionSource(rel) {
1973
2100
  }
1974
2101
  function indexPackageUsage(sources) {
1975
2102
  const usage = /* @__PURE__ */ new Map();
1976
- const bump = (pkg, called) => {
2103
+ const bump = (pkg, called2) => {
1977
2104
  const prev = usage.get(pkg) ?? { called: false };
1978
- usage.set(pkg, { imported: true, called: prev.called || called });
2105
+ usage.set(pkg, { imported: true, called: prev.called || called2 });
1979
2106
  };
1980
2107
  const nsRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
1981
2108
  const namedRe = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
@@ -2031,6 +2158,29 @@ function shortestDependencyPath(target, starts, edges) {
2031
2158
  }
2032
2159
  return void 0;
2033
2160
  }
2161
+ function collectDependencyPaths(target, starts, edges, limit = 8) {
2162
+ const out = [];
2163
+ const queue = [];
2164
+ let steps = 0;
2165
+ for (const start of starts) queue.push([start]);
2166
+ while (queue.length > 0 && out.length < limit && steps < 4e3) {
2167
+ steps += 1;
2168
+ const path = queue.shift();
2169
+ if (path === void 0) continue;
2170
+ const last = path[path.length - 1];
2171
+ if (last === void 0) continue;
2172
+ if (last === target) {
2173
+ if (path.length >= 2) out.push(path);
2174
+ continue;
2175
+ }
2176
+ if (path.length >= 12) continue;
2177
+ for (const next of edges.get(last) ?? []) {
2178
+ if (path.includes(next)) continue;
2179
+ queue.push([...path, next]);
2180
+ }
2181
+ }
2182
+ return out;
2183
+ }
2034
2184
  function reachableFrom(target, roots, edges) {
2035
2185
  if (roots.has(target)) return true;
2036
2186
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -2051,9 +2201,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2051
2201
  }
2052
2202
  if (graph !== void 0 && graph.edgesKnown) {
2053
2203
  const roots = importedDirectNames(productionUsage, graph);
2054
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
2204
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
2205
+ const path = paths[0];
2055
2206
  if (path !== void 0 && path.length >= 2) {
2056
- return { reachability: "reachable-via-dependency", path };
2207
+ return { reachability: "reachable-via-dependency", path, paths };
2057
2208
  }
2058
2209
  return { reachability: "not-imported" };
2059
2210
  }
@@ -2063,10 +2214,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2063
2214
  return { reachability: "not-imported" };
2064
2215
  }
2065
2216
  function reachabilitySteps(reachability) {
2066
- if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown") {
2217
+ if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
2067
2218
  return 0;
2068
2219
  }
2069
- if (reachability === "imported-not-called") return 1;
2220
+ if (reachability === "imported-not-called" || reachability === "symbol-not-called") return 1;
2070
2221
  return 2;
2071
2222
  }
2072
2223
  function downgradeSeverity(severity, steps) {
@@ -2083,8 +2234,108 @@ function isDevOnly(pkgName, ctx) {
2083
2234
  if (inProd) return false;
2084
2235
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
2085
2236
  }
2086
- function applyReachability(findings, ctx) {
2087
- return findings.map((finding4) => {
2237
+ var REACH_RANK = {
2238
+ reachable: 0,
2239
+ "symbol-reachable": 1,
2240
+ "reachable-via-dependency": 2,
2241
+ "imported-not-called": 3,
2242
+ "symbol-not-called": 4,
2243
+ "reachability-unknown": 5,
2244
+ "not-imported": 6
2245
+ };
2246
+ function versionFromSnippet(snippet) {
2247
+ const name = packageNameFromSnippet(snippet);
2248
+ if (name === void 0) return snippet;
2249
+ return snippet.slice(name.length + 1);
2250
+ }
2251
+ function pathFields(classified) {
2252
+ const paths = classified.paths ?? (classified.path !== void 0 ? [classified.path] : []);
2253
+ if (paths.length === 0) return {};
2254
+ return {
2255
+ dependencyPath: paths[0],
2256
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
2257
+ };
2258
+ }
2259
+ function appendOtherPaths(message, paths) {
2260
+ if (paths === void 0 || paths.length < 2) return message;
2261
+ const rest = paths.slice(1).map((p) => p.join(" > ")).join("; ");
2262
+ return `${message} Other paths: ${rest}.`;
2263
+ }
2264
+ function groupPackageAdvisories(findings) {
2265
+ const passthrough = [];
2266
+ const groups = /* @__PURE__ */ new Map();
2267
+ for (const finding4 of findings) {
2268
+ if (finding4.ruleId !== "DEPS-VULN-001" && finding4.ruleId !== "DEPS-MALICIOUS-001") {
2269
+ passthrough.push(finding4);
2270
+ continue;
2271
+ }
2272
+ const name = packageNameFromSnippet(finding4.snippet ?? "") ?? finding4.snippet ?? finding4.id;
2273
+ const advisory = finding4.references?.[0] ?? finding4.id;
2274
+ const key = `${finding4.ruleId}\0${name}\0${advisory}`;
2275
+ const list = groups.get(key);
2276
+ if (list === void 0) groups.set(key, [finding4]);
2277
+ else list.push(finding4);
2278
+ }
2279
+ const merged = [];
2280
+ for (const list of groups.values()) {
2281
+ const only = list[0];
2282
+ if (list.length === 1 && only !== void 0) {
2283
+ merged.push(only);
2284
+ continue;
2285
+ }
2286
+ merged.push(mergeAdvisory(list));
2287
+ }
2288
+ return [...passthrough, ...merged];
2289
+ }
2290
+ function mergeAdvisory(list) {
2291
+ const ranked = [...list].sort(
2292
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || (REACH_RANK[a.reachability ?? "not-imported"] ?? 9) - (REACH_RANK[b.reachability ?? "not-imported"] ?? 9)
2293
+ );
2294
+ const primary = ranked[0];
2295
+ if (primary === void 0) {
2296
+ throw new Error("empty advisory group");
2297
+ }
2298
+ const name = packageNameFromSnippet(primary.snippet ?? "") ?? "package";
2299
+ const versions = [...new Set(list.map((f) => versionFromSnippet(f.snippet ?? "")))].filter((v) => v.length > 0).sort();
2300
+ const paths = [];
2301
+ const seenPath = /* @__PURE__ */ new Set();
2302
+ for (const finding4 of list) {
2303
+ const candidates = finding4.dependencyPaths ?? (finding4.dependencyPath !== void 0 ? [finding4.dependencyPath] : []);
2304
+ for (const path of candidates) {
2305
+ const key = path.join("\0");
2306
+ if (seenPath.has(key)) continue;
2307
+ seenPath.add(key);
2308
+ paths.push(path);
2309
+ }
2310
+ }
2311
+ paths.sort((a, b) => a.length - b.length || a.join(">").localeCompare(b.join(">")));
2312
+ const advisory = primary.references?.[0] ?? "";
2313
+ const onManifest = list.find((f) => f.file === "package.json");
2314
+ const file = onManifest?.file ?? primary.file;
2315
+ const line = onManifest?.line ?? primary.line;
2316
+ const snippet = versions.length > 0 ? `${name}@${versions.join(",")}` : primary.snippet ?? name;
2317
+ let message = primary.message;
2318
+ if (versions.length > 1) {
2319
+ message = `${message} Versions: ${versions.join(", ")}.`;
2320
+ }
2321
+ if (paths.length > 1) {
2322
+ message = `${message} Dependency paths: ${paths.map((p) => p.join(" > ")).join("; ")}.`;
2323
+ }
2324
+ return {
2325
+ ...primary,
2326
+ id: makeFindingId(primary.ruleId, file, `${name}|${advisory}|${versions.join(",")}`),
2327
+ file,
2328
+ line,
2329
+ snippet,
2330
+ message,
2331
+ severity: primary.severity,
2332
+ reachability: primary.reachability,
2333
+ ...paths[0] !== void 0 ? { dependencyPath: paths[0] } : {},
2334
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
2335
+ };
2336
+ }
2337
+ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2338
+ const mapped = findings.map((finding4) => {
2088
2339
  if (finding4.ruleId === "DEPS-MALICIOUS-001") {
2089
2340
  if (finding4.snippet === void 0) return finding4;
2090
2341
  const pkg2 = packageNameFromSnippet(finding4.snippet);
@@ -2094,19 +2345,25 @@ function applyReachability(findings, ctx) {
2094
2345
  ...finding4,
2095
2346
  severity: "critical",
2096
2347
  reachability: classified2.reachability,
2097
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
2348
+ ...pathFields(classified2)
2098
2349
  };
2099
2350
  }
2100
2351
  if (finding4.ruleId !== "DEPS-VULN-001" || finding4.snippet === void 0) return finding4;
2101
2352
  const pkg = packageNameFromSnippet(finding4.snippet);
2102
2353
  if (pkg === void 0 || pkg.length === 0) return finding4;
2103
- const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2354
+ let classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2355
+ const named = symbols.get(finding4.id) ?? [];
2356
+ if (named.length > 0 && ctx.sources !== void 0) {
2357
+ const symbolClass = classifySymbolUse(named, ctx.sources);
2358
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
2359
+ }
2104
2360
  const steps = reachabilitySteps(classified.reachability);
2105
2361
  let severity = steps === 0 ? finding4.severity : downgradeSeverity(finding4.severity, steps);
2106
2362
  let message = finding4.message;
2107
2363
  if (classified.path !== void 0) {
2108
2364
  const shown = classified.path.join(" > ");
2109
2365
  message = `${message} Reachable via ${shown}.`;
2366
+ message = appendOtherPaths(message, classified.paths);
2110
2367
  }
2111
2368
  const devOnly = isDevOnly(pkg, ctx);
2112
2369
  if (devOnly) {
@@ -2116,11 +2373,12 @@ function applyReachability(findings, ctx) {
2116
2373
  return {
2117
2374
  ...finding4,
2118
2375
  reachability: classified.reachability,
2119
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
2376
+ ...pathFields(classified),
2120
2377
  severity,
2121
2378
  message
2122
2379
  };
2123
2380
  });
2381
+ return groupPackageAdvisories(mapped);
2124
2382
  }
2125
2383
 
2126
2384
  // src/modules/deps/semver.ts
@@ -4022,6 +4280,59 @@ function experimentalVex() {
4022
4280
  return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
4023
4281
  }
4024
4282
 
4283
+ // src/modules/deps/license.ts
4284
+ init_finding();
4285
+ var COPYLEFT = /(?:^|[^\w])(?:AGPL|GPL)(?:-|[^\w]|$)/i;
4286
+ function isCopyleftLicense(license) {
4287
+ return COPYLEFT.test(license);
4288
+ }
4289
+ function licenseText(value) {
4290
+ if (typeof value === "string") return value;
4291
+ if (typeof value === "object" && value !== null && "type" in value) {
4292
+ const type = value.type;
4293
+ if (typeof type === "string") return type;
4294
+ }
4295
+ return void 0;
4296
+ }
4297
+ async function readJson(path) {
4298
+ try {
4299
+ return JSON.parse(await readFile(path, "utf8"));
4300
+ } catch {
4301
+ return void 0;
4302
+ }
4303
+ }
4304
+ async function scanCopyleftLicenses(cwd) {
4305
+ const manifest = await readJson(join(cwd, "package.json"));
4306
+ if (typeof manifest !== "object" || manifest === null) return [];
4307
+ const names = /* @__PURE__ */ new Set();
4308
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
4309
+ const block = manifest[field];
4310
+ if (typeof block === "object" && block !== null && !Array.isArray(block)) {
4311
+ for (const name of Object.keys(block)) names.add(name);
4312
+ }
4313
+ }
4314
+ const findings = [];
4315
+ for (const name of names) {
4316
+ const pkg = await readJson(join(cwd, "node_modules", name, "package.json"));
4317
+ if (typeof pkg !== "object" || pkg === null) continue;
4318
+ const license = licenseText(pkg.license);
4319
+ if (license === void 0 || !isCopyleftLicense(license)) continue;
4320
+ findings.push({
4321
+ id: makeFindingId("DEPS-LICENSE-001", "package.json", `${name}|${license}`),
4322
+ ruleId: "DEPS-LICENSE-001",
4323
+ module: "deps",
4324
+ title: `Copyleft dependency ${name}`,
4325
+ severity: "medium",
4326
+ confidence: "high",
4327
+ file: "package.json",
4328
+ snippet: `${name} ${license}`,
4329
+ message: `${name} is licensed ${license}.`,
4330
+ remediation: "Replace the dependency or get a legal review before shipping it in a commercial product."
4331
+ });
4332
+ }
4333
+ return findings;
4334
+ }
4335
+
4025
4336
  // src/fixer/eol.ts
4026
4337
  function detectEol(text) {
4027
4338
  return text.includes("\r\n") ? "\r\n" : "\n";
@@ -4120,7 +4431,7 @@ function walkJs(dir, out = []) {
4120
4431
  }
4121
4432
  return out;
4122
4433
  }
4123
- function readJson(path) {
4434
+ function readJson2(path) {
4124
4435
  return JSON.parse(readFileSync(path, "utf8"));
4125
4436
  }
4126
4437
  var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
@@ -4134,7 +4445,7 @@ function packageSources(dir) {
4134
4445
  let scripts = "";
4135
4446
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
4136
4447
  try {
4137
- const pkg = readJson(manifestPath);
4448
+ const pkg = readJson2(manifestPath);
4138
4449
  const block = pkg.scripts ?? {};
4139
4450
  scripts = Object.values(block).join("\n");
4140
4451
  } catch {
@@ -4408,14 +4719,17 @@ var depsModule = {
4408
4719
  findings2.push(typosquatFinding("package.json", hit));
4409
4720
  }
4410
4721
  const osvHits2 = await queryOsv(pins, osvOpts);
4722
+ const symbols2 = /* @__PURE__ */ new Map();
4411
4723
  for (const hit of osvHits2) {
4412
4724
  for (const vuln of hit.vulns) {
4413
- findings2.push(
4414
- isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name))
4415
- );
4725
+ const finding4 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
4726
+ const names = affectedFunctionNames(vuln);
4727
+ if (names.length > 0 && finding4.ruleId === "DEPS-VULN-001") symbols2.set(finding4.id, names);
4728
+ findings2.push(finding4);
4416
4729
  }
4417
4730
  }
4418
- return applyReachability(findings2, await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest)));
4731
+ const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4732
+ return applyReachability(findings2, noLockCtx, symbols2);
4419
4733
  }
4420
4734
  let contents;
4421
4735
  try {
@@ -4436,18 +4750,21 @@ var depsModule = {
4436
4750
  }
4437
4751
  }
4438
4752
  const osvHits = await queryOsv(packages, osvOpts);
4753
+ const symbols = /* @__PURE__ */ new Map();
4439
4754
  for (const hit of osvHits) {
4440
4755
  for (const vuln of hit.vulns) {
4441
- findings.push(
4442
- isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name))
4443
- );
4756
+ const finding4 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4757
+ const names = affectedFunctionNames(vuln);
4758
+ if (names.length > 0 && finding4.ruleId === "DEPS-VULN-001") symbols.set(finding4.id, names);
4759
+ findings.push(finding4);
4444
4760
  }
4445
4761
  }
4446
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
4762
+ let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
4447
4763
  if (experimentalVex()) {
4448
4764
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4449
4765
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4450
4766
  }
4767
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4451
4768
  return reached;
4452
4769
  }
4453
4770
  };
@@ -4477,7 +4794,7 @@ async function reachabilityCtx(cwd, files, graph) {
4477
4794
  } catch {
4478
4795
  }
4479
4796
  }
4480
- return { productionUsage: indexPackageUsage(prodSources), graph };
4797
+ return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
4481
4798
  }
4482
4799
 
4483
4800
  // src/modules/mcp/index.ts
@@ -5015,6 +5332,26 @@ function isXssSanitizerCall(node) {
5015
5332
  return false;
5016
5333
  }
5017
5334
  var NO_RETURNERS = /* @__PURE__ */ new Set();
5335
+ var CALLBACK_METHODS = /* @__PURE__ */ new Set([
5336
+ "then",
5337
+ "catch",
5338
+ "finally",
5339
+ "map",
5340
+ "forEach",
5341
+ "filter",
5342
+ "flatMap",
5343
+ "find",
5344
+ "reduce"
5345
+ ]);
5346
+ var taintedThisFields = /* @__PURE__ */ new WeakMap();
5347
+ function enclosingClass(node) {
5348
+ let current = node.parent;
5349
+ while (current !== void 0) {
5350
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
5351
+ current = current.parent;
5352
+ }
5353
+ return void 0;
5354
+ }
5018
5355
  function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5019
5356
  if (node === void 0) return false;
5020
5357
  if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
@@ -5026,8 +5363,34 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5026
5363
  case "Identifier":
5027
5364
  return tainted.has(node.name);
5028
5365
  case "MemberExpression":
5366
+ if (taintV2Enabled() && node.object.type === "ThisExpression") {
5367
+ const cls = enclosingClass(node);
5368
+ const name = propName(node);
5369
+ if (cls !== void 0 && name !== void 0 && taintedThisFields.get(cls)?.has(name) === true) {
5370
+ return true;
5371
+ }
5372
+ }
5029
5373
  return exprIsTainted(node.object, tainted, returners);
5374
+ case "ArrayExpression":
5375
+ if (!taintV2Enabled()) return false;
5376
+ return node.elements.some((el) => {
5377
+ if (el === null) return false;
5378
+ if (el.type === "SpreadElement") return exprIsTainted(el.argument, tainted, returners);
5379
+ return exprIsTainted(el, tainted, returners);
5380
+ });
5381
+ case "ObjectExpression":
5382
+ if (!taintV2Enabled()) return false;
5383
+ return node.properties.some((prop) => {
5384
+ if (prop.type === "SpreadElement") return exprIsTainted(prop.argument, tainted, returners);
5385
+ if (prop.type !== "Property") return false;
5386
+ return exprIsTainted(prop.value, tainted, returners);
5387
+ });
5030
5388
  case "CallExpression":
5389
+ if (taintV2Enabled() && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
5390
+ return node.arguments.some(
5391
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
5392
+ );
5393
+ }
5031
5394
  if (isSchemaValidatorCall(node)) return false;
5032
5395
  if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
5033
5396
  if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
@@ -5258,7 +5621,66 @@ function scopeReturnsTaint(scope, tainted, returners) {
5258
5621
  return yes;
5259
5622
  }
5260
5623
  var MAX_TAINT_HOPS = 5;
5261
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5624
+ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5625
+ let changed = false;
5626
+ const mark = (fn, name) => {
5627
+ if (name === void 0) return;
5628
+ const set = seeds.get(fn) ?? /* @__PURE__ */ new Set();
5629
+ if (set.has(name)) return;
5630
+ set.add(name);
5631
+ seeds.set(fn, set);
5632
+ changed = true;
5633
+ };
5634
+ for (const fn of functions) {
5635
+ const f = facts.get(fn);
5636
+ if (f === void 0) continue;
5637
+ let parent = fn.parent;
5638
+ while (parent !== void 0 && !isFunctionNode(parent)) {
5639
+ parent = parent.parent;
5640
+ }
5641
+ if (parent !== void 0 && isFunctionNode(parent)) {
5642
+ const outer = facts.get(parent);
5643
+ if (outer !== void 0) {
5644
+ for (const name of outer.tainted) mark(fn, name);
5645
+ }
5646
+ }
5647
+ walkScope(fn, (node) => {
5648
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") {
5649
+ if (node.left.object.type === "ThisExpression" && exprIsTainted(node.right, f.tainted, returners)) {
5650
+ const cls = enclosingClass(node);
5651
+ const field = propName(node.left);
5652
+ if (cls !== void 0 && field !== void 0) {
5653
+ const fields = taintedThisFields.get(cls) ?? /* @__PURE__ */ new Set();
5654
+ if (!fields.has(field)) {
5655
+ fields.add(field);
5656
+ taintedThisFields.set(cls, fields);
5657
+ changed = true;
5658
+ }
5659
+ }
5660
+ }
5661
+ }
5662
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5663
+ const method = propName(node.callee);
5664
+ if (method === void 0) return;
5665
+ if (CALLBACK_METHODS.has(method) && exprIsTainted(node.callee.object, f.tainted, returners)) {
5666
+ const callback = node.arguments[0];
5667
+ if (callback !== void 0 && isFunctionNode(callback)) {
5668
+ mark(callback, boundNames(callback.params[0])[0]);
5669
+ }
5670
+ }
5671
+ if ((method === "call" || method === "apply") && node.callee.object.type === "Identifier") {
5672
+ const target = nameToFn.get(node.callee.object.name);
5673
+ if (target === void 0 || !isFunctionNode(target)) return;
5674
+ const arg = node.arguments[1];
5675
+ if (arg !== void 0 && arg.type !== "SpreadElement" && exprIsTainted(arg, f.tainted, returners)) {
5676
+ mark(target, boundNames(target.params[0])[0]);
5677
+ }
5678
+ }
5679
+ });
5680
+ }
5681
+ return changed;
5682
+ }
5683
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5262
5684
  attachParents(ast);
5263
5685
  const facts = /* @__PURE__ */ new WeakMap();
5264
5686
  const functions = [];
@@ -5272,7 +5694,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5272
5694
  }
5273
5695
  const seeds = /* @__PURE__ */ new Map();
5274
5696
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5275
- const returners = new Set(extraReturners);
5697
+ const returners = new Set(extraReturners2);
5276
5698
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5277
5699
  let changed = false;
5278
5700
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -5312,6 +5734,9 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5312
5734
  });
5313
5735
  });
5314
5736
  }
5737
+ if (taintV2Enabled() && seedTaintV2(functions, facts, seeds, returners, nameToFn)) {
5738
+ changed = true;
5739
+ }
5315
5740
  if (!changed) break;
5316
5741
  }
5317
5742
  return facts;
@@ -5454,6 +5879,21 @@ function requireCall(node) {
5454
5879
  }
5455
5880
  return void 0;
5456
5881
  }
5882
+ function reexportsOf(ast) {
5883
+ const out = /* @__PURE__ */ new Map();
5884
+ walk(ast, (node) => {
5885
+ if (node.type !== "ExportNamedDeclaration" || node.source === null || node.source === void 0) return;
5886
+ const spec = literalString(node.source);
5887
+ if (spec === void 0) return;
5888
+ for (const specNode of node.specifiers) {
5889
+ if (specNode.type !== "ExportSpecifier") continue;
5890
+ const exported = specNode.exported.type === "Identifier" ? specNode.exported.name : void 0;
5891
+ const local = specNode.local.type === "Identifier" ? specNode.local.name : exported;
5892
+ if (exported !== void 0 && local !== void 0) out.set(exported, { spec, name: local });
5893
+ }
5894
+ });
5895
+ return out;
5896
+ }
5457
5897
  function resolveRelative(fromFile, spec, files) {
5458
5898
  if (!spec.startsWith(".")) return void 0;
5459
5899
  const base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
@@ -5531,6 +5971,22 @@ function runInterproc(parsed) {
5531
5971
  functions.set(`${fn.file}#${fn.name}`, fn);
5532
5972
  }
5533
5973
  }
5974
+ if (taintV2Enabled()) {
5975
+ const barrels = /* @__PURE__ */ new Map();
5976
+ for (const p of parsed) barrels.set(p.file, reexportsOf(p.ast));
5977
+ for (const ns of requires.values()) {
5978
+ for (const [local, target] of ns) {
5979
+ const hash = target.indexOf("#");
5980
+ if (hash < 0) continue;
5981
+ const file = target.slice(0, hash);
5982
+ const name = target.slice(hash + 1);
5983
+ const re = barrels.get(file)?.get(name);
5984
+ if (re === void 0) continue;
5985
+ const resolved = resolveRelative(file, re.spec, files);
5986
+ if (resolved !== void 0) ns.set(local, `${resolved}#${re.name}`);
5987
+ }
5988
+ }
5989
+ }
5534
5990
  const queue = [];
5535
5991
  for (const p of parsed) {
5536
5992
  const facts = analyzeFile(p.ast);
@@ -6046,7 +6502,10 @@ function isMongoReceiver(node, source) {
6046
6502
  const obj = node.callee.object;
6047
6503
  if (obj.type === "Identifier") {
6048
6504
  if (isCollectionName(obj.name)) return true;
6049
- if (isPascalModel(obj.name)) return !fileHasSqlOrm;
6505
+ if (isPascalModel(obj.name)) {
6506
+ if (/extends\s+BaseEntity\b/.test(source)) return false;
6507
+ return !fileHasSqlOrm;
6508
+ }
6050
6509
  return fileHasMongo;
6051
6510
  }
6052
6511
  if (obj.type === "MemberExpression") {
@@ -7108,6 +7567,136 @@ function scanPromptInjection(file, source) {
7108
7567
  return findings;
7109
7568
  }
7110
7569
 
7570
+ // src/runtime/lru.ts
7571
+ var LruMap = class {
7572
+ constructor(max) {
7573
+ this.max = max;
7574
+ if (max < 1) throw new Error("LruMap max must be >= 1");
7575
+ }
7576
+ max;
7577
+ map = /* @__PURE__ */ new Map();
7578
+ get size() {
7579
+ return this.map.size;
7580
+ }
7581
+ has(key) {
7582
+ return this.map.has(key);
7583
+ }
7584
+ get(key) {
7585
+ if (!this.map.has(key)) return void 0;
7586
+ const value = this.map.get(key);
7587
+ this.map.delete(key);
7588
+ this.map.set(key, value);
7589
+ return value;
7590
+ }
7591
+ set(key, value) {
7592
+ if (this.map.has(key)) this.map.delete(key);
7593
+ this.map.set(key, value);
7594
+ while (this.map.size > this.max) {
7595
+ const oldest = this.map.keys().next().value;
7596
+ if (oldest === void 0) break;
7597
+ this.map.delete(oldest);
7598
+ }
7599
+ return this;
7600
+ }
7601
+ };
7602
+ function packageVersion() {
7603
+ let dir = dirname(fileURLToPath(import.meta.url));
7604
+ for (let depth = 0; depth < 6; depth += 1) {
7605
+ try {
7606
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
7607
+ const pkg = JSON.parse(raw);
7608
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7609
+ return pkg.version;
7610
+ }
7611
+ } catch {
7612
+ }
7613
+ const parent = dirname(dir);
7614
+ if (parent === dir) break;
7615
+ dir = parent;
7616
+ }
7617
+ throw new Error("fullshield package.json not found");
7618
+ }
7619
+
7620
+ // src/core/parse-pool.ts
7621
+ var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7622
+ var cache = new LruMap(64);
7623
+ function parsePoolEnabled() {
7624
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
7625
+ }
7626
+ function parsePoolSize() {
7627
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7628
+ if (raw !== void 0 && raw !== "") {
7629
+ const parsed = Number(raw);
7630
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7631
+ }
7632
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
7633
+ }
7634
+ function workerMemoryMb() {
7635
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7636
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7637
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7638
+ return 512;
7639
+ }
7640
+ function parseCacheKey(code) {
7641
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
7642
+ }
7643
+ function parseOne(code) {
7644
+ const key = parseCacheKey(code);
7645
+ const hit = cache.get(key);
7646
+ if (hit !== void 0) return Promise.resolve(hit);
7647
+ return new Promise((resolve5, reject) => {
7648
+ const worker = new Worker(fileURLToPath(workerUrl), {
7649
+ workerData: { code },
7650
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7651
+ });
7652
+ let settled = false;
7653
+ const finish = () => {
7654
+ void worker.terminate();
7655
+ };
7656
+ const succeed = (ast) => {
7657
+ if (settled) return;
7658
+ settled = true;
7659
+ cache.set(key, ast);
7660
+ finish();
7661
+ resolve5(ast);
7662
+ };
7663
+ const fail = (error) => {
7664
+ if (settled) return;
7665
+ settled = true;
7666
+ finish();
7667
+ reject(error);
7668
+ };
7669
+ worker.once("message", (ast) => {
7670
+ succeed(ast);
7671
+ });
7672
+ worker.once("error", (error) => {
7673
+ fail(error);
7674
+ });
7675
+ worker.once("exit", (code2) => {
7676
+ if (settled) return;
7677
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
7678
+ });
7679
+ });
7680
+ }
7681
+ async function parseWithPool(codes) {
7682
+ const size = parsePoolSize();
7683
+ const out = new Array(codes.length);
7684
+ let next = 0;
7685
+ async function run() {
7686
+ for (; ; ) {
7687
+ const index = next;
7688
+ next += 1;
7689
+ if (index >= codes.length) return;
7690
+ const code = codes[index];
7691
+ if (code === void 0) return;
7692
+ out[index] = await parseOne(code);
7693
+ }
7694
+ }
7695
+ const workers = Math.min(size, Math.max(1, codes.length));
7696
+ await Promise.all(Array.from({ length: workers }, () => run()));
7697
+ return out;
7698
+ }
7699
+
7111
7700
  // src/modules/sast/engine.ts
7112
7701
  var sastRules = [
7113
7702
  sqliRule,
@@ -7140,8 +7729,8 @@ function snippetOf(source, node) {
7140
7729
  if (node.range === void 0) return "";
7141
7730
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7142
7731
  }
7143
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7144
- const facts = analyzeFile(ast, extraReturners);
7732
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7733
+ const facts = analyzeFile(ast, extraReturners2);
7145
7734
  const findings = [];
7146
7735
  for (const rule of sastRules) {
7147
7736
  const ctx = {
@@ -7219,51 +7808,203 @@ function findingsFromTemplate(filename, source) {
7219
7808
  xssRule.create(ctx);
7220
7809
  return findings;
7221
7810
  }
7811
+ function dedupeSastSinks(findings) {
7812
+ const seen = /* @__PURE__ */ new Set();
7813
+ const out = [];
7814
+ for (const finding4 of findings) {
7815
+ if (finding4.module !== "sast" || finding4.line === void 0) {
7816
+ out.push(finding4);
7817
+ continue;
7818
+ }
7819
+ const key = `${finding4.ruleId}\0${finding4.file ?? ""}\0${finding4.line}`;
7820
+ if (seen.has(key)) continue;
7821
+ seen.add(key);
7822
+ out.push(finding4);
7823
+ }
7824
+ return out;
7825
+ }
7826
+ function sastStreamThreshold() {
7827
+ const raw = process.env.FULLSHIELD_SAST_STREAM_AT;
7828
+ if (raw !== void 0 && raw !== "") {
7829
+ const parsed = Number(raw);
7830
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7831
+ }
7832
+ return 4e3;
7833
+ }
7834
+ function sastBatchSize() {
7835
+ const raw = process.env.FULLSHIELD_SAST_BATCH;
7836
+ if (raw !== void 0 && raw !== "") {
7837
+ const parsed = Number(raw);
7838
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7839
+ }
7840
+ return 150;
7841
+ }
7842
+ function skipFinding(rel, message) {
7843
+ return {
7844
+ id: makeFindingId("SAST-SKIP-001", rel, message),
7845
+ ruleId: "SAST-SKIP-001",
7846
+ module: "sast",
7847
+ title: "File skipped by SAST",
7848
+ severity: "info",
7849
+ confidence: "high",
7850
+ file: rel,
7851
+ message,
7852
+ remediation: "Raise sast.maxFileBytes in fullshield.config.json to parse this file, or leave it skipped."
7853
+ };
7854
+ }
7855
+ function skipReason(source, maxBytes) {
7856
+ if (source.includes("\0")) return "it contains NUL bytes";
7857
+ const bytes = Buffer.byteLength(source);
7858
+ if (bytes > maxBytes) return `${bytes} bytes exceeds the SAST parse limit of ${maxBytes} bytes`;
7859
+ return void 0;
7860
+ }
7861
+ async function readScanSource(cwd, rel) {
7862
+ try {
7863
+ return await readFile(join(cwd, rel), "utf8");
7864
+ } catch {
7865
+ return void 0;
7866
+ }
7867
+ }
7868
+ function cloneAst(value) {
7869
+ if (value === null || typeof value !== "object") return void 0;
7870
+ try {
7871
+ return structuredClone(value);
7872
+ } catch {
7873
+ return void 0;
7874
+ }
7875
+ }
7876
+ async function parseInProcess(source) {
7877
+ try {
7878
+ return await parseSource(source);
7879
+ } catch {
7880
+ return void 0;
7881
+ }
7882
+ }
7883
+ async function parseCode(source) {
7884
+ if (!parsePoolEnabled()) return parseInProcess(source);
7885
+ try {
7886
+ const cloned = cloneAst((await parseWithPool([source]))[0]);
7887
+ if (cloned !== void 0) return cloned;
7888
+ } catch {
7889
+ }
7890
+ return parseInProcess(source);
7891
+ }
7892
+ function releaseParsed(parsed) {
7893
+ for (const entry of parsed) {
7894
+ entry.source = "";
7895
+ entry.ast = void 0;
7896
+ }
7897
+ parsed.length = 0;
7898
+ }
7899
+ function extraReturners(rel, ast, files, returning) {
7900
+ const ns = collectRequires(rel, ast, files);
7901
+ const extra = new Set(returning.get(rel));
7902
+ for (const [local, spec] of ns) {
7903
+ const hash = spec.indexOf("#");
7904
+ const file = hash >= 0 ? spec.slice(0, hash) : spec;
7905
+ const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7906
+ if (returning.get(file)?.has(exp)) extra.add(local);
7907
+ }
7908
+ return extra;
7909
+ }
7910
+ async function runSastStreaming(ctx) {
7911
+ const maxBytes = ctx.config.sast.maxFileBytes;
7912
+ const findings = [];
7913
+ const returning = /* @__PURE__ */ new Map();
7914
+ const parseable = [];
7915
+ const fileSet = /* @__PURE__ */ new Set();
7916
+ let projectCsrf = false;
7917
+ for (const rel of ctx.files) {
7918
+ const isSource = SOURCE_EXT.test(rel);
7919
+ const isTemplate = TEMPLATE_EXT.test(rel);
7920
+ if (!isSource && !isTemplate) continue;
7921
+ const source = await readScanSource(ctx.cwd, rel);
7922
+ if (source === void 0) continue;
7923
+ const why = skipReason(source, maxBytes);
7924
+ if (why !== void 0) {
7925
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7926
+ continue;
7927
+ }
7928
+ if (sourceHasCsrfGuard(source)) projectCsrf = true;
7929
+ if (isTemplate) {
7930
+ findings.push(...findingsFromTemplate(rel, source));
7931
+ continue;
7932
+ }
7933
+ const ast = await parseCode(source);
7934
+ if (ast === void 0) continue;
7935
+ returning.set(rel, functionsThatReturnTaint(ast));
7936
+ parseable.push(rel);
7937
+ fileSet.add(rel);
7938
+ }
7939
+ for (const rel of parseable) {
7940
+ const source = await readScanSource(ctx.cwd, rel);
7941
+ if (source === void 0) continue;
7942
+ const ast = await parseCode(source);
7943
+ if (ast === void 0) continue;
7944
+ findings.push(
7945
+ ...findingsFromAst(rel, source, ast, extraReturners(rel, ast, fileSet, returning), projectCsrf)
7946
+ );
7947
+ }
7948
+ const batch = sastBatchSize();
7949
+ for (let i = 0; i < parseable.length; i += batch) {
7950
+ const parsed = [];
7951
+ for (const rel of parseable.slice(i, i + batch)) {
7952
+ const source = await readScanSource(ctx.cwd, rel);
7953
+ if (source === void 0) continue;
7954
+ const ast = await parseCode(source);
7955
+ if (ast === void 0) continue;
7956
+ parsed.push({ file: rel, source, ast });
7957
+ }
7958
+ findings.push(...runInterproc(parsed));
7959
+ releaseParsed(parsed);
7960
+ }
7961
+ return dedupeSastSinks(findings);
7962
+ }
7222
7963
  var sastModule = {
7223
7964
  name: "sast",
7224
7965
  async run(ctx) {
7966
+ const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
7967
+ if (candidates.length > sastStreamThreshold()) return runSastStreaming(ctx);
7968
+ const maxBytes = ctx.config.sast.maxFileBytes;
7225
7969
  const findings = [];
7226
7970
  const parsed = [];
7227
- for (const rel of ctx.files) {
7228
- const isSource = SOURCE_EXT.test(rel);
7229
- const isTemplate = TEMPLATE_EXT.test(rel);
7230
- if (!isSource && !isTemplate) continue;
7231
- let source;
7232
- try {
7233
- source = await readFile(join(ctx.cwd, rel), "utf8");
7234
- } catch {
7971
+ for (const rel of candidates) {
7972
+ const source = await readScanSource(ctx.cwd, rel);
7973
+ if (source === void 0) continue;
7974
+ const why = skipReason(source, maxBytes);
7975
+ if (why !== void 0) {
7976
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7235
7977
  continue;
7236
7978
  }
7237
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7238
- if (isTemplate) {
7979
+ if (TEMPLATE_EXT.test(rel)) {
7239
7980
  findings.push(...findingsFromTemplate(rel, source));
7240
7981
  continue;
7241
7982
  }
7242
- try {
7243
- const ast = await parseSource(source);
7244
- parsed.push({ file: rel, source, ast });
7245
- } catch {
7246
- }
7983
+ const ast = await parseCode(source);
7984
+ if (ast === void 0) continue;
7985
+ parsed.push({ file: rel, source, ast });
7247
7986
  }
7248
7987
  const files = new Set(parsed.map((p) => p.file));
7249
7988
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7250
7989
  const returning = /* @__PURE__ */ new Map();
7251
- for (const p of parsed) {
7252
- returning.set(p.file, functionsThatReturnTaint(p.ast));
7253
- }
7254
- for (const p of parsed) {
7255
- const ns = collectRequires(p.file, p.ast, files);
7256
- const extra = new Set(returning.get(p.file));
7257
- for (const [local, spec] of ns) {
7258
- const hash = spec.indexOf("#");
7259
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7260
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7261
- if (returning.get(file)?.has(exp)) extra.add(local);
7262
- }
7263
- findings.push(...findingsFromAst(p.file, p.source, p.ast, extra, projectCsrf));
7990
+ for (const entry of parsed) {
7991
+ returning.set(entry.file, functionsThatReturnTaint(entry.ast));
7992
+ }
7993
+ for (const entry of parsed) {
7994
+ findings.push(
7995
+ ...findingsFromAst(
7996
+ entry.file,
7997
+ entry.source,
7998
+ entry.ast,
7999
+ extraReturners(entry.file, entry.ast, files, returning),
8000
+ projectCsrf
8001
+ )
8002
+ );
7264
8003
  }
7265
8004
  findings.push(...runInterproc(parsed));
7266
- return findings;
8005
+ const out = dedupeSastSinks(findings);
8006
+ releaseParsed(parsed);
8007
+ return out;
7267
8008
  }
7268
8009
  };
7269
8010
 
@@ -7618,7 +8359,7 @@ function createAiClient(options) {
7618
8359
  const useFixtures = options.useFixtures ?? !isLiveAi();
7619
8360
  const model = options.model ?? process.env.FULLSHIELD_AI_MODEL ?? (useFixtures ? "recorded-fixture" : "qwen2.5-coder:7b");
7620
8361
  const provider = options.provider ?? process.env.FULLSHIELD_AI_PROVIDER ?? (useFixtures ? "fixture" : "ollama");
7621
- const cache = /* @__PURE__ */ new Map();
8362
+ const cache2 = /* @__PURE__ */ new Map();
7622
8363
  let spentUsd = 0;
7623
8364
  let networkCalls = 0;
7624
8365
  const liveCost = provider === "ollama" ? 0 : FIXTURE_COST;
@@ -7638,7 +8379,7 @@ function createAiClient(options) {
7638
8379
  const redactedPrompt = `${modeInstruction(req.mode)}
7639
8380
  ${wrapUntrustedCode(req.prompt)}`;
7640
8381
  const key = cacheKey(req.model ?? model, redactedPrompt);
7641
- const cached = cache.get(key);
8382
+ const cached = cache2.get(key);
7642
8383
  if (cached !== void 0) {
7643
8384
  return { ...cached, cached: true, costUsd: 0 };
7644
8385
  }
@@ -7652,7 +8393,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7652
8393
  cached: false,
7653
8394
  redactedPrompt
7654
8395
  };
7655
- cache.set(key, response2);
8396
+ cache2.set(key, response2);
7656
8397
  return response2;
7657
8398
  }
7658
8399
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -7702,21 +8443,42 @@ ${wrapUntrustedCode(req.prompt)}`;
7702
8443
  cached: false,
7703
8444
  redactedPrompt
7704
8445
  };
7705
- cache.set(key, response);
8446
+ cache2.set(key, response);
7706
8447
  return response;
7707
8448
  }
7708
8449
  };
7709
8450
  }
8451
+ function isIsoDate(value) {
8452
+ if (!/^\d{4}-\d{2}-\d{2}(T[\d:.+\-Z]+)?$/.test(value)) return false;
8453
+ return Number.isFinite(Date.parse(value));
8454
+ }
8455
+ function expiresAtMs(expires) {
8456
+ if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) return Date.parse(`${expires}T23:59:59.999Z`);
8457
+ return Date.parse(expires);
8458
+ }
8459
+ function validSuppression(value) {
8460
+ if (typeof value !== "object" || value === null) return void 0;
8461
+ const row = value;
8462
+ if (typeof row.ruleId !== "string" || row.ruleId.length === 0) return void 0;
8463
+ if (typeof row.owner !== "string" || row.owner.length === 0) return void 0;
8464
+ if (typeof row.reason !== "string" || row.reason.length === 0) return void 0;
8465
+ if (typeof row.expires !== "string" || !isIsoDate(row.expires)) return void 0;
8466
+ const file = row.file === void 0 ? void 0 : row.file.replace(/\\/g, "/");
8467
+ if (row.file !== void 0 && (typeof row.file !== "string" || row.file.length === 0)) return void 0;
8468
+ return { ruleId: row.ruleId, file, owner: row.owner, reason: row.reason, expires: row.expires };
8469
+ }
7710
8470
  async function readBaseline(path) {
7711
8471
  try {
7712
8472
  const raw = JSON.parse(await readFile(path, "utf8"));
7713
8473
  if (raw.version !== 1 || !Array.isArray(raw.findings)) return void 0;
8474
+ if (raw.suppressions !== void 0 && !Array.isArray(raw.suppressions)) return void 0;
7714
8475
  return raw;
7715
8476
  } catch {
7716
8477
  return void 0;
7717
8478
  }
7718
8479
  }
7719
8480
  async function writeBaseline(path, findings) {
8481
+ const existing = await readBaseline(path);
7720
8482
  const doc = {
7721
8483
  version: 1,
7722
8484
  findings: findings.map((f) => ({
@@ -7724,7 +8486,8 @@ async function writeBaseline(path, findings) {
7724
8486
  ruleId: f.ruleId,
7725
8487
  file: f.file,
7726
8488
  line: f.line
7727
- }))
8489
+ })),
8490
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
7728
8491
  };
7729
8492
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
7730
8493
  `, "utf8");
@@ -7734,6 +8497,30 @@ function applyBaseline(findings, baseline) {
7734
8497
  const known = new Set(baseline.findings.map((f) => f.id));
7735
8498
  return findings.filter((f) => !known.has(f.id));
7736
8499
  }
8500
+ function applySuppressions(findings, rows, now = /* @__PURE__ */ new Date()) {
8501
+ if (rows === void 0 || rows.length === 0) return { findings: [...findings], errors: [] };
8502
+ const errors = [];
8503
+ const active = [];
8504
+ rows.forEach((row, index) => {
8505
+ const parsed = validSuppression(row);
8506
+ if (parsed === void 0) {
8507
+ errors.push(`suppression ${index + 1} needs ruleId, owner, reason, and an ISO expires date`);
8508
+ return;
8509
+ }
8510
+ if (expiresAtMs(parsed.expires) < now.getTime()) {
8511
+ errors.push(
8512
+ `suppression for ${parsed.ruleId} expired on ${parsed.expires} (owner: ${parsed.owner}); renew or remove it`
8513
+ );
8514
+ return;
8515
+ }
8516
+ active.push(parsed);
8517
+ });
8518
+ const kept = findings.filter((finding4) => {
8519
+ const file = (finding4.file ?? "").replace(/\\/g, "/");
8520
+ return !active.some((row) => row.ruleId === finding4.ruleId && (row.file === void 0 || row.file === file));
8521
+ });
8522
+ return { findings: kept, errors };
8523
+ }
7737
8524
 
7738
8525
  // src/core/config.ts
7739
8526
  init_finding();
@@ -7742,14 +8529,27 @@ var DEFAULT_CONFIG = {
7742
8529
  modules: [...MODULE_NAMES],
7743
8530
  failOn: "high",
7744
8531
  ignore: ["dist/**", "**/*.test.ts"],
8532
+ defaultExcludes: true,
7745
8533
  baseline: ".fullshield-baseline.json",
7746
8534
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
7747
8535
  deps: { offline: false },
7748
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8536
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8537
+ sast: { maxFileBytes: 1024 * 1024 }
7749
8538
  };
7750
8539
  var ConfigError = class extends Error {
7751
8540
  };
7752
- var KNOWN_KEYS = /* @__PURE__ */ new Set(["modules", "failOn", "ignore", "baseline", "notify", "deps", "ai"]);
8541
+ var KNOWN_KEYS = /* @__PURE__ */ new Set([
8542
+ "modules",
8543
+ "failOn",
8544
+ "ignore",
8545
+ "defaultExcludes",
8546
+ "excludes",
8547
+ "baseline",
8548
+ "notify",
8549
+ "deps",
8550
+ "ai",
8551
+ "sast"
8552
+ ]);
7753
8553
  function validate(raw, source) {
7754
8554
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
7755
8555
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -7851,6 +8651,32 @@ function validate(raw, source) {
7851
8651
  }
7852
8652
  out.ai = ai;
7853
8653
  }
8654
+ if (record.defaultExcludes !== void 0) {
8655
+ if (typeof record.defaultExcludes !== "boolean") {
8656
+ throw new ConfigError(`${source}: "defaultExcludes" must be a boolean`);
8657
+ }
8658
+ out.defaultExcludes = record.defaultExcludes;
8659
+ }
8660
+ if (record.excludes !== void 0) {
8661
+ if (!Array.isArray(record.excludes) || record.excludes.some((p) => typeof p !== "string")) {
8662
+ throw new ConfigError(`${source}: "excludes" must be an array of glob strings`);
8663
+ }
8664
+ out.excludes = record.excludes;
8665
+ }
8666
+ if (record.sast !== void 0) {
8667
+ if (typeof record.sast !== "object" || record.sast === null || Array.isArray(record.sast)) {
8668
+ throw new ConfigError(`${source}: "sast" must be an object`);
8669
+ }
8670
+ const rawSast = record.sast;
8671
+ const sast = { maxFileBytes: 1024 * 1024 };
8672
+ if (rawSast.maxFileBytes !== void 0) {
8673
+ if (typeof rawSast.maxFileBytes !== "number" || rawSast.maxFileBytes < 1) {
8674
+ throw new ConfigError(`${source}: "sast.maxFileBytes" must be a positive number`);
8675
+ }
8676
+ sast.maxFileBytes = rawSast.maxFileBytes;
8677
+ }
8678
+ out.sast = sast;
8679
+ }
7854
8680
  return out;
7855
8681
  }
7856
8682
  async function loadConfig(cwd, configPath) {
@@ -7877,10 +8703,26 @@ async function loadConfig(cwd, configPath) {
7877
8703
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
7878
8704
  }
7879
8705
  var ignoreFactory = ignoreModule.default;
7880
- var ALWAYS_IGNORED = [".git/", "node_modules/", ".fullshield/"];
8706
+ var HARD_IGNORED = [".git/", ".fullshield/"];
8707
+ var DEFAULT_EXCLUDES = [
8708
+ "node_modules/",
8709
+ "dist/",
8710
+ "build/",
8711
+ ".next/",
8712
+ "coverage/",
8713
+ "vendor/",
8714
+ "vendored/",
8715
+ "third_party/",
8716
+ "third-party/",
8717
+ "compiled/",
8718
+ "*.min.js"
8719
+ ];
7881
8720
  async function walkFiles(options) {
7882
8721
  const ig = ignoreFactory();
7883
- ig.add(ALWAYS_IGNORED);
8722
+ ig.add(HARD_IGNORED);
8723
+ if (options.defaultExcludes !== false) {
8724
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8725
+ }
7884
8726
  try {
7885
8727
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
7886
8728
  } catch {
@@ -7929,23 +8771,6 @@ async function listStagedFiles(cwd) {
7929
8771
  // src/core/engine.ts
7930
8772
  init_suppression();
7931
8773
  init_finding();
7932
- function packageVersion() {
7933
- let dir = dirname(fileURLToPath(import.meta.url));
7934
- for (let depth = 0; depth < 6; depth += 1) {
7935
- try {
7936
- const raw = readFileSync(join(dir, "package.json"), "utf8");
7937
- const pkg = JSON.parse(raw);
7938
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7939
- return pkg.version;
7940
- }
7941
- } catch {
7942
- }
7943
- const parent = dirname(dir);
7944
- if (parent === dir) break;
7945
- dir = parent;
7946
- }
7947
- throw new Error("fullshield package.json not found");
7948
- }
7949
8774
 
7950
8775
  // src/policy/index.ts
7951
8776
  init_finding();
@@ -8079,7 +8904,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8079
8904
  return "*";
8080
8905
  }
8081
8906
  }
8082
- function expiresAtMs(expires) {
8907
+ function expiresAtMs2(expires) {
8083
8908
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8084
8909
  return Date.parse(`${expires}T23:59:59.999Z`);
8085
8910
  }
@@ -8101,7 +8926,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8101
8926
  });
8102
8927
  const suppressed = /* @__PURE__ */ new Set();
8103
8928
  for (const exception of policy.exceptions ?? []) {
8104
- if (expiresAtMs(exception.expires) < now.getTime()) {
8929
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8105
8930
  errors.push(
8106
8931
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8107
8932
  );
@@ -8170,7 +8995,12 @@ async function runScan(options = {}, modules = builtinModules) {
8170
8995
  options.modules ?? config.modules,
8171
8996
  loadedPolicy?.requiredModules
8172
8997
  );
8173
- let files = await walkFiles({ cwd, ignore: config.ignore });
8998
+ let files = await walkFiles({
8999
+ cwd,
9000
+ ignore: config.ignore,
9001
+ defaultExcludes: config.defaultExcludes,
9002
+ excludes: config.excludes
9003
+ });
8174
9004
  if (options.staged === true) {
8175
9005
  const staged = new Set(await listStagedFiles(cwd));
8176
9006
  files = files.filter((file) => staged.has(file));
@@ -8204,6 +9034,7 @@ async function runScan(options = {}, modules = builtinModules) {
8204
9034
  );
8205
9035
  }
8206
9036
  const extraHits = [];
9037
+ let historyNotice;
8207
9038
  const secretsWanted = modulesRequested.includes("secrets");
8208
9039
  if (secretsAdvEnabled() && secretsWanted) {
8209
9040
  if (options.history === true) {
@@ -8211,6 +9042,7 @@ async function runScan(options = {}, modules = builtinModules) {
8211
9042
  const hist = await scanGitHistory2(cwd);
8212
9043
  extraHits.push(...hist.hits);
8213
9044
  collected.push(...hist.findings);
9045
+ historyNotice = hist.history;
8214
9046
  }
8215
9047
  if (options.bundle !== void 0 && options.bundle.length > 0) {
8216
9048
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -8231,7 +9063,11 @@ async function runScan(options = {}, modules = builtinModules) {
8231
9063
  if (options.updateBaseline === true) {
8232
9064
  await writeBaseline(baselinePath, unsuppressed);
8233
9065
  }
8234
- let findings = sortFindings(applyBaseline(unsuppressed, await readBaseline(baselinePath)));
9066
+ const baselineDoc = await readBaseline(baselinePath);
9067
+ let findings = sortFindings(applyBaseline(unsuppressed, baselineDoc));
9068
+ const suppressed = applySuppressions(findings, baselineDoc?.suppressions);
9069
+ findings = sortFindings(suppressed.findings);
9070
+ const suppressionErrors = suppressed.errors;
8235
9071
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
8236
9072
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
8237
9073
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -8303,10 +9139,12 @@ async function runScan(options = {}, modules = builtinModules) {
8303
9139
  cwd,
8304
9140
  filesScanned: files.length,
8305
9141
  modulesRequested: [...modulesRequested],
8306
- modulesRun
9142
+ modulesRun,
9143
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
8307
9144
  },
8308
9145
  config,
8309
- policy: policyResult
9146
+ policy: policyResult,
9147
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
8310
9148
  };
8311
9149
  }
8312
9150
  function scan(options = {}) {