fullshield 0.2.6 → 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/agent.js CHANGED
@@ -557,6 +557,7 @@ var init_incremental = __esm({
557
557
  // src/modules/secrets/history.ts
558
558
  var history_exports = {};
559
559
  __export(history_exports, {
560
+ historyCommitLimit: () => historyCommitLimit,
560
561
  scanGitHistory: () => scanGitHistory
561
562
  });
562
563
  function gitError(error) {
@@ -599,7 +600,13 @@ function remapHit(hit, file) {
599
600
  secret: hit.secret
600
601
  };
601
602
  }
602
- 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) {
603
610
  const changes = [];
604
611
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
605
612
  const child = spawn(
@@ -616,6 +623,8 @@ async function streamLogChanges(cwd) {
616
623
  "--format=FSCOMMIT:%H",
617
624
  "--no-textconv",
618
625
  "--no-ext-diff",
626
+ "-n",
627
+ String(maxCommits),
619
628
  "HEAD"
620
629
  ],
621
630
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -684,8 +693,14 @@ async function headSecretKeys(cwd) {
684
693
  }
685
694
  return keys;
686
695
  }
687
- async function scanGitHistory(cwd) {
688
- 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);
689
704
  const blobHits = /* @__PURE__ */ new Map();
690
705
  const first = /* @__PURE__ */ new Map();
691
706
  for (const change of changes) {
@@ -722,7 +737,13 @@ async function scanGitHistory(cwd) {
722
737
  secret: hit.secret
723
738
  });
724
739
  }
725
- 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
+ };
726
747
  }
727
748
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
728
749
  var init_history = __esm({
@@ -1070,7 +1091,8 @@ function walk(node, visit) {
1070
1091
  }
1071
1092
  }
1072
1093
  function attachParents(node, parent) {
1073
- node.parent = parent;
1094
+ const current = node;
1095
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
1074
1096
  for (const key of Object.keys(node)) {
1075
1097
  if (SKIP_KEYS.has(String(key))) continue;
1076
1098
  const value = node[key];
@@ -1143,6 +1165,10 @@ function mcpCfgEnabled() {
1143
1165
  function taintV2Enabled() {
1144
1166
  return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
1145
1167
  }
1168
+ var LICENSE_FLAG = "FULLSHIELD_EXPERIMENTAL_LICENSE";
1169
+ function licenseEnabled() {
1170
+ return process.env[LICENSE_FLAG] === "1";
1171
+ }
1146
1172
 
1147
1173
  // src/modules/config/finding.ts
1148
1174
  init_finding();
@@ -2132,6 +2158,29 @@ function shortestDependencyPath(target, starts, edges) {
2132
2158
  }
2133
2159
  return void 0;
2134
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
+ }
2135
2184
  function reachableFrom(target, roots, edges) {
2136
2185
  if (roots.has(target)) return true;
2137
2186
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -2152,9 +2201,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2152
2201
  }
2153
2202
  if (graph !== void 0 && graph.edgesKnown) {
2154
2203
  const roots = importedDirectNames(productionUsage, graph);
2155
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
2204
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
2205
+ const path = paths[0];
2156
2206
  if (path !== void 0 && path.length >= 2) {
2157
- return { reachability: "reachable-via-dependency", path };
2207
+ return { reachability: "reachable-via-dependency", path, paths };
2158
2208
  }
2159
2209
  return { reachability: "not-imported" };
2160
2210
  }
@@ -2184,8 +2234,108 @@ function isDevOnly(pkgName, ctx) {
2184
2234
  if (inProd) return false;
2185
2235
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
2186
2236
  }
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 finding3 of findings) {
2268
+ if (finding3.ruleId !== "DEPS-VULN-001" && finding3.ruleId !== "DEPS-MALICIOUS-001") {
2269
+ passthrough.push(finding3);
2270
+ continue;
2271
+ }
2272
+ const name = packageNameFromSnippet(finding3.snippet ?? "") ?? finding3.snippet ?? finding3.id;
2273
+ const advisory = finding3.references?.[0] ?? finding3.id;
2274
+ const key = `${finding3.ruleId}\0${name}\0${advisory}`;
2275
+ const list = groups.get(key);
2276
+ if (list === void 0) groups.set(key, [finding3]);
2277
+ else list.push(finding3);
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 finding3 of list) {
2303
+ const candidates = finding3.dependencyPaths ?? (finding3.dependencyPath !== void 0 ? [finding3.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
+ }
2187
2337
  function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2188
- return findings.map((finding3) => {
2338
+ const mapped = findings.map((finding3) => {
2189
2339
  if (finding3.ruleId === "DEPS-MALICIOUS-001") {
2190
2340
  if (finding3.snippet === void 0) return finding3;
2191
2341
  const pkg2 = packageNameFromSnippet(finding3.snippet);
@@ -2195,7 +2345,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2195
2345
  ...finding3,
2196
2346
  severity: "critical",
2197
2347
  reachability: classified2.reachability,
2198
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
2348
+ ...pathFields(classified2)
2199
2349
  };
2200
2350
  }
2201
2351
  if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
@@ -2205,7 +2355,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2205
2355
  const named = symbols.get(finding3.id) ?? [];
2206
2356
  if (named.length > 0 && ctx.sources !== void 0) {
2207
2357
  const symbolClass = classifySymbolUse(named, ctx.sources);
2208
- classified = { reachability: symbolClass, path: classified.path };
2358
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
2209
2359
  }
2210
2360
  const steps = reachabilitySteps(classified.reachability);
2211
2361
  let severity = steps === 0 ? finding3.severity : downgradeSeverity(finding3.severity, steps);
@@ -2213,6 +2363,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2213
2363
  if (classified.path !== void 0) {
2214
2364
  const shown = classified.path.join(" > ");
2215
2365
  message = `${message} Reachable via ${shown}.`;
2366
+ message = appendOtherPaths(message, classified.paths);
2216
2367
  }
2217
2368
  const devOnly = isDevOnly(pkg, ctx);
2218
2369
  if (devOnly) {
@@ -2222,11 +2373,12 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2222
2373
  return {
2223
2374
  ...finding3,
2224
2375
  reachability: classified.reachability,
2225
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
2376
+ ...pathFields(classified),
2226
2377
  severity,
2227
2378
  message
2228
2379
  };
2229
2380
  });
2381
+ return groupPackageAdvisories(mapped);
2230
2382
  }
2231
2383
 
2232
2384
  // src/modules/deps/semver.ts
@@ -4128,6 +4280,59 @@ function experimentalVex() {
4128
4280
  return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
4129
4281
  }
4130
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
+
4131
4336
  // src/fixer/eol.ts
4132
4337
  function detectEol(text) {
4133
4338
  return text.includes("\r\n") ? "\r\n" : "\n";
@@ -4226,7 +4431,7 @@ function walkJs(dir, out = []) {
4226
4431
  }
4227
4432
  return out;
4228
4433
  }
4229
- function readJson(path) {
4434
+ function readJson2(path) {
4230
4435
  return JSON.parse(readFileSync(path, "utf8"));
4231
4436
  }
4232
4437
  var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
@@ -4240,7 +4445,7 @@ function packageSources(dir) {
4240
4445
  let scripts = "";
4241
4446
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
4242
4447
  try {
4243
- const pkg = readJson(manifestPath);
4448
+ const pkg = readJson2(manifestPath);
4244
4449
  const block = pkg.scripts ?? {};
4245
4450
  scripts = Object.values(block).join("\n");
4246
4451
  } catch {
@@ -4559,6 +4764,7 @@ var depsModule = {
4559
4764
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4560
4765
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4561
4766
  }
4767
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4562
4768
  return reached;
4563
4769
  }
4564
4770
  };
@@ -5474,7 +5680,7 @@ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5474
5680
  }
5475
5681
  return changed;
5476
5682
  }
5477
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5683
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5478
5684
  attachParents(ast);
5479
5685
  const facts = /* @__PURE__ */ new WeakMap();
5480
5686
  const functions = [];
@@ -5488,7 +5694,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5488
5694
  }
5489
5695
  const seeds = /* @__PURE__ */ new Map();
5490
5696
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5491
- const returners = new Set(extraReturners);
5697
+ const returners = new Set(extraReturners2);
5492
5698
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5493
5699
  let changed = false;
5494
5700
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -7360,6 +7566,39 @@ function scanPromptInjection(file, source) {
7360
7566
  });
7361
7567
  return findings;
7362
7568
  }
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
+ };
7363
7602
  function packageVersion() {
7364
7603
  let dir = dirname(fileURLToPath(import.meta.url));
7365
7604
  for (let depth = 0; depth < 6; depth += 1) {
@@ -7380,7 +7619,7 @@ function packageVersion() {
7380
7619
 
7381
7620
  // src/core/parse-pool.ts
7382
7621
  var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7383
- var cache = /* @__PURE__ */ new Map();
7622
+ var cache = new LruMap(64);
7384
7623
  function parsePoolEnabled() {
7385
7624
  return process.env.FULLSHIELD_PARSE_POOL === "1";
7386
7625
  }
@@ -7388,9 +7627,15 @@ function parsePoolSize() {
7388
7627
  const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7389
7628
  if (raw !== void 0 && raw !== "") {
7390
7629
  const parsed = Number(raw);
7391
- if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7630
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7392
7631
  }
7393
- return Math.max(1, availableParallelism() - 1);
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;
7394
7639
  }
7395
7640
  function parseCacheKey(code) {
7396
7641
  return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
@@ -7400,12 +7645,37 @@ function parseOne(code) {
7400
7645
  const hit = cache.get(key);
7401
7646
  if (hit !== void 0) return Promise.resolve(hit);
7402
7647
  return new Promise((resolve5, reject) => {
7403
- const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
7404
- worker.once("message", (ast) => {
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;
7405
7659
  cache.set(key, ast);
7660
+ finish();
7406
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"}`));
7407
7678
  });
7408
- worker.once("error", reject);
7409
7679
  });
7410
7680
  }
7411
7681
  async function parseWithPool(codes) {
@@ -7459,8 +7729,8 @@ function snippetOf(source, node) {
7459
7729
  if (node.range === void 0) return "";
7460
7730
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7461
7731
  }
7462
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7463
- const facts = analyzeFile(ast, extraReturners);
7732
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7733
+ const facts = analyzeFile(ast, extraReturners2);
7464
7734
  const findings = [];
7465
7735
  for (const rule of sastRules) {
7466
7736
  const ctx = {
@@ -7538,52 +7808,203 @@ function findingsFromTemplate(filename, source) {
7538
7808
  xssRule.create(ctx);
7539
7809
  return findings;
7540
7810
  }
7811
+ function dedupeSastSinks(findings) {
7812
+ const seen = /* @__PURE__ */ new Set();
7813
+ const out = [];
7814
+ for (const finding3 of findings) {
7815
+ if (finding3.module !== "sast" || finding3.line === void 0) {
7816
+ out.push(finding3);
7817
+ continue;
7818
+ }
7819
+ const key = `${finding3.ruleId}\0${finding3.file ?? ""}\0${finding3.line}`;
7820
+ if (seen.has(key)) continue;
7821
+ seen.add(key);
7822
+ out.push(finding3);
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
+ }
7541
7963
  var sastModule = {
7542
7964
  name: "sast",
7543
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;
7544
7969
  const findings = [];
7545
7970
  const parsed = [];
7546
- for (const rel of ctx.files) {
7547
- const isSource = SOURCE_EXT.test(rel);
7548
- const isTemplate = TEMPLATE_EXT.test(rel);
7549
- if (!isSource && !isTemplate) continue;
7550
- let source;
7551
- try {
7552
- source = await readFile(join(ctx.cwd, rel), "utf8");
7553
- } 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}.`));
7554
7977
  continue;
7555
7978
  }
7556
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7557
- if (isTemplate) {
7979
+ if (TEMPLATE_EXT.test(rel)) {
7558
7980
  findings.push(...findingsFromTemplate(rel, source));
7559
7981
  continue;
7560
7982
  }
7561
- try {
7562
- const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
7563
- if (ast === void 0) continue;
7564
- parsed.push({ file: rel, source, ast });
7565
- } catch {
7566
- }
7983
+ const ast = await parseCode(source);
7984
+ if (ast === void 0) continue;
7985
+ parsed.push({ file: rel, source, ast });
7567
7986
  }
7568
7987
  const files = new Set(parsed.map((p) => p.file));
7569
7988
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7570
7989
  const returning = /* @__PURE__ */ new Map();
7571
- for (const p of parsed) {
7572
- returning.set(p.file, functionsThatReturnTaint(p.ast));
7573
- }
7574
- for (const p of parsed) {
7575
- const ns = collectRequires(p.file, p.ast, files);
7576
- const extra = new Set(returning.get(p.file));
7577
- for (const [local, spec] of ns) {
7578
- const hash = spec.indexOf("#");
7579
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7580
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7581
- if (returning.get(file)?.has(exp)) extra.add(local);
7582
- }
7583
- 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
+ );
7584
8003
  }
7585
8004
  findings.push(...runInterproc(parsed));
7586
- return findings;
8005
+ const out = dedupeSastSinks(findings);
8006
+ releaseParsed(parsed);
8007
+ return out;
7587
8008
  }
7588
8009
  };
7589
8010
 
@@ -8027,16 +8448,37 @@ ${wrapUntrustedCode(req.prompt)}`;
8027
8448
  }
8028
8449
  };
8029
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
+ }
8030
8470
  async function readBaseline(path) {
8031
8471
  try {
8032
8472
  const raw = JSON.parse(await readFile(path, "utf8"));
8033
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;
8034
8475
  return raw;
8035
8476
  } catch {
8036
8477
  return void 0;
8037
8478
  }
8038
8479
  }
8039
8480
  async function writeBaseline(path, findings) {
8481
+ const existing = await readBaseline(path);
8040
8482
  const doc = {
8041
8483
  version: 1,
8042
8484
  findings: findings.map((f) => ({
@@ -8044,7 +8486,8 @@ async function writeBaseline(path, findings) {
8044
8486
  ruleId: f.ruleId,
8045
8487
  file: f.file,
8046
8488
  line: f.line
8047
- }))
8489
+ })),
8490
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
8048
8491
  };
8049
8492
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
8050
8493
  `, "utf8");
@@ -8054,6 +8497,30 @@ function applyBaseline(findings, baseline) {
8054
8497
  const known = new Set(baseline.findings.map((f) => f.id));
8055
8498
  return findings.filter((f) => !known.has(f.id));
8056
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((finding3) => {
8519
+ const file = (finding3.file ?? "").replace(/\\/g, "/");
8520
+ return !active.some((row) => row.ruleId === finding3.ruleId && (row.file === void 0 || row.file === file));
8521
+ });
8522
+ return { findings: kept, errors };
8523
+ }
8057
8524
 
8058
8525
  // src/core/config.ts
8059
8526
  init_finding();
@@ -8062,14 +8529,27 @@ var DEFAULT_CONFIG = {
8062
8529
  modules: [...MODULE_NAMES],
8063
8530
  failOn: "high",
8064
8531
  ignore: ["dist/**", "**/*.test.ts"],
8532
+ defaultExcludes: true,
8065
8533
  baseline: ".fullshield-baseline.json",
8066
8534
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
8067
8535
  deps: { offline: false },
8068
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8536
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8537
+ sast: { maxFileBytes: 1024 * 1024 }
8069
8538
  };
8070
8539
  var ConfigError = class extends Error {
8071
8540
  };
8072
- 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
+ ]);
8073
8553
  function validate(raw, source) {
8074
8554
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
8075
8555
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -8171,6 +8651,32 @@ function validate(raw, source) {
8171
8651
  }
8172
8652
  out.ai = ai;
8173
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
+ }
8174
8680
  return out;
8175
8681
  }
8176
8682
  async function loadConfig(cwd, configPath) {
@@ -8197,10 +8703,26 @@ async function loadConfig(cwd, configPath) {
8197
8703
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
8198
8704
  }
8199
8705
  var ignoreFactory = ignoreModule.default;
8200
- 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
+ ];
8201
8720
  async function walkFiles(options) {
8202
8721
  const ig = ignoreFactory();
8203
- ig.add(ALWAYS_IGNORED);
8722
+ ig.add(HARD_IGNORED);
8723
+ if (options.defaultExcludes !== false) {
8724
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8725
+ }
8204
8726
  try {
8205
8727
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
8206
8728
  } catch {
@@ -8382,7 +8904,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8382
8904
  return "*";
8383
8905
  }
8384
8906
  }
8385
- function expiresAtMs(expires) {
8907
+ function expiresAtMs2(expires) {
8386
8908
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8387
8909
  return Date.parse(`${expires}T23:59:59.999Z`);
8388
8910
  }
@@ -8404,7 +8926,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8404
8926
  });
8405
8927
  const suppressed = /* @__PURE__ */ new Set();
8406
8928
  for (const exception of policy.exceptions ?? []) {
8407
- if (expiresAtMs(exception.expires) < now.getTime()) {
8929
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8408
8930
  errors.push(
8409
8931
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8410
8932
  );
@@ -8473,7 +8995,12 @@ async function runScan(options = {}, modules = builtinModules) {
8473
8995
  options.modules ?? config.modules,
8474
8996
  loadedPolicy?.requiredModules
8475
8997
  );
8476
- 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
+ });
8477
9004
  if (options.staged === true) {
8478
9005
  const staged = new Set(await listStagedFiles(cwd));
8479
9006
  files = files.filter((file) => staged.has(file));
@@ -8507,6 +9034,7 @@ async function runScan(options = {}, modules = builtinModules) {
8507
9034
  );
8508
9035
  }
8509
9036
  const extraHits = [];
9037
+ let historyNotice;
8510
9038
  const secretsWanted = modulesRequested.includes("secrets");
8511
9039
  if (secretsAdvEnabled() && secretsWanted) {
8512
9040
  if (options.history === true) {
@@ -8514,6 +9042,7 @@ async function runScan(options = {}, modules = builtinModules) {
8514
9042
  const hist = await scanGitHistory2(cwd);
8515
9043
  extraHits.push(...hist.hits);
8516
9044
  collected.push(...hist.findings);
9045
+ historyNotice = hist.history;
8517
9046
  }
8518
9047
  if (options.bundle !== void 0 && options.bundle.length > 0) {
8519
9048
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -8534,7 +9063,11 @@ async function runScan(options = {}, modules = builtinModules) {
8534
9063
  if (options.updateBaseline === true) {
8535
9064
  await writeBaseline(baselinePath, unsuppressed);
8536
9065
  }
8537
- 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;
8538
9071
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
8539
9072
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
8540
9073
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -8606,10 +9139,12 @@ async function runScan(options = {}, modules = builtinModules) {
8606
9139
  cwd,
8607
9140
  filesScanned: files.length,
8608
9141
  modulesRequested: [...modulesRequested],
8609
- modulesRun
9142
+ modulesRun,
9143
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
8610
9144
  },
8611
9145
  config,
8612
- policy: policyResult
9146
+ policy: policyResult,
9147
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
8613
9148
  };
8614
9149
  }
8615
9150
  function scan(options = {}) {