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/index.js CHANGED
@@ -565,6 +565,7 @@ var init_incremental = __esm({
565
565
  // src/modules/secrets/history.ts
566
566
  var history_exports = {};
567
567
  __export(history_exports, {
568
+ historyCommitLimit: () => historyCommitLimit,
568
569
  scanGitHistory: () => scanGitHistory
569
570
  });
570
571
  function gitError(error) {
@@ -607,7 +608,13 @@ function remapHit(hit, file) {
607
608
  secret: hit.secret
608
609
  };
609
610
  }
610
- async function streamLogChanges(cwd) {
611
+ function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
612
+ if (raw === void 0 || raw.trim() === "") return 500;
613
+ const parsed = Number(raw);
614
+ if (!Number.isInteger(parsed) || parsed < 1) return 500;
615
+ return Math.min(parsed, 5e3);
616
+ }
617
+ async function streamLogChanges(cwd, maxCommits) {
611
618
  const changes = [];
612
619
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
613
620
  const child = spawn(
@@ -624,6 +631,8 @@ async function streamLogChanges(cwd) {
624
631
  "--format=FSCOMMIT:%H",
625
632
  "--no-textconv",
626
633
  "--no-ext-diff",
634
+ "-n",
635
+ String(maxCommits),
627
636
  "HEAD"
628
637
  ],
629
638
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -692,8 +701,14 @@ async function headSecretKeys(cwd) {
692
701
  }
693
702
  return keys;
694
703
  }
695
- async function scanGitHistory(cwd) {
696
- const changes = await streamLogChanges(cwd);
704
+ async function commitCount(cwd) {
705
+ const raw = await gitOk(cwd, ["rev-list", "--count", "HEAD"]);
706
+ const total = Number(raw.trim());
707
+ return Number.isInteger(total) && total >= 0 ? total : 0;
708
+ }
709
+ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
710
+ const total = await commitCount(cwd);
711
+ const changes = await streamLogChanges(cwd, maxCommits);
697
712
  const blobHits = /* @__PURE__ */ new Map();
698
713
  const first = /* @__PURE__ */ new Map();
699
714
  for (const change of changes) {
@@ -730,7 +745,13 @@ async function scanGitHistory(cwd) {
730
745
  secret: hit.secret
731
746
  });
732
747
  }
733
- return { findings: hits.map((h) => h.finding), hits };
748
+ const scanned = Math.min(maxCommits, total);
749
+ const history = total > maxCommits ? `scanned ${scanned} of ${total} commits` : void 0;
750
+ return {
751
+ findings: hits.map((h) => h.finding),
752
+ hits,
753
+ ...history !== void 0 ? { history } : {}
754
+ };
734
755
  }
735
756
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
736
757
  var init_history = __esm({
@@ -1078,7 +1099,8 @@ function walk(node, visit) {
1078
1099
  }
1079
1100
  }
1080
1101
  function attachParents(node, parent) {
1081
- node.parent = parent;
1102
+ const current = node;
1103
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
1082
1104
  for (const key of Object.keys(node)) {
1083
1105
  if (SKIP_KEYS.has(String(key))) continue;
1084
1106
  const value = node[key];
@@ -1151,6 +1173,11 @@ function mcpCfgEnabled() {
1151
1173
  function taintV2Enabled() {
1152
1174
  return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
1153
1175
  }
1176
+ var DIFF_FLAG = "FULLSHIELD_EXPERIMENTAL_DIFF";
1177
+ var LICENSE_FLAG = "FULLSHIELD_EXPERIMENTAL_LICENSE";
1178
+ function licenseEnabled() {
1179
+ return process.env[LICENSE_FLAG] === "1";
1180
+ }
1154
1181
 
1155
1182
  // src/modules/config/finding.ts
1156
1183
  init_finding();
@@ -2143,6 +2170,29 @@ function shortestDependencyPath(target, starts, edges) {
2143
2170
  }
2144
2171
  return void 0;
2145
2172
  }
2173
+ function collectDependencyPaths(target, starts, edges, limit = 8) {
2174
+ const out = [];
2175
+ const queue = [];
2176
+ let steps = 0;
2177
+ for (const start of starts) queue.push([start]);
2178
+ while (queue.length > 0 && out.length < limit && steps < 4e3) {
2179
+ steps += 1;
2180
+ const path = queue.shift();
2181
+ if (path === void 0) continue;
2182
+ const last = path[path.length - 1];
2183
+ if (last === void 0) continue;
2184
+ if (last === target) {
2185
+ if (path.length >= 2) out.push(path);
2186
+ continue;
2187
+ }
2188
+ if (path.length >= 12) continue;
2189
+ for (const next of edges.get(last) ?? []) {
2190
+ if (path.includes(next)) continue;
2191
+ queue.push([...path, next]);
2192
+ }
2193
+ }
2194
+ return out;
2195
+ }
2146
2196
  function reachableFrom(target, roots, edges) {
2147
2197
  if (roots.has(target)) return true;
2148
2198
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -2163,9 +2213,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2163
2213
  }
2164
2214
  if (graph !== void 0 && graph.edgesKnown) {
2165
2215
  const roots = importedDirectNames(productionUsage, graph);
2166
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
2216
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
2217
+ const path = paths[0];
2167
2218
  if (path !== void 0 && path.length >= 2) {
2168
- return { reachability: "reachable-via-dependency", path };
2219
+ return { reachability: "reachable-via-dependency", path, paths };
2169
2220
  }
2170
2221
  return { reachability: "not-imported" };
2171
2222
  }
@@ -2195,8 +2246,108 @@ function isDevOnly(pkgName, ctx) {
2195
2246
  if (inProd) return false;
2196
2247
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
2197
2248
  }
2249
+ var REACH_RANK = {
2250
+ reachable: 0,
2251
+ "symbol-reachable": 1,
2252
+ "reachable-via-dependency": 2,
2253
+ "imported-not-called": 3,
2254
+ "symbol-not-called": 4,
2255
+ "reachability-unknown": 5,
2256
+ "not-imported": 6
2257
+ };
2258
+ function versionFromSnippet(snippet) {
2259
+ const name = packageNameFromSnippet(snippet);
2260
+ if (name === void 0) return snippet;
2261
+ return snippet.slice(name.length + 1);
2262
+ }
2263
+ function pathFields(classified) {
2264
+ const paths = classified.paths ?? (classified.path !== void 0 ? [classified.path] : []);
2265
+ if (paths.length === 0) return {};
2266
+ return {
2267
+ dependencyPath: paths[0],
2268
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
2269
+ };
2270
+ }
2271
+ function appendOtherPaths(message, paths) {
2272
+ if (paths === void 0 || paths.length < 2) return message;
2273
+ const rest = paths.slice(1).map((p) => p.join(" > ")).join("; ");
2274
+ return `${message} Other paths: ${rest}.`;
2275
+ }
2276
+ function groupPackageAdvisories(findings) {
2277
+ const passthrough = [];
2278
+ const groups = /* @__PURE__ */ new Map();
2279
+ for (const finding3 of findings) {
2280
+ if (finding3.ruleId !== "DEPS-VULN-001" && finding3.ruleId !== "DEPS-MALICIOUS-001") {
2281
+ passthrough.push(finding3);
2282
+ continue;
2283
+ }
2284
+ const name = packageNameFromSnippet(finding3.snippet ?? "") ?? finding3.snippet ?? finding3.id;
2285
+ const advisory = finding3.references?.[0] ?? finding3.id;
2286
+ const key = `${finding3.ruleId}\0${name}\0${advisory}`;
2287
+ const list = groups.get(key);
2288
+ if (list === void 0) groups.set(key, [finding3]);
2289
+ else list.push(finding3);
2290
+ }
2291
+ const merged = [];
2292
+ for (const list of groups.values()) {
2293
+ const only = list[0];
2294
+ if (list.length === 1 && only !== void 0) {
2295
+ merged.push(only);
2296
+ continue;
2297
+ }
2298
+ merged.push(mergeAdvisory(list));
2299
+ }
2300
+ return [...passthrough, ...merged];
2301
+ }
2302
+ function mergeAdvisory(list) {
2303
+ const ranked = [...list].sort(
2304
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || (REACH_RANK[a.reachability ?? "not-imported"] ?? 9) - (REACH_RANK[b.reachability ?? "not-imported"] ?? 9)
2305
+ );
2306
+ const primary = ranked[0];
2307
+ if (primary === void 0) {
2308
+ throw new Error("empty advisory group");
2309
+ }
2310
+ const name = packageNameFromSnippet(primary.snippet ?? "") ?? "package";
2311
+ const versions = [...new Set(list.map((f) => versionFromSnippet(f.snippet ?? "")))].filter((v) => v.length > 0).sort();
2312
+ const paths = [];
2313
+ const seenPath = /* @__PURE__ */ new Set();
2314
+ for (const finding3 of list) {
2315
+ const candidates = finding3.dependencyPaths ?? (finding3.dependencyPath !== void 0 ? [finding3.dependencyPath] : []);
2316
+ for (const path of candidates) {
2317
+ const key = path.join("\0");
2318
+ if (seenPath.has(key)) continue;
2319
+ seenPath.add(key);
2320
+ paths.push(path);
2321
+ }
2322
+ }
2323
+ paths.sort((a, b) => a.length - b.length || a.join(">").localeCompare(b.join(">")));
2324
+ const advisory = primary.references?.[0] ?? "";
2325
+ const onManifest = list.find((f) => f.file === "package.json");
2326
+ const file = onManifest?.file ?? primary.file;
2327
+ const line = onManifest?.line ?? primary.line;
2328
+ const snippet = versions.length > 0 ? `${name}@${versions.join(",")}` : primary.snippet ?? name;
2329
+ let message = primary.message;
2330
+ if (versions.length > 1) {
2331
+ message = `${message} Versions: ${versions.join(", ")}.`;
2332
+ }
2333
+ if (paths.length > 1) {
2334
+ message = `${message} Dependency paths: ${paths.map((p) => p.join(" > ")).join("; ")}.`;
2335
+ }
2336
+ return {
2337
+ ...primary,
2338
+ id: makeFindingId(primary.ruleId, file, `${name}|${advisory}|${versions.join(",")}`),
2339
+ file,
2340
+ line,
2341
+ snippet,
2342
+ message,
2343
+ severity: primary.severity,
2344
+ reachability: primary.reachability,
2345
+ ...paths[0] !== void 0 ? { dependencyPath: paths[0] } : {},
2346
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
2347
+ };
2348
+ }
2198
2349
  function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2199
- return findings.map((finding3) => {
2350
+ const mapped = findings.map((finding3) => {
2200
2351
  if (finding3.ruleId === "DEPS-MALICIOUS-001") {
2201
2352
  if (finding3.snippet === void 0) return finding3;
2202
2353
  const pkg2 = packageNameFromSnippet(finding3.snippet);
@@ -2206,7 +2357,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2206
2357
  ...finding3,
2207
2358
  severity: "critical",
2208
2359
  reachability: classified2.reachability,
2209
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
2360
+ ...pathFields(classified2)
2210
2361
  };
2211
2362
  }
2212
2363
  if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
@@ -2216,7 +2367,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2216
2367
  const named = symbols.get(finding3.id) ?? [];
2217
2368
  if (named.length > 0 && ctx.sources !== void 0) {
2218
2369
  const symbolClass = classifySymbolUse(named, ctx.sources);
2219
- classified = { reachability: symbolClass, path: classified.path };
2370
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
2220
2371
  }
2221
2372
  const steps = reachabilitySteps(classified.reachability);
2222
2373
  let severity = steps === 0 ? finding3.severity : downgradeSeverity(finding3.severity, steps);
@@ -2224,6 +2375,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2224
2375
  if (classified.path !== void 0) {
2225
2376
  const shown = classified.path.join(" > ");
2226
2377
  message = `${message} Reachable via ${shown}.`;
2378
+ message = appendOtherPaths(message, classified.paths);
2227
2379
  }
2228
2380
  const devOnly = isDevOnly(pkg, ctx);
2229
2381
  if (devOnly) {
@@ -2233,11 +2385,12 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2233
2385
  return {
2234
2386
  ...finding3,
2235
2387
  reachability: classified.reachability,
2236
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
2388
+ ...pathFields(classified),
2237
2389
  severity,
2238
2390
  message
2239
2391
  };
2240
2392
  });
2393
+ return groupPackageAdvisories(mapped);
2241
2394
  }
2242
2395
 
2243
2396
  // src/modules/deps/semver.ts
@@ -4139,6 +4292,59 @@ function experimentalVex() {
4139
4292
  return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
4140
4293
  }
4141
4294
 
4295
+ // src/modules/deps/license.ts
4296
+ init_finding();
4297
+ var COPYLEFT = /(?:^|[^\w])(?:AGPL|GPL)(?:-|[^\w]|$)/i;
4298
+ function isCopyleftLicense(license) {
4299
+ return COPYLEFT.test(license);
4300
+ }
4301
+ function licenseText(value) {
4302
+ if (typeof value === "string") return value;
4303
+ if (typeof value === "object" && value !== null && "type" in value) {
4304
+ const type = value.type;
4305
+ if (typeof type === "string") return type;
4306
+ }
4307
+ return void 0;
4308
+ }
4309
+ async function readJson(path) {
4310
+ try {
4311
+ return JSON.parse(await readFile(path, "utf8"));
4312
+ } catch {
4313
+ return void 0;
4314
+ }
4315
+ }
4316
+ async function scanCopyleftLicenses(cwd) {
4317
+ const manifest = await readJson(join(cwd, "package.json"));
4318
+ if (typeof manifest !== "object" || manifest === null) return [];
4319
+ const names = /* @__PURE__ */ new Set();
4320
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
4321
+ const block = manifest[field];
4322
+ if (typeof block === "object" && block !== null && !Array.isArray(block)) {
4323
+ for (const name of Object.keys(block)) names.add(name);
4324
+ }
4325
+ }
4326
+ const findings = [];
4327
+ for (const name of names) {
4328
+ const pkg = await readJson(join(cwd, "node_modules", name, "package.json"));
4329
+ if (typeof pkg !== "object" || pkg === null) continue;
4330
+ const license = licenseText(pkg.license);
4331
+ if (license === void 0 || !isCopyleftLicense(license)) continue;
4332
+ findings.push({
4333
+ id: makeFindingId("DEPS-LICENSE-001", "package.json", `${name}|${license}`),
4334
+ ruleId: "DEPS-LICENSE-001",
4335
+ module: "deps",
4336
+ title: `Copyleft dependency ${name}`,
4337
+ severity: "medium",
4338
+ confidence: "high",
4339
+ file: "package.json",
4340
+ snippet: `${name} ${license}`,
4341
+ message: `${name} is licensed ${license}.`,
4342
+ remediation: "Replace the dependency or get a legal review before shipping it in a commercial product."
4343
+ });
4344
+ }
4345
+ return findings;
4346
+ }
4347
+
4142
4348
  // src/fixer/eol.ts
4143
4349
  function detectEol(text) {
4144
4350
  return text.includes("\r\n") ? "\r\n" : "\n";
@@ -4253,7 +4459,7 @@ function walkJs(dir, out = []) {
4253
4459
  }
4254
4460
  return out;
4255
4461
  }
4256
- function readJson(path) {
4462
+ function readJson2(path) {
4257
4463
  return JSON.parse(readFileSync(path, "utf8"));
4258
4464
  }
4259
4465
  var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
@@ -4267,7 +4473,7 @@ function packageSources(dir) {
4267
4473
  let scripts = "";
4268
4474
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
4269
4475
  try {
4270
- const pkg = readJson(manifestPath);
4476
+ const pkg = readJson2(manifestPath);
4271
4477
  const block = pkg.scripts ?? {};
4272
4478
  scripts = Object.values(block).join("\n");
4273
4479
  } catch {
@@ -4602,6 +4808,7 @@ var depsModule = {
4602
4808
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4603
4809
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4604
4810
  }
4811
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4605
4812
  return reached;
4606
4813
  }
4607
4814
  };
@@ -5540,7 +5747,7 @@ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5540
5747
  }
5541
5748
  return changed;
5542
5749
  }
5543
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5750
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5544
5751
  attachParents(ast);
5545
5752
  const facts = /* @__PURE__ */ new WeakMap();
5546
5753
  const functions = [];
@@ -5554,7 +5761,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5554
5761
  }
5555
5762
  const seeds = /* @__PURE__ */ new Map();
5556
5763
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5557
- const returners = new Set(extraReturners);
5764
+ const returners = new Set(extraReturners2);
5558
5765
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5559
5766
  let changed = false;
5560
5767
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -7426,6 +7633,39 @@ function scanPromptInjection(file, source) {
7426
7633
  });
7427
7634
  return findings;
7428
7635
  }
7636
+
7637
+ // src/runtime/lru.ts
7638
+ var LruMap = class {
7639
+ constructor(max) {
7640
+ this.max = max;
7641
+ if (max < 1) throw new Error("LruMap max must be >= 1");
7642
+ }
7643
+ max;
7644
+ map = /* @__PURE__ */ new Map();
7645
+ get size() {
7646
+ return this.map.size;
7647
+ }
7648
+ has(key) {
7649
+ return this.map.has(key);
7650
+ }
7651
+ get(key) {
7652
+ if (!this.map.has(key)) return void 0;
7653
+ const value = this.map.get(key);
7654
+ this.map.delete(key);
7655
+ this.map.set(key, value);
7656
+ return value;
7657
+ }
7658
+ set(key, value) {
7659
+ if (this.map.has(key)) this.map.delete(key);
7660
+ this.map.set(key, value);
7661
+ while (this.map.size > this.max) {
7662
+ const oldest = this.map.keys().next().value;
7663
+ if (oldest === void 0) break;
7664
+ this.map.delete(oldest);
7665
+ }
7666
+ return this;
7667
+ }
7668
+ };
7429
7669
  function packageVersion() {
7430
7670
  let dir = dirname(fileURLToPath(import.meta.url));
7431
7671
  for (let depth = 0; depth < 6; depth += 1) {
@@ -7446,7 +7686,7 @@ function packageVersion() {
7446
7686
 
7447
7687
  // src/core/parse-pool.ts
7448
7688
  var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7449
- var cache = /* @__PURE__ */ new Map();
7689
+ var cache = new LruMap(64);
7450
7690
  function parsePoolEnabled() {
7451
7691
  return process.env.FULLSHIELD_PARSE_POOL === "1";
7452
7692
  }
@@ -7454,9 +7694,15 @@ function parsePoolSize() {
7454
7694
  const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7455
7695
  if (raw !== void 0 && raw !== "") {
7456
7696
  const parsed = Number(raw);
7457
- if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7697
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7458
7698
  }
7459
- return Math.max(1, availableParallelism() - 1);
7699
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
7700
+ }
7701
+ function workerMemoryMb() {
7702
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7703
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7704
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7705
+ return 512;
7460
7706
  }
7461
7707
  function parseCacheKey(code) {
7462
7708
  return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
@@ -7466,12 +7712,37 @@ function parseOne(code) {
7466
7712
  const hit = cache.get(key);
7467
7713
  if (hit !== void 0) return Promise.resolve(hit);
7468
7714
  return new Promise((resolve5, reject) => {
7469
- const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
7470
- worker.once("message", (ast) => {
7715
+ const worker = new Worker(fileURLToPath(workerUrl), {
7716
+ workerData: { code },
7717
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7718
+ });
7719
+ let settled = false;
7720
+ const finish = () => {
7721
+ void worker.terminate();
7722
+ };
7723
+ const succeed = (ast) => {
7724
+ if (settled) return;
7725
+ settled = true;
7471
7726
  cache.set(key, ast);
7727
+ finish();
7472
7728
  resolve5(ast);
7729
+ };
7730
+ const fail = (error) => {
7731
+ if (settled) return;
7732
+ settled = true;
7733
+ finish();
7734
+ reject(error);
7735
+ };
7736
+ worker.once("message", (ast) => {
7737
+ succeed(ast);
7738
+ });
7739
+ worker.once("error", (error) => {
7740
+ fail(error);
7741
+ });
7742
+ worker.once("exit", (code2) => {
7743
+ if (settled) return;
7744
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
7473
7745
  });
7474
- worker.once("error", reject);
7475
7746
  });
7476
7747
  }
7477
7748
  async function parseWithPool(codes) {
@@ -7525,8 +7796,8 @@ function snippetOf(source, node) {
7525
7796
  if (node.range === void 0) return "";
7526
7797
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7527
7798
  }
7528
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7529
- const facts = analyzeFile(ast, extraReturners);
7799
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7800
+ const facts = analyzeFile(ast, extraReturners2);
7530
7801
  const findings = [];
7531
7802
  for (const rule of sastRules) {
7532
7803
  const ctx = {
@@ -7604,52 +7875,203 @@ function findingsFromTemplate(filename, source) {
7604
7875
  xssRule.create(ctx);
7605
7876
  return findings;
7606
7877
  }
7878
+ function dedupeSastSinks(findings) {
7879
+ const seen = /* @__PURE__ */ new Set();
7880
+ const out = [];
7881
+ for (const finding3 of findings) {
7882
+ if (finding3.module !== "sast" || finding3.line === void 0) {
7883
+ out.push(finding3);
7884
+ continue;
7885
+ }
7886
+ const key = `${finding3.ruleId}\0${finding3.file ?? ""}\0${finding3.line}`;
7887
+ if (seen.has(key)) continue;
7888
+ seen.add(key);
7889
+ out.push(finding3);
7890
+ }
7891
+ return out;
7892
+ }
7893
+ function sastStreamThreshold() {
7894
+ const raw = process.env.FULLSHIELD_SAST_STREAM_AT;
7895
+ if (raw !== void 0 && raw !== "") {
7896
+ const parsed = Number(raw);
7897
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7898
+ }
7899
+ return 4e3;
7900
+ }
7901
+ function sastBatchSize() {
7902
+ const raw = process.env.FULLSHIELD_SAST_BATCH;
7903
+ if (raw !== void 0 && raw !== "") {
7904
+ const parsed = Number(raw);
7905
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7906
+ }
7907
+ return 150;
7908
+ }
7909
+ function skipFinding(rel, message) {
7910
+ return {
7911
+ id: makeFindingId("SAST-SKIP-001", rel, message),
7912
+ ruleId: "SAST-SKIP-001",
7913
+ module: "sast",
7914
+ title: "File skipped by SAST",
7915
+ severity: "info",
7916
+ confidence: "high",
7917
+ file: rel,
7918
+ message,
7919
+ remediation: "Raise sast.maxFileBytes in fullshield.config.json to parse this file, or leave it skipped."
7920
+ };
7921
+ }
7922
+ function skipReason(source, maxBytes) {
7923
+ if (source.includes("\0")) return "it contains NUL bytes";
7924
+ const bytes = Buffer.byteLength(source);
7925
+ if (bytes > maxBytes) return `${bytes} bytes exceeds the SAST parse limit of ${maxBytes} bytes`;
7926
+ return void 0;
7927
+ }
7928
+ async function readScanSource(cwd, rel) {
7929
+ try {
7930
+ return await readFile(join(cwd, rel), "utf8");
7931
+ } catch {
7932
+ return void 0;
7933
+ }
7934
+ }
7935
+ function cloneAst(value) {
7936
+ if (value === null || typeof value !== "object") return void 0;
7937
+ try {
7938
+ return structuredClone(value);
7939
+ } catch {
7940
+ return void 0;
7941
+ }
7942
+ }
7943
+ async function parseInProcess(source) {
7944
+ try {
7945
+ return await parseSource(source);
7946
+ } catch {
7947
+ return void 0;
7948
+ }
7949
+ }
7950
+ async function parseCode(source) {
7951
+ if (!parsePoolEnabled()) return parseInProcess(source);
7952
+ try {
7953
+ const cloned = cloneAst((await parseWithPool([source]))[0]);
7954
+ if (cloned !== void 0) return cloned;
7955
+ } catch {
7956
+ }
7957
+ return parseInProcess(source);
7958
+ }
7959
+ function releaseParsed(parsed) {
7960
+ for (const entry of parsed) {
7961
+ entry.source = "";
7962
+ entry.ast = void 0;
7963
+ }
7964
+ parsed.length = 0;
7965
+ }
7966
+ function extraReturners(rel, ast, files, returning) {
7967
+ const ns = collectRequires(rel, ast, files);
7968
+ const extra = new Set(returning.get(rel));
7969
+ for (const [local, spec] of ns) {
7970
+ const hash = spec.indexOf("#");
7971
+ const file = hash >= 0 ? spec.slice(0, hash) : spec;
7972
+ const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7973
+ if (returning.get(file)?.has(exp)) extra.add(local);
7974
+ }
7975
+ return extra;
7976
+ }
7977
+ async function runSastStreaming(ctx) {
7978
+ const maxBytes = ctx.config.sast.maxFileBytes;
7979
+ const findings = [];
7980
+ const returning = /* @__PURE__ */ new Map();
7981
+ const parseable = [];
7982
+ const fileSet = /* @__PURE__ */ new Set();
7983
+ let projectCsrf = false;
7984
+ for (const rel of ctx.files) {
7985
+ const isSource = SOURCE_EXT.test(rel);
7986
+ const isTemplate = TEMPLATE_EXT.test(rel);
7987
+ if (!isSource && !isTemplate) continue;
7988
+ const source = await readScanSource(ctx.cwd, rel);
7989
+ if (source === void 0) continue;
7990
+ const why = skipReason(source, maxBytes);
7991
+ if (why !== void 0) {
7992
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7993
+ continue;
7994
+ }
7995
+ if (sourceHasCsrfGuard(source)) projectCsrf = true;
7996
+ if (isTemplate) {
7997
+ findings.push(...findingsFromTemplate(rel, source));
7998
+ continue;
7999
+ }
8000
+ const ast = await parseCode(source);
8001
+ if (ast === void 0) continue;
8002
+ returning.set(rel, functionsThatReturnTaint(ast));
8003
+ parseable.push(rel);
8004
+ fileSet.add(rel);
8005
+ }
8006
+ for (const rel of parseable) {
8007
+ const source = await readScanSource(ctx.cwd, rel);
8008
+ if (source === void 0) continue;
8009
+ const ast = await parseCode(source);
8010
+ if (ast === void 0) continue;
8011
+ findings.push(
8012
+ ...findingsFromAst(rel, source, ast, extraReturners(rel, ast, fileSet, returning), projectCsrf)
8013
+ );
8014
+ }
8015
+ const batch = sastBatchSize();
8016
+ for (let i = 0; i < parseable.length; i += batch) {
8017
+ const parsed = [];
8018
+ for (const rel of parseable.slice(i, i + batch)) {
8019
+ const source = await readScanSource(ctx.cwd, rel);
8020
+ if (source === void 0) continue;
8021
+ const ast = await parseCode(source);
8022
+ if (ast === void 0) continue;
8023
+ parsed.push({ file: rel, source, ast });
8024
+ }
8025
+ findings.push(...runInterproc(parsed));
8026
+ releaseParsed(parsed);
8027
+ }
8028
+ return dedupeSastSinks(findings);
8029
+ }
7607
8030
  var sastModule = {
7608
8031
  name: "sast",
7609
8032
  async run(ctx) {
8033
+ const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
8034
+ if (candidates.length > sastStreamThreshold()) return runSastStreaming(ctx);
8035
+ const maxBytes = ctx.config.sast.maxFileBytes;
7610
8036
  const findings = [];
7611
8037
  const parsed = [];
7612
- for (const rel of ctx.files) {
7613
- const isSource = SOURCE_EXT.test(rel);
7614
- const isTemplate = TEMPLATE_EXT.test(rel);
7615
- if (!isSource && !isTemplate) continue;
7616
- let source;
7617
- try {
7618
- source = await readFile(join(ctx.cwd, rel), "utf8");
7619
- } catch {
8038
+ for (const rel of candidates) {
8039
+ const source = await readScanSource(ctx.cwd, rel);
8040
+ if (source === void 0) continue;
8041
+ const why = skipReason(source, maxBytes);
8042
+ if (why !== void 0) {
8043
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7620
8044
  continue;
7621
8045
  }
7622
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7623
- if (isTemplate) {
8046
+ if (TEMPLATE_EXT.test(rel)) {
7624
8047
  findings.push(...findingsFromTemplate(rel, source));
7625
8048
  continue;
7626
8049
  }
7627
- try {
7628
- const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
7629
- if (ast === void 0) continue;
7630
- parsed.push({ file: rel, source, ast });
7631
- } catch {
7632
- }
8050
+ const ast = await parseCode(source);
8051
+ if (ast === void 0) continue;
8052
+ parsed.push({ file: rel, source, ast });
7633
8053
  }
7634
8054
  const files = new Set(parsed.map((p) => p.file));
7635
8055
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7636
8056
  const returning = /* @__PURE__ */ new Map();
7637
- for (const p of parsed) {
7638
- returning.set(p.file, functionsThatReturnTaint(p.ast));
8057
+ for (const entry of parsed) {
8058
+ returning.set(entry.file, functionsThatReturnTaint(entry.ast));
7639
8059
  }
7640
- for (const p of parsed) {
7641
- const ns = collectRequires(p.file, p.ast, files);
7642
- const extra = new Set(returning.get(p.file));
7643
- for (const [local, spec] of ns) {
7644
- const hash = spec.indexOf("#");
7645
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7646
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7647
- if (returning.get(file)?.has(exp)) extra.add(local);
7648
- }
7649
- findings.push(...findingsFromAst(p.file, p.source, p.ast, extra, projectCsrf));
8060
+ for (const entry of parsed) {
8061
+ findings.push(
8062
+ ...findingsFromAst(
8063
+ entry.file,
8064
+ entry.source,
8065
+ entry.ast,
8066
+ extraReturners(entry.file, entry.ast, files, returning),
8067
+ projectCsrf
8068
+ )
8069
+ );
7650
8070
  }
7651
8071
  findings.push(...runInterproc(parsed));
7652
- return findings;
8072
+ const out = dedupeSastSinks(findings);
8073
+ releaseParsed(parsed);
8074
+ return out;
7653
8075
  }
7654
8076
  };
7655
8077
 
@@ -8110,16 +8532,37 @@ ${wrapUntrustedCode(req.prompt)}`;
8110
8532
  }
8111
8533
  };
8112
8534
  }
8535
+ function isIsoDate(value) {
8536
+ if (!/^\d{4}-\d{2}-\d{2}(T[\d:.+\-Z]+)?$/.test(value)) return false;
8537
+ return Number.isFinite(Date.parse(value));
8538
+ }
8539
+ function expiresAtMs(expires) {
8540
+ if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) return Date.parse(`${expires}T23:59:59.999Z`);
8541
+ return Date.parse(expires);
8542
+ }
8543
+ function validSuppression(value) {
8544
+ if (typeof value !== "object" || value === null) return void 0;
8545
+ const row = value;
8546
+ if (typeof row.ruleId !== "string" || row.ruleId.length === 0) return void 0;
8547
+ if (typeof row.owner !== "string" || row.owner.length === 0) return void 0;
8548
+ if (typeof row.reason !== "string" || row.reason.length === 0) return void 0;
8549
+ if (typeof row.expires !== "string" || !isIsoDate(row.expires)) return void 0;
8550
+ const file = row.file === void 0 ? void 0 : row.file.replace(/\\/g, "/");
8551
+ if (row.file !== void 0 && (typeof row.file !== "string" || row.file.length === 0)) return void 0;
8552
+ return { ruleId: row.ruleId, file, owner: row.owner, reason: row.reason, expires: row.expires };
8553
+ }
8113
8554
  async function readBaseline(path) {
8114
8555
  try {
8115
8556
  const raw = JSON.parse(await readFile(path, "utf8"));
8116
8557
  if (raw.version !== 1 || !Array.isArray(raw.findings)) return void 0;
8558
+ if (raw.suppressions !== void 0 && !Array.isArray(raw.suppressions)) return void 0;
8117
8559
  return raw;
8118
8560
  } catch {
8119
8561
  return void 0;
8120
8562
  }
8121
8563
  }
8122
8564
  async function writeBaseline(path, findings) {
8565
+ const existing = await readBaseline(path);
8123
8566
  const doc = {
8124
8567
  version: 1,
8125
8568
  findings: findings.map((f) => ({
@@ -8127,7 +8570,8 @@ async function writeBaseline(path, findings) {
8127
8570
  ruleId: f.ruleId,
8128
8571
  file: f.file,
8129
8572
  line: f.line
8130
- }))
8573
+ })),
8574
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
8131
8575
  };
8132
8576
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
8133
8577
  `, "utf8");
@@ -8137,6 +8581,30 @@ function applyBaseline(findings, baseline) {
8137
8581
  const known = new Set(baseline.findings.map((f) => f.id));
8138
8582
  return findings.filter((f) => !known.has(f.id));
8139
8583
  }
8584
+ function applySuppressions(findings, rows, now = /* @__PURE__ */ new Date()) {
8585
+ if (rows === void 0 || rows.length === 0) return { findings: [...findings], errors: [] };
8586
+ const errors = [];
8587
+ const active = [];
8588
+ rows.forEach((row, index) => {
8589
+ const parsed = validSuppression(row);
8590
+ if (parsed === void 0) {
8591
+ errors.push(`suppression ${index + 1} needs ruleId, owner, reason, and an ISO expires date`);
8592
+ return;
8593
+ }
8594
+ if (expiresAtMs(parsed.expires) < now.getTime()) {
8595
+ errors.push(
8596
+ `suppression for ${parsed.ruleId} expired on ${parsed.expires} (owner: ${parsed.owner}); renew or remove it`
8597
+ );
8598
+ return;
8599
+ }
8600
+ active.push(parsed);
8601
+ });
8602
+ const kept = findings.filter((finding3) => {
8603
+ const file = (finding3.file ?? "").replace(/\\/g, "/");
8604
+ return !active.some((row) => row.ruleId === finding3.ruleId && (row.file === void 0 || row.file === file));
8605
+ });
8606
+ return { findings: kept, errors };
8607
+ }
8140
8608
 
8141
8609
  // src/core/config.ts
8142
8610
  init_finding();
@@ -8145,14 +8613,27 @@ var DEFAULT_CONFIG = {
8145
8613
  modules: [...MODULE_NAMES],
8146
8614
  failOn: "high",
8147
8615
  ignore: ["dist/**", "**/*.test.ts"],
8616
+ defaultExcludes: true,
8148
8617
  baseline: ".fullshield-baseline.json",
8149
8618
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
8150
8619
  deps: { offline: false },
8151
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8620
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8621
+ sast: { maxFileBytes: 1024 * 1024 }
8152
8622
  };
8153
8623
  var ConfigError = class extends Error {
8154
8624
  };
8155
- var KNOWN_KEYS = /* @__PURE__ */ new Set(["modules", "failOn", "ignore", "baseline", "notify", "deps", "ai"]);
8625
+ var KNOWN_KEYS = /* @__PURE__ */ new Set([
8626
+ "modules",
8627
+ "failOn",
8628
+ "ignore",
8629
+ "defaultExcludes",
8630
+ "excludes",
8631
+ "baseline",
8632
+ "notify",
8633
+ "deps",
8634
+ "ai",
8635
+ "sast"
8636
+ ]);
8156
8637
  function validate(raw, source) {
8157
8638
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
8158
8639
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -8254,6 +8735,32 @@ function validate(raw, source) {
8254
8735
  }
8255
8736
  out.ai = ai;
8256
8737
  }
8738
+ if (record.defaultExcludes !== void 0) {
8739
+ if (typeof record.defaultExcludes !== "boolean") {
8740
+ throw new ConfigError(`${source}: "defaultExcludes" must be a boolean`);
8741
+ }
8742
+ out.defaultExcludes = record.defaultExcludes;
8743
+ }
8744
+ if (record.excludes !== void 0) {
8745
+ if (!Array.isArray(record.excludes) || record.excludes.some((p) => typeof p !== "string")) {
8746
+ throw new ConfigError(`${source}: "excludes" must be an array of glob strings`);
8747
+ }
8748
+ out.excludes = record.excludes;
8749
+ }
8750
+ if (record.sast !== void 0) {
8751
+ if (typeof record.sast !== "object" || record.sast === null || Array.isArray(record.sast)) {
8752
+ throw new ConfigError(`${source}: "sast" must be an object`);
8753
+ }
8754
+ const rawSast = record.sast;
8755
+ const sast = { maxFileBytes: 1024 * 1024 };
8756
+ if (rawSast.maxFileBytes !== void 0) {
8757
+ if (typeof rawSast.maxFileBytes !== "number" || rawSast.maxFileBytes < 1) {
8758
+ throw new ConfigError(`${source}: "sast.maxFileBytes" must be a positive number`);
8759
+ }
8760
+ sast.maxFileBytes = rawSast.maxFileBytes;
8761
+ }
8762
+ out.sast = sast;
8763
+ }
8257
8764
  return out;
8258
8765
  }
8259
8766
  async function loadConfig(cwd, configPath) {
@@ -8280,10 +8787,26 @@ async function loadConfig(cwd, configPath) {
8280
8787
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
8281
8788
  }
8282
8789
  var ignoreFactory = ignoreModule.default;
8283
- var ALWAYS_IGNORED = [".git/", "node_modules/", ".fullshield/"];
8790
+ var HARD_IGNORED = [".git/", ".fullshield/"];
8791
+ var DEFAULT_EXCLUDES = [
8792
+ "node_modules/",
8793
+ "dist/",
8794
+ "build/",
8795
+ ".next/",
8796
+ "coverage/",
8797
+ "vendor/",
8798
+ "vendored/",
8799
+ "third_party/",
8800
+ "third-party/",
8801
+ "compiled/",
8802
+ "*.min.js"
8803
+ ];
8284
8804
  async function walkFiles(options) {
8285
8805
  const ig = ignoreFactory();
8286
- ig.add(ALWAYS_IGNORED);
8806
+ ig.add(HARD_IGNORED);
8807
+ if (options.defaultExcludes !== false) {
8808
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8809
+ }
8287
8810
  try {
8288
8811
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
8289
8812
  } catch {
@@ -8501,7 +9024,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8501
9024
  return "*";
8502
9025
  }
8503
9026
  }
8504
- function expiresAtMs(expires) {
9027
+ function expiresAtMs2(expires) {
8505
9028
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8506
9029
  return Date.parse(`${expires}T23:59:59.999Z`);
8507
9030
  }
@@ -8523,7 +9046,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8523
9046
  });
8524
9047
  const suppressed = /* @__PURE__ */ new Set();
8525
9048
  for (const exception of policy.exceptions ?? []) {
8526
- if (expiresAtMs(exception.expires) < now.getTime()) {
9049
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8527
9050
  errors.push(
8528
9051
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8529
9052
  );
@@ -8592,7 +9115,12 @@ async function runScan(options = {}, modules = builtinModules) {
8592
9115
  options.modules ?? config.modules,
8593
9116
  loadedPolicy?.requiredModules
8594
9117
  );
8595
- let files = await walkFiles({ cwd, ignore: config.ignore });
9118
+ let files = await walkFiles({
9119
+ cwd,
9120
+ ignore: config.ignore,
9121
+ defaultExcludes: config.defaultExcludes,
9122
+ excludes: config.excludes
9123
+ });
8596
9124
  if (options.staged === true) {
8597
9125
  const staged = new Set(await listStagedFiles(cwd));
8598
9126
  files = files.filter((file) => staged.has(file));
@@ -8626,6 +9154,7 @@ async function runScan(options = {}, modules = builtinModules) {
8626
9154
  );
8627
9155
  }
8628
9156
  const extraHits = [];
9157
+ let historyNotice;
8629
9158
  const secretsWanted = modulesRequested.includes("secrets");
8630
9159
  if (secretsAdvEnabled() && secretsWanted) {
8631
9160
  if (options.history === true) {
@@ -8633,6 +9162,7 @@ async function runScan(options = {}, modules = builtinModules) {
8633
9162
  const hist = await scanGitHistory2(cwd);
8634
9163
  extraHits.push(...hist.hits);
8635
9164
  collected.push(...hist.findings);
9165
+ historyNotice = hist.history;
8636
9166
  }
8637
9167
  if (options.bundle !== void 0 && options.bundle.length > 0) {
8638
9168
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -8653,7 +9183,11 @@ async function runScan(options = {}, modules = builtinModules) {
8653
9183
  if (options.updateBaseline === true) {
8654
9184
  await writeBaseline(baselinePath, unsuppressed);
8655
9185
  }
8656
- let findings = sortFindings(applyBaseline(unsuppressed, await readBaseline(baselinePath)));
9186
+ const baselineDoc = await readBaseline(baselinePath);
9187
+ let findings = sortFindings(applyBaseline(unsuppressed, baselineDoc));
9188
+ const suppressed = applySuppressions(findings, baselineDoc?.suppressions);
9189
+ findings = sortFindings(suppressed.findings);
9190
+ const suppressionErrors = suppressed.errors;
8657
9191
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
8658
9192
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
8659
9193
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -8725,10 +9259,12 @@ async function runScan(options = {}, modules = builtinModules) {
8725
9259
  cwd,
8726
9260
  filesScanned: files.length,
8727
9261
  modulesRequested: [...modulesRequested],
8728
- modulesRun
9262
+ modulesRun,
9263
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
8729
9264
  },
8730
9265
  config,
8731
- policy: policyResult
9266
+ policy: policyResult,
9267
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
8732
9268
  };
8733
9269
  }
8734
9270
  function scan(options = {}) {
@@ -9064,6 +9600,27 @@ init_suppression();
9064
9600
 
9065
9601
  // src/reporters/console.ts
9066
9602
  init_finding();
9603
+
9604
+ // src/reporters/redact.ts
9605
+ init_mask();
9606
+ var SECRET = /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk_live_[A-Za-z0-9]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
9607
+ function redactSecrets2(text) {
9608
+ return text.replace(SECRET, (match) => maskSecret(match));
9609
+ }
9610
+ function redactFinding(finding3) {
9611
+ return {
9612
+ ...finding3,
9613
+ title: redactSecrets2(finding3.title),
9614
+ message: redactSecrets2(finding3.message),
9615
+ remediation: redactSecrets2(finding3.remediation),
9616
+ snippet: finding3.snippet === void 0 ? void 0 : redactSecrets2(finding3.snippet)
9617
+ };
9618
+ }
9619
+ function redactScan(result) {
9620
+ return { ...result, findings: result.findings.map(redactFinding) };
9621
+ }
9622
+
9623
+ // src/reporters/console.ts
9067
9624
  var SEVERITY_COLOR = {
9068
9625
  critical: (t) => pc.bold(pc.red(t)),
9069
9626
  high: (t) => pc.red(t),
@@ -9103,7 +9660,8 @@ function renderFinding(finding3) {
9103
9660
  }
9104
9661
  return lines;
9105
9662
  }
9106
- function renderConsoleReport(result) {
9663
+ function renderConsoleReport(input) {
9664
+ const result = redactScan(input);
9107
9665
  const { findings, summary, metadata } = result;
9108
9666
  const lines = [];
9109
9667
  lines.push(
@@ -9111,6 +9669,7 @@ function renderConsoleReport(result) {
9111
9669
  ` \u2014 scanned ${metadata.filesScanned} files in ${metadata.durationMs}ms (modules: ${metadata.modulesRun.length > 0 ? metadata.modulesRun.join(", ") : "none"})`
9112
9670
  )
9113
9671
  );
9672
+ if (metadata.history !== void 0) lines.push(metadata.history);
9114
9673
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
9115
9674
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
9116
9675
  lines.push(
@@ -9154,7 +9713,8 @@ function location2(finding3) {
9154
9713
  if (finding3.file === void 0) return "(project)";
9155
9714
  return finding3.line !== void 0 ? `${finding3.file}:${finding3.line}` : finding3.file;
9156
9715
  }
9157
- function renderHtmlReport(result) {
9716
+ function renderHtmlReport(input) {
9717
+ const result = redactScan(input);
9158
9718
  const sections = [];
9159
9719
  const bySeverityDesc = [...SEVERITIES].reverse();
9160
9720
  for (const severity of bySeverityDesc) {
@@ -9201,7 +9761,7 @@ ${items}`);
9201
9761
 
9202
9762
  // src/reporters/json.ts
9203
9763
  function publicFinding(finding3) {
9204
- const copy = { ...finding3 };
9764
+ const copy = redactFinding(finding3);
9205
9765
  delete copy.aiVerdict;
9206
9766
  delete copy.exploitScenario;
9207
9767
  delete copy.aiReasoning;
@@ -9225,7 +9785,8 @@ function location3(finding3) {
9225
9785
  if (finding3.file === void 0) return "(project)";
9226
9786
  return finding3.line !== void 0 ? `${finding3.file}:${finding3.line}` : finding3.file;
9227
9787
  }
9228
- function renderMarkdownReport(result) {
9788
+ function renderMarkdownReport(input) {
9789
+ const result = redactScan(input);
9229
9790
  const lines = [
9230
9791
  "# FullShield report",
9231
9792
  "",
@@ -9277,7 +9838,8 @@ function sarifLevel(severity) {
9277
9838
  function uri(file) {
9278
9839
  return (file ?? "").replace(/\\/g, "/");
9279
9840
  }
9280
- function renderSarifReport(result) {
9841
+ function renderSarifReport(input) {
9842
+ const result = redactScan(input);
9281
9843
  const rules = /* @__PURE__ */ new Map();
9282
9844
  for (const finding3 of result.findings) {
9283
9845
  if (!rules.has(finding3.ruleId)) {
@@ -9384,6 +9946,257 @@ function validateSarif21(doc) {
9384
9946
  return errors;
9385
9947
  }
9386
9948
 
9949
+ // src/core/fingerprint.ts
9950
+ init_finding();
9951
+ function lineIndependentFingerprint(finding3) {
9952
+ const file = (finding3.file ?? "").replace(/\\/g, "/");
9953
+ const text = (finding3.snippet ?? finding3.message ?? "").replace(/\s+/g, " ").trim();
9954
+ return makeFindingId(finding3.ruleId, file, `${finding3.severity}\0${text}`);
9955
+ }
9956
+
9957
+ // src/reporters/ci.ts
9958
+ init_finding();
9959
+ var MESSAGE_CAP = 4e3;
9960
+ function posix2(file) {
9961
+ return file.replace(/\\/g, "/");
9962
+ }
9963
+ function clip(text) {
9964
+ return text.length <= MESSAGE_CAP ? text : `${text.slice(0, MESSAGE_CAP)}\u2026`;
9965
+ }
9966
+ function githubEscape(value, property) {
9967
+ let out = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A");
9968
+ if (property) out = out.replace(/,/g, "%2C");
9969
+ return out;
9970
+ }
9971
+ function githubLevel(severity) {
9972
+ if (severity === "critical" || severity === "high") return "error";
9973
+ if (severity === "medium") return "warning";
9974
+ return "notice";
9975
+ }
9976
+ function gitlabSeverity(severity) {
9977
+ if (severity === "info") return "info";
9978
+ if (severity === "low") return "minor";
9979
+ if (severity === "medium") return "major";
9980
+ if (severity === "high") return "critical";
9981
+ return "blocker";
9982
+ }
9983
+ function xmlEscape(value) {
9984
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
9985
+ }
9986
+ function oneLine(value) {
9987
+ return value.replace(/\s+/g, " ").trim();
9988
+ }
9989
+ function renderGithubAnnotations(input) {
9990
+ const result = redactScan(input);
9991
+ if (result.findings.length === 0) {
9992
+ return "::notice title=FullShield::No findings\n";
9993
+ }
9994
+ const lines = result.findings.map((finding3) => {
9995
+ const props = [];
9996
+ if (finding3.file !== void 0) props.push(`file=${githubEscape(posix2(finding3.file), true)}`);
9997
+ if (finding3.line !== void 0) props.push(`line=${finding3.line}`);
9998
+ if (finding3.column !== void 0) props.push(`col=${finding3.column + 1}`);
9999
+ props.push(`title=${githubEscape(finding3.ruleId, true)}`);
10000
+ const message = githubEscape(clip(finding3.message), false);
10001
+ return `::${githubLevel(finding3.severity)} ${props.join(",")}::${message}`;
10002
+ });
10003
+ return `${lines.join("\n")}
10004
+ `;
10005
+ }
10006
+ function junitCase(finding3) {
10007
+ const file = finding3.file !== void 0 ? posix2(finding3.file) : "fullshield";
10008
+ const attrs = [
10009
+ `name="${xmlEscape(finding3.ruleId)}"`,
10010
+ `classname="${xmlEscape(file)}"`,
10011
+ `file="${xmlEscape(file)}"`
10012
+ ];
10013
+ if (finding3.line !== void 0) attrs.push(`line="${finding3.line}"`);
10014
+ const open = `<testcase ${attrs.join(" ")}>`;
10015
+ if (!meetsThreshold(finding3.severity, "medium")) {
10016
+ return `${open}<skipped message="${xmlEscape(oneLine(clip(finding3.message)))}"/></testcase>`;
10017
+ }
10018
+ const detail = clip(`${finding3.message}${finding3.remediation.length > 0 ? `
10019
+ Fix: ${finding3.remediation}` : ""}`);
10020
+ return `${open}<failure type="${finding3.severity}" message="${xmlEscape(oneLine(clip(finding3.message)))}">${xmlEscape(detail)}</failure></testcase>`;
10021
+ }
10022
+ function renderJunitReport(input) {
10023
+ const result = redactScan(input);
10024
+ const header = '<?xml version="1.0" encoding="UTF-8"?>';
10025
+ if (result.findings.length === 0) {
10026
+ return `${header}
10027
+ <testsuites name="fullshield" tests="1" failures="0" skipped="0"><testsuite name="fullshield" tests="1" failures="0" skipped="0"><testcase name="scan" classname="fullshield"/></testsuite></testsuites>
10028
+ `;
10029
+ }
10030
+ const groups = /* @__PURE__ */ new Map();
10031
+ for (const finding3 of result.findings) {
10032
+ const list = groups.get(finding3.module) ?? [];
10033
+ list.push(finding3);
10034
+ groups.set(finding3.module, list);
10035
+ }
10036
+ let failures = 0;
10037
+ let skipped = 0;
10038
+ const suites = [];
10039
+ for (const [name, findings] of groups) {
10040
+ let suiteFailures = 0;
10041
+ let suiteSkipped = 0;
10042
+ const cases = findings.map((finding3) => {
10043
+ if (meetsThreshold(finding3.severity, "medium")) suiteFailures += 1;
10044
+ else suiteSkipped += 1;
10045
+ return junitCase(finding3);
10046
+ });
10047
+ failures += suiteFailures;
10048
+ skipped += suiteSkipped;
10049
+ suites.push(
10050
+ `<testsuite name="${xmlEscape(name)}" tests="${findings.length}" failures="${suiteFailures}" skipped="${suiteSkipped}">${cases.join("")}</testsuite>`
10051
+ );
10052
+ }
10053
+ return `${header}
10054
+ <testsuites name="fullshield" tests="${result.findings.length}" failures="${failures}" skipped="${skipped}">${suites.join("")}</testsuites>
10055
+ `;
10056
+ }
10057
+ function renderGitlabCodeQuality(input) {
10058
+ const result = redactScan(input);
10059
+ const report = result.findings.map((finding3) => {
10060
+ const path = finding3.file !== void 0 ? posix2(finding3.file) : ".";
10061
+ const location4 = { path };
10062
+ if (finding3.line !== void 0) location4.lines = { begin: finding3.line };
10063
+ return {
10064
+ description: clip(finding3.message),
10065
+ check_name: finding3.ruleId,
10066
+ fingerprint: lineIndependentFingerprint(finding3),
10067
+ severity: gitlabSeverity(finding3.severity),
10068
+ location: location4
10069
+ };
10070
+ });
10071
+ return `${JSON.stringify(report, null, 2)}
10072
+ `;
10073
+ }
10074
+
10075
+ // src/diff/index.ts
10076
+ init_finding();
10077
+ function posix3(file) {
10078
+ return (file ?? "").replace(/\\/g, "/");
10079
+ }
10080
+ function diffKey(finding3) {
10081
+ return lineIndependentFingerprint(finding3);
10082
+ }
10083
+ function groupById(findings) {
10084
+ const groups = /* @__PURE__ */ new Map();
10085
+ for (const finding3 of findings) {
10086
+ if (finding3.id === void 0 || finding3.id.length === 0) continue;
10087
+ const list = groups.get(finding3.id) ?? [];
10088
+ list.push(finding3);
10089
+ groups.set(finding3.id, list);
10090
+ }
10091
+ return groups;
10092
+ }
10093
+ function pairMoves(removed, added) {
10094
+ const removedIds = groupById(removed);
10095
+ const addedIds = groupById(added);
10096
+ const moved = [];
10097
+ for (const [id, group] of removedIds) {
10098
+ const match = addedIds.get(id);
10099
+ if (group.length === 1 && match?.length === 1 && group[0] !== void 0 && match[0] !== void 0) {
10100
+ moved.push({ before: group[0], after: match[0] });
10101
+ }
10102
+ }
10103
+ return moved;
10104
+ }
10105
+ function byLocation(a, b) {
10106
+ return (a.line ?? 0) - (b.line ?? 0) || a.id.localeCompare(b.id) || a.ruleId.localeCompare(b.ruleId);
10107
+ }
10108
+ function groupFindings(findings) {
10109
+ const groups = /* @__PURE__ */ new Map();
10110
+ for (const finding3 of findings) {
10111
+ const key = diffKey(finding3);
10112
+ const list = groups.get(key) ?? [];
10113
+ list.push(finding3);
10114
+ groups.set(key, list);
10115
+ }
10116
+ return groups;
10117
+ }
10118
+ function diffScans(before, after) {
10119
+ const beforeGroups = groupFindings(before);
10120
+ const afterGroups = groupFindings(after);
10121
+ const unchanged = [];
10122
+ const added = [];
10123
+ const removed = [];
10124
+ const moved = [];
10125
+ const keys = [];
10126
+ for (const key of beforeGroups.keys()) keys.push(key);
10127
+ for (const key of afterGroups.keys()) {
10128
+ if (!beforeGroups.has(key)) keys.push(key);
10129
+ }
10130
+ for (const key of keys) {
10131
+ const left = [...beforeGroups.get(key) ?? []].sort(byLocation);
10132
+ const right = [...afterGroups.get(key) ?? []].sort(byLocation);
10133
+ const paired = Math.min(left.length, right.length);
10134
+ for (let i = 0; i < paired; i += 1) {
10135
+ const earlier = left[i];
10136
+ const later = right[i];
10137
+ if (earlier === void 0 || later === void 0) continue;
10138
+ if ((earlier.line ?? 0) === (later.line ?? 0)) unchanged.push(later);
10139
+ else moved.push({ before: earlier, after: later });
10140
+ }
10141
+ for (const finding3 of left.slice(paired)) removed.push(finding3);
10142
+ for (const finding3 of right.slice(paired)) added.push(finding3);
10143
+ }
10144
+ const idMoves = pairMoves(removed, added);
10145
+ const movedBefore = new Set(idMoves.map((item) => item.before));
10146
+ const movedAfter = new Set(idMoves.map((item) => item.after));
10147
+ return {
10148
+ unchanged,
10149
+ added: added.filter((finding3) => !movedAfter.has(finding3)),
10150
+ removed: removed.filter((finding3) => !movedBefore.has(finding3)),
10151
+ moved: [...moved, ...idMoves]
10152
+ };
10153
+ }
10154
+ function parseScanDocument(text) {
10155
+ let parsed;
10156
+ try {
10157
+ parsed = JSON.parse(text);
10158
+ } catch {
10159
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
10160
+ }
10161
+ const list = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings) ? parsed.findings : void 0;
10162
+ if (list === void 0) {
10163
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
10164
+ }
10165
+ return list.map((item) => {
10166
+ if (typeof item !== "object" || item === null) {
10167
+ throw new Error("scan JSON finding is missing ruleId or severity");
10168
+ }
10169
+ const record = item;
10170
+ const ruleId = record.ruleId;
10171
+ const severity = record.severity;
10172
+ if (typeof ruleId !== "string" || ruleId.length === 0 || typeof severity !== "string") {
10173
+ throw new Error("scan JSON finding is missing ruleId or severity");
10174
+ }
10175
+ if (!isSeverity(severity)) {
10176
+ throw new Error(`scan JSON finding has an unknown severity "${severity}"`);
10177
+ }
10178
+ if (record.line !== void 0 && (typeof record.line !== "number" || !Number.isInteger(record.line) || record.line < 0)) {
10179
+ throw new Error("scan JSON finding line must be an integer >= 0");
10180
+ }
10181
+ return item;
10182
+ });
10183
+ }
10184
+ var ADDED_CAP = 20;
10185
+ function addedLine(finding3) {
10186
+ const where = finding3.file !== void 0 ? ` ${posix3(finding3.file)}:${finding3.line ?? 0}` : "";
10187
+ return `${finding3.severity} ${finding3.ruleId}${where}`;
10188
+ }
10189
+ function renderDiffText(diff) {
10190
+ const lines = [
10191
+ `fullshield diff: unchanged ${diff.unchanged.length}, added ${diff.added.length}, removed ${diff.removed.length}, moved ${diff.moved.length}`
10192
+ ];
10193
+ if (diff.added.length === 0) return lines.join("\n");
10194
+ lines.push("added:");
10195
+ for (const finding3 of diff.added.slice(0, ADDED_CAP)) lines.push(addedLine(finding3));
10196
+ if (diff.added.length > ADDED_CAP) lines.push(`${diff.added.length - ADDED_CAP} more \u2014 use --json`);
10197
+ return lines.join("\n");
10198
+ }
10199
+
9387
10200
  // src/sbom/cyclonedx.ts
9388
10201
  function validateCycloneDx15(doc) {
9389
10202
  const errors = [];
@@ -9610,4 +10423,4 @@ function buildVex(findings, format, meta = { name: "app", version: "0.0.0" }) {
9610
10423
  return buildOpenVex(findings);
9611
10424
  }
9612
10425
 
9613
- export { CONFIG_FILE_NAME, ConfigError, DEFAULT_CONFIG, IMPLEMENTED_MODULES, MODULE_NAMES, POLICY_FILE_NAME, POLICY_FLAG, POLICY_PEER_ERROR, PolicyError, SEVERITIES, analyzeWorkflowTampering, analyzeWormIndicators, applyBaseline, applyInlineSuppressions, applyPolicy, buildCycloneDxVex, buildOpenVex, buildSbom, buildVex, builtinModules, createAiClient, experimentalVex, failsCi, guardTools, isInternalAi, isLiveAi, isModuleName, isPolicyEnabled, isSeverity, loadConfig, loadPolicy, makeFindingId, mcpCfgEnabled2 as mcpCfgEnabled, mcpHomeConfigPaths, meetsThreshold, packageVersion, parseIgnore, parsePolicy, provenanceCoverage, readBaseline, renderConsoleReport, renderCycloneDx, renderHtmlReport, renderJsonReport, renderMarkdownReport, renderPrSummary, renderSarifReport, ruleDocsUrl, runAiFix, runFix, runScan, scan, scanMcpConfig, scanTextForSecrets, severityRank, sortFindings, summarize, validateCycloneDx15, validateCycloneDxVex, validateOpenVex, validateSarif21, walkFiles, writeBaseline };
10426
+ export { CONFIG_FILE_NAME, ConfigError, DEFAULT_CONFIG, DIFF_FLAG, IMPLEMENTED_MODULES, MODULE_NAMES, POLICY_FILE_NAME, POLICY_FLAG, POLICY_PEER_ERROR, PolicyError, SEVERITIES, analyzeWorkflowTampering, analyzeWormIndicators, applyBaseline, applyInlineSuppressions, applyPolicy, buildCycloneDxVex, buildOpenVex, buildSbom, buildVex, builtinModules, createAiClient, diffScans, experimentalVex, failsCi, guardTools, isInternalAi, isLiveAi, isModuleName, isPolicyEnabled, isSeverity, loadConfig, loadPolicy, makeFindingId, mcpCfgEnabled2 as mcpCfgEnabled, mcpHomeConfigPaths, meetsThreshold, packageVersion, parseIgnore, parsePolicy, parseScanDocument, provenanceCoverage, readBaseline, renderConsoleReport, renderCycloneDx, renderDiffText, renderGithubAnnotations, renderGitlabCodeQuality, renderHtmlReport, renderJsonReport, renderJunitReport, renderMarkdownReport, renderPrSummary, renderSarifReport, ruleDocsUrl, runAiFix, runFix, runScan, scan, scanMcpConfig, scanTextForSecrets, severityRank, sortFindings, summarize, validateCycloneDx15, validateCycloneDxVex, validateOpenVex, validateSarif21, walkFiles, writeBaseline };