fullshield 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { readFile, mkdir, writeFile, stat, unlink, readdir, mkdtemp } from 'fs/promises';
3
- import { resolve, join, basename, dirname, relative, posix } from 'path';
3
+ import { resolve, join, basename, dirname, posix, relative } from 'path';
4
4
  import { execFile, spawnSync, spawn, execFileSync } from 'child_process';
5
5
  import { createInterface } from 'readline';
6
6
  import { promisify } from 'util';
7
7
  import { performance } from 'perf_hooks';
8
- import { homedir, tmpdir } from 'os';
8
+ import { homedir, availableParallelism, tmpdir } from 'os';
9
9
  import { existsSync, statSync, readFileSync, readdirSync, cpSync, mkdirSync, rmSync } from 'fs';
10
- import ignoreModule from 'ignore';
10
+ import { Worker } from 'worker_threads';
11
11
  import { fileURLToPath } from 'url';
12
+ import ignoreModule from 'ignore';
12
13
  import pc from 'picocolors';
13
14
 
14
15
  var __defProp = Object.defineProperty;
@@ -564,6 +565,7 @@ var init_incremental = __esm({
564
565
  // src/modules/secrets/history.ts
565
566
  var history_exports = {};
566
567
  __export(history_exports, {
568
+ historyCommitLimit: () => historyCommitLimit,
567
569
  scanGitHistory: () => scanGitHistory
568
570
  });
569
571
  function gitError(error) {
@@ -606,7 +608,13 @@ function remapHit(hit, file) {
606
608
  secret: hit.secret
607
609
  };
608
610
  }
609
- 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) {
610
618
  const changes = [];
611
619
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
612
620
  const child = spawn(
@@ -623,6 +631,8 @@ async function streamLogChanges(cwd) {
623
631
  "--format=FSCOMMIT:%H",
624
632
  "--no-textconv",
625
633
  "--no-ext-diff",
634
+ "-n",
635
+ String(maxCommits),
626
636
  "HEAD"
627
637
  ],
628
638
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -691,8 +701,14 @@ async function headSecretKeys(cwd) {
691
701
  }
692
702
  return keys;
693
703
  }
694
- async function scanGitHistory(cwd) {
695
- 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);
696
712
  const blobHits = /* @__PURE__ */ new Map();
697
713
  const first = /* @__PURE__ */ new Map();
698
714
  for (const change of changes) {
@@ -729,7 +745,13 @@ async function scanGitHistory(cwd) {
729
745
  secret: hit.secret
730
746
  });
731
747
  }
732
- 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
+ };
733
755
  }
734
756
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
735
757
  var init_history = __esm({
@@ -1077,7 +1099,8 @@ function walk(node, visit) {
1077
1099
  }
1078
1100
  }
1079
1101
  function attachParents(node, parent) {
1080
- node.parent = parent;
1102
+ const current = node;
1103
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
1081
1104
  for (const key of Object.keys(node)) {
1082
1105
  if (SKIP_KEYS.has(String(key))) continue;
1083
1106
  const value = node[key];
@@ -1147,6 +1170,14 @@ function classesEnabled() {
1147
1170
  function mcpCfgEnabled() {
1148
1171
  return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
1149
1172
  }
1173
+ function taintV2Enabled() {
1174
+ return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
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
+ }
1150
1181
 
1151
1182
  // src/modules/config/finding.ts
1152
1183
  init_finding();
@@ -1956,6 +1987,103 @@ function exactPinsFromManifest(pkg) {
1956
1987
 
1957
1988
  // src/modules/deps/reachability.ts
1958
1989
  init_finding();
1990
+
1991
+ // src/modules/deps/symbol-reachability.ts
1992
+ function affectedFunctionNames(vuln) {
1993
+ const names = /* @__PURE__ */ new Set();
1994
+ for (const affected of vuln.affected ?? []) {
1995
+ const list = affected.ecosystem_specific?.affected_functions;
1996
+ if (!Array.isArray(list)) continue;
1997
+ for (const item of list) {
1998
+ if (typeof item === "string" && item.length > 0) names.add(item);
1999
+ }
2000
+ }
2001
+ return [...names];
2002
+ }
2003
+ function escapeRegExp(value) {
2004
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2005
+ }
2006
+ function called(source, local) {
2007
+ return new RegExp(`\\b${escapeRegExp(local)}\\s*\\(`).test(source);
2008
+ }
2009
+ function memberCalled(source, object, member) {
2010
+ return new RegExp(`\\b${escapeRegExp(object)}\\s*\\.\\s*${escapeRegExp(member)}\\s*\\(`).test(source);
2011
+ }
2012
+ function sourceCallsAffectedSymbol(source, symbol) {
2013
+ const esc2 = escapeRegExp(symbol);
2014
+ const named = new RegExp(
2015
+ `import\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`,
2016
+ "g"
2017
+ );
2018
+ let match;
2019
+ while ((match = named.exec(source)) !== null) {
2020
+ const parts = (match[1] ?? "").split(",");
2021
+ for (const part of parts) {
2022
+ const bits = part.trim().split(/\s+as\s+/);
2023
+ const imported = bits[0]?.trim();
2024
+ const local = (bits[1] ?? bits[0])?.trim();
2025
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
2026
+ }
2027
+ }
2028
+ const star = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"][^'"]+['"]/g;
2029
+ while ((match = star.exec(source)) !== null) {
2030
+ const local = match[1];
2031
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
2032
+ }
2033
+ const subDefault = new RegExp(
2034
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"][^'"]+/${esc2}['"]`
2035
+ );
2036
+ const sub = subDefault.exec(source);
2037
+ if (sub?.[1] !== void 0 && called(source, sub[1])) return true;
2038
+ const reqMember = new RegExp(
2039
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+['"]\\s*\\)\\s*\\.\\s*${esc2}`
2040
+ );
2041
+ const reqHit = reqMember.exec(source);
2042
+ if (reqHit?.[1] !== void 0 && called(source, reqHit[1])) return true;
2043
+ const reqNs = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2044
+ while ((match = reqNs.exec(source)) !== null) {
2045
+ const local = match[1];
2046
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
2047
+ }
2048
+ const reqNamed = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2049
+ while ((match = reqNamed.exec(source)) !== null) {
2050
+ const parts = (match[1] ?? "").split(",");
2051
+ for (const part of parts) {
2052
+ const bits = part.trim().split(/\s*:\s*/);
2053
+ const imported = bits[0]?.trim();
2054
+ const local = (bits[1] ?? bits[0])?.trim();
2055
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
2056
+ }
2057
+ }
2058
+ const reqSub = new RegExp(
2059
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+/${esc2}['"]\\s*\\)`
2060
+ );
2061
+ const subReq = reqSub.exec(source);
2062
+ if (subReq?.[1] !== void 0 && called(source, subReq[1])) return true;
2063
+ const barrel = new RegExp(`export\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`, "g");
2064
+ while ((match = barrel.exec(source)) !== null) {
2065
+ const parts = (match[1] ?? "").split(",");
2066
+ for (const part of parts) {
2067
+ const bits = part.trim().split(/\s+as\s+/);
2068
+ const exported = (bits[1] ?? bits[0])?.trim();
2069
+ if (exported === symbol) {
2070
+ const local = bits[0]?.trim();
2071
+ if (local !== void 0 && called(source, local)) return true;
2072
+ }
2073
+ }
2074
+ }
2075
+ return false;
2076
+ }
2077
+ function classifySymbolUse(symbols, sources) {
2078
+ for (const symbol of symbols) {
2079
+ for (const source of sources.values()) {
2080
+ if (sourceCallsAffectedSymbol(source, symbol)) return "symbol-reachable";
2081
+ }
2082
+ }
2083
+ return "symbol-not-called";
2084
+ }
2085
+
2086
+ // src/modules/deps/reachability.ts
1959
2087
  function packageNameFromSnippet(snippet) {
1960
2088
  if (snippet.startsWith("@")) {
1961
2089
  const second = snippet.indexOf("@", 1);
@@ -1984,9 +2112,9 @@ function isProductionSource(rel) {
1984
2112
  }
1985
2113
  function indexPackageUsage(sources) {
1986
2114
  const usage = /* @__PURE__ */ new Map();
1987
- const bump = (pkg, called) => {
2115
+ const bump = (pkg, called2) => {
1988
2116
  const prev = usage.get(pkg) ?? { called: false };
1989
- usage.set(pkg, { imported: true, called: prev.called || called });
2117
+ usage.set(pkg, { imported: true, called: prev.called || called2 });
1990
2118
  };
1991
2119
  const nsRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
1992
2120
  const namedRe = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
@@ -2042,6 +2170,29 @@ function shortestDependencyPath(target, starts, edges) {
2042
2170
  }
2043
2171
  return void 0;
2044
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
+ }
2045
2196
  function reachableFrom(target, roots, edges) {
2046
2197
  if (roots.has(target)) return true;
2047
2198
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -2062,9 +2213,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2062
2213
  }
2063
2214
  if (graph !== void 0 && graph.edgesKnown) {
2064
2215
  const roots = importedDirectNames(productionUsage, graph);
2065
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
2216
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
2217
+ const path = paths[0];
2066
2218
  if (path !== void 0 && path.length >= 2) {
2067
- return { reachability: "reachable-via-dependency", path };
2219
+ return { reachability: "reachable-via-dependency", path, paths };
2068
2220
  }
2069
2221
  return { reachability: "not-imported" };
2070
2222
  }
@@ -2074,10 +2226,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2074
2226
  return { reachability: "not-imported" };
2075
2227
  }
2076
2228
  function reachabilitySteps(reachability) {
2077
- if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown") {
2229
+ if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
2078
2230
  return 0;
2079
2231
  }
2080
- if (reachability === "imported-not-called") return 1;
2232
+ if (reachability === "imported-not-called" || reachability === "symbol-not-called") return 1;
2081
2233
  return 2;
2082
2234
  }
2083
2235
  function downgradeSeverity(severity, steps) {
@@ -2094,8 +2246,108 @@ function isDevOnly(pkgName, ctx) {
2094
2246
  if (inProd) return false;
2095
2247
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
2096
2248
  }
2097
- function applyReachability(findings, ctx) {
2098
- return findings.map((finding3) => {
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
+ }
2349
+ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2350
+ const mapped = findings.map((finding3) => {
2099
2351
  if (finding3.ruleId === "DEPS-MALICIOUS-001") {
2100
2352
  if (finding3.snippet === void 0) return finding3;
2101
2353
  const pkg2 = packageNameFromSnippet(finding3.snippet);
@@ -2105,19 +2357,25 @@ function applyReachability(findings, ctx) {
2105
2357
  ...finding3,
2106
2358
  severity: "critical",
2107
2359
  reachability: classified2.reachability,
2108
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
2360
+ ...pathFields(classified2)
2109
2361
  };
2110
2362
  }
2111
2363
  if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
2112
2364
  const pkg = packageNameFromSnippet(finding3.snippet);
2113
2365
  if (pkg === void 0 || pkg.length === 0) return finding3;
2114
- const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2366
+ let classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2367
+ const named = symbols.get(finding3.id) ?? [];
2368
+ if (named.length > 0 && ctx.sources !== void 0) {
2369
+ const symbolClass = classifySymbolUse(named, ctx.sources);
2370
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
2371
+ }
2115
2372
  const steps = reachabilitySteps(classified.reachability);
2116
2373
  let severity = steps === 0 ? finding3.severity : downgradeSeverity(finding3.severity, steps);
2117
2374
  let message = finding3.message;
2118
2375
  if (classified.path !== void 0) {
2119
2376
  const shown = classified.path.join(" > ");
2120
2377
  message = `${message} Reachable via ${shown}.`;
2378
+ message = appendOtherPaths(message, classified.paths);
2121
2379
  }
2122
2380
  const devOnly = isDevOnly(pkg, ctx);
2123
2381
  if (devOnly) {
@@ -2127,11 +2385,12 @@ function applyReachability(findings, ctx) {
2127
2385
  return {
2128
2386
  ...finding3,
2129
2387
  reachability: classified.reachability,
2130
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
2388
+ ...pathFields(classified),
2131
2389
  severity,
2132
2390
  message
2133
2391
  };
2134
2392
  });
2393
+ return groupPackageAdvisories(mapped);
2135
2394
  }
2136
2395
 
2137
2396
  // src/modules/deps/semver.ts
@@ -4033,6 +4292,59 @@ function experimentalVex() {
4033
4292
  return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
4034
4293
  }
4035
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
+
4036
4348
  // src/fixer/eol.ts
4037
4349
  function detectEol(text) {
4038
4350
  return text.includes("\r\n") ? "\r\n" : "\n";
@@ -4147,7 +4459,7 @@ function walkJs(dir, out = []) {
4147
4459
  }
4148
4460
  return out;
4149
4461
  }
4150
- function readJson(path) {
4462
+ function readJson2(path) {
4151
4463
  return JSON.parse(readFileSync(path, "utf8"));
4152
4464
  }
4153
4465
  var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
@@ -4161,7 +4473,7 @@ function packageSources(dir) {
4161
4473
  let scripts = "";
4162
4474
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
4163
4475
  try {
4164
- const pkg = readJson(manifestPath);
4476
+ const pkg = readJson2(manifestPath);
4165
4477
  const block = pkg.scripts ?? {};
4166
4478
  scripts = Object.values(block).join("\n");
4167
4479
  } catch {
@@ -4451,14 +4763,17 @@ var depsModule = {
4451
4763
  findings2.push(typosquatFinding("package.json", hit));
4452
4764
  }
4453
4765
  const osvHits2 = await queryOsv(pins, osvOpts);
4766
+ const symbols2 = /* @__PURE__ */ new Map();
4454
4767
  for (const hit of osvHits2) {
4455
4768
  for (const vuln of hit.vulns) {
4456
- findings2.push(
4457
- isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name))
4458
- );
4769
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
4770
+ const names = affectedFunctionNames(vuln);
4771
+ if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols2.set(finding3.id, names);
4772
+ findings2.push(finding3);
4459
4773
  }
4460
4774
  }
4461
- return applyReachability(findings2, await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest)));
4775
+ const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4776
+ return applyReachability(findings2, noLockCtx, symbols2);
4462
4777
  }
4463
4778
  let contents;
4464
4779
  try {
@@ -4479,18 +4794,21 @@ var depsModule = {
4479
4794
  }
4480
4795
  }
4481
4796
  const osvHits = await queryOsv(packages, osvOpts);
4797
+ const symbols = /* @__PURE__ */ new Map();
4482
4798
  for (const hit of osvHits) {
4483
4799
  for (const vuln of hit.vulns) {
4484
- findings.push(
4485
- isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name))
4486
- );
4800
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4801
+ const names = affectedFunctionNames(vuln);
4802
+ if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols.set(finding3.id, names);
4803
+ findings.push(finding3);
4487
4804
  }
4488
4805
  }
4489
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
4806
+ let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
4490
4807
  if (experimentalVex()) {
4491
4808
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4492
4809
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4493
4810
  }
4811
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4494
4812
  return reached;
4495
4813
  }
4496
4814
  };
@@ -4520,7 +4838,7 @@ async function reachabilityCtx(cwd, files, graph) {
4520
4838
  } catch {
4521
4839
  }
4522
4840
  }
4523
- return { productionUsage: indexPackageUsage(prodSources), graph };
4841
+ return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
4524
4842
  }
4525
4843
 
4526
4844
  // src/modules/mcp/index.ts
@@ -5081,6 +5399,26 @@ function isXssSanitizerCall(node) {
5081
5399
  return false;
5082
5400
  }
5083
5401
  var NO_RETURNERS = /* @__PURE__ */ new Set();
5402
+ var CALLBACK_METHODS = /* @__PURE__ */ new Set([
5403
+ "then",
5404
+ "catch",
5405
+ "finally",
5406
+ "map",
5407
+ "forEach",
5408
+ "filter",
5409
+ "flatMap",
5410
+ "find",
5411
+ "reduce"
5412
+ ]);
5413
+ var taintedThisFields = /* @__PURE__ */ new WeakMap();
5414
+ function enclosingClass(node) {
5415
+ let current = node.parent;
5416
+ while (current !== void 0) {
5417
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
5418
+ current = current.parent;
5419
+ }
5420
+ return void 0;
5421
+ }
5084
5422
  function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5085
5423
  if (node === void 0) return false;
5086
5424
  if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
@@ -5092,8 +5430,34 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5092
5430
  case "Identifier":
5093
5431
  return tainted.has(node.name);
5094
5432
  case "MemberExpression":
5433
+ if (taintV2Enabled() && node.object.type === "ThisExpression") {
5434
+ const cls = enclosingClass(node);
5435
+ const name = propName(node);
5436
+ if (cls !== void 0 && name !== void 0 && taintedThisFields.get(cls)?.has(name) === true) {
5437
+ return true;
5438
+ }
5439
+ }
5095
5440
  return exprIsTainted(node.object, tainted, returners);
5441
+ case "ArrayExpression":
5442
+ if (!taintV2Enabled()) return false;
5443
+ return node.elements.some((el) => {
5444
+ if (el === null) return false;
5445
+ if (el.type === "SpreadElement") return exprIsTainted(el.argument, tainted, returners);
5446
+ return exprIsTainted(el, tainted, returners);
5447
+ });
5448
+ case "ObjectExpression":
5449
+ if (!taintV2Enabled()) return false;
5450
+ return node.properties.some((prop) => {
5451
+ if (prop.type === "SpreadElement") return exprIsTainted(prop.argument, tainted, returners);
5452
+ if (prop.type !== "Property") return false;
5453
+ return exprIsTainted(prop.value, tainted, returners);
5454
+ });
5096
5455
  case "CallExpression":
5456
+ if (taintV2Enabled() && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
5457
+ return node.arguments.some(
5458
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
5459
+ );
5460
+ }
5097
5461
  if (isSchemaValidatorCall(node)) return false;
5098
5462
  if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
5099
5463
  if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
@@ -5324,7 +5688,66 @@ function scopeReturnsTaint(scope, tainted, returners) {
5324
5688
  return yes;
5325
5689
  }
5326
5690
  var MAX_TAINT_HOPS = 5;
5327
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5691
+ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5692
+ let changed = false;
5693
+ const mark = (fn, name) => {
5694
+ if (name === void 0) return;
5695
+ const set = seeds.get(fn) ?? /* @__PURE__ */ new Set();
5696
+ if (set.has(name)) return;
5697
+ set.add(name);
5698
+ seeds.set(fn, set);
5699
+ changed = true;
5700
+ };
5701
+ for (const fn of functions) {
5702
+ const f = facts.get(fn);
5703
+ if (f === void 0) continue;
5704
+ let parent = fn.parent;
5705
+ while (parent !== void 0 && !isFunctionNode(parent)) {
5706
+ parent = parent.parent;
5707
+ }
5708
+ if (parent !== void 0 && isFunctionNode(parent)) {
5709
+ const outer = facts.get(parent);
5710
+ if (outer !== void 0) {
5711
+ for (const name of outer.tainted) mark(fn, name);
5712
+ }
5713
+ }
5714
+ walkScope(fn, (node) => {
5715
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") {
5716
+ if (node.left.object.type === "ThisExpression" && exprIsTainted(node.right, f.tainted, returners)) {
5717
+ const cls = enclosingClass(node);
5718
+ const field = propName(node.left);
5719
+ if (cls !== void 0 && field !== void 0) {
5720
+ const fields = taintedThisFields.get(cls) ?? /* @__PURE__ */ new Set();
5721
+ if (!fields.has(field)) {
5722
+ fields.add(field);
5723
+ taintedThisFields.set(cls, fields);
5724
+ changed = true;
5725
+ }
5726
+ }
5727
+ }
5728
+ }
5729
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5730
+ const method = propName(node.callee);
5731
+ if (method === void 0) return;
5732
+ if (CALLBACK_METHODS.has(method) && exprIsTainted(node.callee.object, f.tainted, returners)) {
5733
+ const callback = node.arguments[0];
5734
+ if (callback !== void 0 && isFunctionNode(callback)) {
5735
+ mark(callback, boundNames(callback.params[0])[0]);
5736
+ }
5737
+ }
5738
+ if ((method === "call" || method === "apply") && node.callee.object.type === "Identifier") {
5739
+ const target = nameToFn.get(node.callee.object.name);
5740
+ if (target === void 0 || !isFunctionNode(target)) return;
5741
+ const arg = node.arguments[1];
5742
+ if (arg !== void 0 && arg.type !== "SpreadElement" && exprIsTainted(arg, f.tainted, returners)) {
5743
+ mark(target, boundNames(target.params[0])[0]);
5744
+ }
5745
+ }
5746
+ });
5747
+ }
5748
+ return changed;
5749
+ }
5750
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
5328
5751
  attachParents(ast);
5329
5752
  const facts = /* @__PURE__ */ new WeakMap();
5330
5753
  const functions = [];
@@ -5338,7 +5761,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5338
5761
  }
5339
5762
  const seeds = /* @__PURE__ */ new Map();
5340
5763
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
5341
- const returners = new Set(extraReturners);
5764
+ const returners = new Set(extraReturners2);
5342
5765
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
5343
5766
  let changed = false;
5344
5767
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -5378,6 +5801,9 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5378
5801
  });
5379
5802
  });
5380
5803
  }
5804
+ if (taintV2Enabled() && seedTaintV2(functions, facts, seeds, returners, nameToFn)) {
5805
+ changed = true;
5806
+ }
5381
5807
  if (!changed) break;
5382
5808
  }
5383
5809
  return facts;
@@ -5520,6 +5946,21 @@ function requireCall(node) {
5520
5946
  }
5521
5947
  return void 0;
5522
5948
  }
5949
+ function reexportsOf(ast) {
5950
+ const out = /* @__PURE__ */ new Map();
5951
+ walk(ast, (node) => {
5952
+ if (node.type !== "ExportNamedDeclaration" || node.source === null || node.source === void 0) return;
5953
+ const spec = literalString(node.source);
5954
+ if (spec === void 0) return;
5955
+ for (const specNode of node.specifiers) {
5956
+ if (specNode.type !== "ExportSpecifier") continue;
5957
+ const exported = specNode.exported.type === "Identifier" ? specNode.exported.name : void 0;
5958
+ const local = specNode.local.type === "Identifier" ? specNode.local.name : exported;
5959
+ if (exported !== void 0 && local !== void 0) out.set(exported, { spec, name: local });
5960
+ }
5961
+ });
5962
+ return out;
5963
+ }
5523
5964
  function resolveRelative(fromFile, spec, files) {
5524
5965
  if (!spec.startsWith(".")) return void 0;
5525
5966
  const base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
@@ -5597,6 +6038,22 @@ function runInterproc(parsed) {
5597
6038
  functions.set(`${fn.file}#${fn.name}`, fn);
5598
6039
  }
5599
6040
  }
6041
+ if (taintV2Enabled()) {
6042
+ const barrels = /* @__PURE__ */ new Map();
6043
+ for (const p of parsed) barrels.set(p.file, reexportsOf(p.ast));
6044
+ for (const ns of requires.values()) {
6045
+ for (const [local, target] of ns) {
6046
+ const hash = target.indexOf("#");
6047
+ if (hash < 0) continue;
6048
+ const file = target.slice(0, hash);
6049
+ const name = target.slice(hash + 1);
6050
+ const re = barrels.get(file)?.get(name);
6051
+ if (re === void 0) continue;
6052
+ const resolved = resolveRelative(file, re.spec, files);
6053
+ if (resolved !== void 0) ns.set(local, `${resolved}#${re.name}`);
6054
+ }
6055
+ }
6056
+ }
5600
6057
  const queue = [];
5601
6058
  for (const p of parsed) {
5602
6059
  const facts = analyzeFile(p.ast);
@@ -6112,7 +6569,10 @@ function isMongoReceiver(node, source) {
6112
6569
  const obj = node.callee.object;
6113
6570
  if (obj.type === "Identifier") {
6114
6571
  if (isCollectionName(obj.name)) return true;
6115
- if (isPascalModel(obj.name)) return !fileHasSqlOrm;
6572
+ if (isPascalModel(obj.name)) {
6573
+ if (/extends\s+BaseEntity\b/.test(source)) return false;
6574
+ return !fileHasSqlOrm;
6575
+ }
6116
6576
  return fileHasMongo;
6117
6577
  }
6118
6578
  if (obj.type === "MemberExpression") {
@@ -7174,6 +7634,136 @@ function scanPromptInjection(file, source) {
7174
7634
  return findings;
7175
7635
  }
7176
7636
 
7637
+ // src/runtime/lru.ts
7638
+ var LruMap = class {
7639
+ constructor(max) {
7640
+ this.max = max;
7641
+ if (max < 1) throw new Error("LruMap max must be >= 1");
7642
+ }
7643
+ max;
7644
+ map = /* @__PURE__ */ new Map();
7645
+ get size() {
7646
+ return this.map.size;
7647
+ }
7648
+ has(key) {
7649
+ return this.map.has(key);
7650
+ }
7651
+ get(key) {
7652
+ if (!this.map.has(key)) return void 0;
7653
+ const value = this.map.get(key);
7654
+ this.map.delete(key);
7655
+ this.map.set(key, value);
7656
+ return value;
7657
+ }
7658
+ set(key, value) {
7659
+ if (this.map.has(key)) this.map.delete(key);
7660
+ this.map.set(key, value);
7661
+ while (this.map.size > this.max) {
7662
+ const oldest = this.map.keys().next().value;
7663
+ if (oldest === void 0) break;
7664
+ this.map.delete(oldest);
7665
+ }
7666
+ return this;
7667
+ }
7668
+ };
7669
+ function packageVersion() {
7670
+ let dir = dirname(fileURLToPath(import.meta.url));
7671
+ for (let depth = 0; depth < 6; depth += 1) {
7672
+ try {
7673
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
7674
+ const pkg = JSON.parse(raw);
7675
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7676
+ return pkg.version;
7677
+ }
7678
+ } catch {
7679
+ }
7680
+ const parent = dirname(dir);
7681
+ if (parent === dir) break;
7682
+ dir = parent;
7683
+ }
7684
+ throw new Error("fullshield package.json not found");
7685
+ }
7686
+
7687
+ // src/core/parse-pool.ts
7688
+ var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7689
+ var cache = new LruMap(64);
7690
+ function parsePoolEnabled() {
7691
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
7692
+ }
7693
+ function parsePoolSize() {
7694
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7695
+ if (raw !== void 0 && raw !== "") {
7696
+ const parsed = Number(raw);
7697
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7698
+ }
7699
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
7700
+ }
7701
+ function workerMemoryMb() {
7702
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7703
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7704
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7705
+ return 512;
7706
+ }
7707
+ function parseCacheKey(code) {
7708
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
7709
+ }
7710
+ function parseOne(code) {
7711
+ const key = parseCacheKey(code);
7712
+ const hit = cache.get(key);
7713
+ if (hit !== void 0) return Promise.resolve(hit);
7714
+ return new Promise((resolve5, reject) => {
7715
+ const worker = new Worker(fileURLToPath(workerUrl), {
7716
+ workerData: { code },
7717
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7718
+ });
7719
+ let settled = false;
7720
+ const finish = () => {
7721
+ void worker.terminate();
7722
+ };
7723
+ const succeed = (ast) => {
7724
+ if (settled) return;
7725
+ settled = true;
7726
+ cache.set(key, ast);
7727
+ finish();
7728
+ resolve5(ast);
7729
+ };
7730
+ const fail = (error) => {
7731
+ if (settled) return;
7732
+ settled = true;
7733
+ finish();
7734
+ reject(error);
7735
+ };
7736
+ worker.once("message", (ast) => {
7737
+ succeed(ast);
7738
+ });
7739
+ worker.once("error", (error) => {
7740
+ fail(error);
7741
+ });
7742
+ worker.once("exit", (code2) => {
7743
+ if (settled) return;
7744
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
7745
+ });
7746
+ });
7747
+ }
7748
+ async function parseWithPool(codes) {
7749
+ const size = parsePoolSize();
7750
+ const out = new Array(codes.length);
7751
+ let next = 0;
7752
+ async function run() {
7753
+ for (; ; ) {
7754
+ const index = next;
7755
+ next += 1;
7756
+ if (index >= codes.length) return;
7757
+ const code = codes[index];
7758
+ if (code === void 0) return;
7759
+ out[index] = await parseOne(code);
7760
+ }
7761
+ }
7762
+ const workers = Math.min(size, Math.max(1, codes.length));
7763
+ await Promise.all(Array.from({ length: workers }, () => run()));
7764
+ return out;
7765
+ }
7766
+
7177
7767
  // src/modules/sast/engine.ts
7178
7768
  var sastRules = [
7179
7769
  sqliRule,
@@ -7206,8 +7796,8 @@ function snippetOf(source, node) {
7206
7796
  if (node.range === void 0) return "";
7207
7797
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7208
7798
  }
7209
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7210
- const facts = analyzeFile(ast, extraReturners);
7799
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7800
+ const facts = analyzeFile(ast, extraReturners2);
7211
7801
  const findings = [];
7212
7802
  for (const rule of sastRules) {
7213
7803
  const ctx = {
@@ -7285,51 +7875,203 @@ function findingsFromTemplate(filename, source) {
7285
7875
  xssRule.create(ctx);
7286
7876
  return findings;
7287
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
+ }
7288
8030
  var sastModule = {
7289
8031
  name: "sast",
7290
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;
7291
8036
  const findings = [];
7292
8037
  const parsed = [];
7293
- for (const rel of ctx.files) {
7294
- const isSource = SOURCE_EXT.test(rel);
7295
- const isTemplate = TEMPLATE_EXT.test(rel);
7296
- if (!isSource && !isTemplate) continue;
7297
- let source;
7298
- try {
7299
- source = await readFile(join(ctx.cwd, rel), "utf8");
7300
- } 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}.`));
7301
8044
  continue;
7302
8045
  }
7303
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7304
- if (isTemplate) {
8046
+ if (TEMPLATE_EXT.test(rel)) {
7305
8047
  findings.push(...findingsFromTemplate(rel, source));
7306
8048
  continue;
7307
8049
  }
7308
- try {
7309
- const ast = await parseSource(source);
7310
- parsed.push({ file: rel, source, ast });
7311
- } catch {
7312
- }
8050
+ const ast = await parseCode(source);
8051
+ if (ast === void 0) continue;
8052
+ parsed.push({ file: rel, source, ast });
7313
8053
  }
7314
8054
  const files = new Set(parsed.map((p) => p.file));
7315
8055
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7316
8056
  const returning = /* @__PURE__ */ new Map();
7317
- for (const p of parsed) {
7318
- returning.set(p.file, functionsThatReturnTaint(p.ast));
8057
+ for (const entry of parsed) {
8058
+ returning.set(entry.file, functionsThatReturnTaint(entry.ast));
7319
8059
  }
7320
- for (const p of parsed) {
7321
- const ns = collectRequires(p.file, p.ast, files);
7322
- const extra = new Set(returning.get(p.file));
7323
- for (const [local, spec] of ns) {
7324
- const hash = spec.indexOf("#");
7325
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7326
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7327
- if (returning.get(file)?.has(exp)) extra.add(local);
7328
- }
7329
- 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
+ );
7330
8070
  }
7331
8071
  findings.push(...runInterproc(parsed));
7332
- return findings;
8072
+ const out = dedupeSastSinks(findings);
8073
+ releaseParsed(parsed);
8074
+ return out;
7333
8075
  }
7334
8076
  };
7335
8077
 
@@ -7701,7 +8443,7 @@ function createAiClient(options) {
7701
8443
  const useFixtures = options.useFixtures ?? !isLiveAi();
7702
8444
  const model = options.model ?? process.env.FULLSHIELD_AI_MODEL ?? (useFixtures ? "recorded-fixture" : "qwen2.5-coder:7b");
7703
8445
  const provider = options.provider ?? process.env.FULLSHIELD_AI_PROVIDER ?? (useFixtures ? "fixture" : "ollama");
7704
- const cache = /* @__PURE__ */ new Map();
8446
+ const cache2 = /* @__PURE__ */ new Map();
7705
8447
  let spentUsd = 0;
7706
8448
  let networkCalls = 0;
7707
8449
  const liveCost = provider === "ollama" ? 0 : FIXTURE_COST;
@@ -7721,7 +8463,7 @@ function createAiClient(options) {
7721
8463
  const redactedPrompt = `${modeInstruction(req.mode)}
7722
8464
  ${wrapUntrustedCode(req.prompt)}`;
7723
8465
  const key = cacheKey(req.model ?? model, redactedPrompt);
7724
- const cached = cache.get(key);
8466
+ const cached = cache2.get(key);
7725
8467
  if (cached !== void 0) {
7726
8468
  return { ...cached, cached: true, costUsd: 0 };
7727
8469
  }
@@ -7735,7 +8477,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7735
8477
  cached: false,
7736
8478
  redactedPrompt
7737
8479
  };
7738
- cache.set(key, response2);
8480
+ cache2.set(key, response2);
7739
8481
  return response2;
7740
8482
  }
7741
8483
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -7785,21 +8527,42 @@ ${wrapUntrustedCode(req.prompt)}`;
7785
8527
  cached: false,
7786
8528
  redactedPrompt
7787
8529
  };
7788
- cache.set(key, response);
8530
+ cache2.set(key, response);
7789
8531
  return response;
7790
8532
  }
7791
8533
  };
7792
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
+ }
7793
8554
  async function readBaseline(path) {
7794
8555
  try {
7795
8556
  const raw = JSON.parse(await readFile(path, "utf8"));
7796
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;
7797
8559
  return raw;
7798
8560
  } catch {
7799
8561
  return void 0;
7800
8562
  }
7801
8563
  }
7802
8564
  async function writeBaseline(path, findings) {
8565
+ const existing = await readBaseline(path);
7803
8566
  const doc = {
7804
8567
  version: 1,
7805
8568
  findings: findings.map((f) => ({
@@ -7807,7 +8570,8 @@ async function writeBaseline(path, findings) {
7807
8570
  ruleId: f.ruleId,
7808
8571
  file: f.file,
7809
8572
  line: f.line
7810
- }))
8573
+ })),
8574
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
7811
8575
  };
7812
8576
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
7813
8577
  `, "utf8");
@@ -7817,6 +8581,30 @@ function applyBaseline(findings, baseline) {
7817
8581
  const known = new Set(baseline.findings.map((f) => f.id));
7818
8582
  return findings.filter((f) => !known.has(f.id));
7819
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
+ }
7820
8608
 
7821
8609
  // src/core/config.ts
7822
8610
  init_finding();
@@ -7825,14 +8613,27 @@ var DEFAULT_CONFIG = {
7825
8613
  modules: [...MODULE_NAMES],
7826
8614
  failOn: "high",
7827
8615
  ignore: ["dist/**", "**/*.test.ts"],
8616
+ defaultExcludes: true,
7828
8617
  baseline: ".fullshield-baseline.json",
7829
8618
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
7830
8619
  deps: { offline: false },
7831
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8620
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8621
+ sast: { maxFileBytes: 1024 * 1024 }
7832
8622
  };
7833
8623
  var ConfigError = class extends Error {
7834
8624
  };
7835
- 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
+ ]);
7836
8637
  function validate(raw, source) {
7837
8638
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
7838
8639
  throw new ConfigError(`${source}: config must be a JSON object`);
@@ -7934,6 +8735,32 @@ function validate(raw, source) {
7934
8735
  }
7935
8736
  out.ai = ai;
7936
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
+ }
7937
8764
  return out;
7938
8765
  }
7939
8766
  async function loadConfig(cwd, configPath) {
@@ -7960,10 +8787,26 @@ async function loadConfig(cwd, configPath) {
7960
8787
  return { ...structuredClone(DEFAULT_CONFIG), ...validate(raw, file) };
7961
8788
  }
7962
8789
  var ignoreFactory = ignoreModule.default;
7963
- 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
+ ];
7964
8804
  async function walkFiles(options) {
7965
8805
  const ig = ignoreFactory();
7966
- ig.add(ALWAYS_IGNORED);
8806
+ ig.add(HARD_IGNORED);
8807
+ if (options.defaultExcludes !== false) {
8808
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8809
+ }
7967
8810
  try {
7968
8811
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
7969
8812
  } catch {
@@ -8012,23 +8855,6 @@ async function listStagedFiles(cwd) {
8012
8855
  // src/core/engine.ts
8013
8856
  init_suppression();
8014
8857
  init_finding();
8015
- function packageVersion() {
8016
- let dir = dirname(fileURLToPath(import.meta.url));
8017
- for (let depth = 0; depth < 6; depth += 1) {
8018
- try {
8019
- const raw = readFileSync(join(dir, "package.json"), "utf8");
8020
- const pkg = JSON.parse(raw);
8021
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
8022
- return pkg.version;
8023
- }
8024
- } catch {
8025
- }
8026
- const parent = dirname(dir);
8027
- if (parent === dir) break;
8028
- dir = parent;
8029
- }
8030
- throw new Error("fullshield package.json not found");
8031
- }
8032
8858
 
8033
8859
  // src/policy/index.ts
8034
8860
  init_finding();
@@ -8198,7 +9024,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8198
9024
  return "*";
8199
9025
  }
8200
9026
  }
8201
- function expiresAtMs(expires) {
9027
+ function expiresAtMs2(expires) {
8202
9028
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8203
9029
  return Date.parse(`${expires}T23:59:59.999Z`);
8204
9030
  }
@@ -8220,7 +9046,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8220
9046
  });
8221
9047
  const suppressed = /* @__PURE__ */ new Set();
8222
9048
  for (const exception of policy.exceptions ?? []) {
8223
- if (expiresAtMs(exception.expires) < now.getTime()) {
9049
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8224
9050
  errors.push(
8225
9051
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8226
9052
  );
@@ -8289,7 +9115,12 @@ async function runScan(options = {}, modules = builtinModules) {
8289
9115
  options.modules ?? config.modules,
8290
9116
  loadedPolicy?.requiredModules
8291
9117
  );
8292
- 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
+ });
8293
9124
  if (options.staged === true) {
8294
9125
  const staged = new Set(await listStagedFiles(cwd));
8295
9126
  files = files.filter((file) => staged.has(file));
@@ -8323,6 +9154,7 @@ async function runScan(options = {}, modules = builtinModules) {
8323
9154
  );
8324
9155
  }
8325
9156
  const extraHits = [];
9157
+ let historyNotice;
8326
9158
  const secretsWanted = modulesRequested.includes("secrets");
8327
9159
  if (secretsAdvEnabled() && secretsWanted) {
8328
9160
  if (options.history === true) {
@@ -8330,6 +9162,7 @@ async function runScan(options = {}, modules = builtinModules) {
8330
9162
  const hist = await scanGitHistory2(cwd);
8331
9163
  extraHits.push(...hist.hits);
8332
9164
  collected.push(...hist.findings);
9165
+ historyNotice = hist.history;
8333
9166
  }
8334
9167
  if (options.bundle !== void 0 && options.bundle.length > 0) {
8335
9168
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -8350,7 +9183,11 @@ async function runScan(options = {}, modules = builtinModules) {
8350
9183
  if (options.updateBaseline === true) {
8351
9184
  await writeBaseline(baselinePath, unsuppressed);
8352
9185
  }
8353
- 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;
8354
9191
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
8355
9192
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
8356
9193
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -8422,10 +9259,12 @@ async function runScan(options = {}, modules = builtinModules) {
8422
9259
  cwd,
8423
9260
  filesScanned: files.length,
8424
9261
  modulesRequested: [...modulesRequested],
8425
- modulesRun
9262
+ modulesRun,
9263
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
8426
9264
  },
8427
9265
  config,
8428
- policy: policyResult
9266
+ policy: policyResult,
9267
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
8429
9268
  };
8430
9269
  }
8431
9270
  function scan(options = {}) {
@@ -8761,6 +9600,27 @@ init_suppression();
8761
9600
 
8762
9601
  // src/reporters/console.ts
8763
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
8764
9624
  var SEVERITY_COLOR = {
8765
9625
  critical: (t) => pc.bold(pc.red(t)),
8766
9626
  high: (t) => pc.red(t),
@@ -8800,7 +9660,8 @@ function renderFinding(finding3) {
8800
9660
  }
8801
9661
  return lines;
8802
9662
  }
8803
- function renderConsoleReport(result) {
9663
+ function renderConsoleReport(input) {
9664
+ const result = redactScan(input);
8804
9665
  const { findings, summary, metadata } = result;
8805
9666
  const lines = [];
8806
9667
  lines.push(
@@ -8808,6 +9669,7 @@ function renderConsoleReport(result) {
8808
9669
  ` \u2014 scanned ${metadata.filesScanned} files in ${metadata.durationMs}ms (modules: ${metadata.modulesRun.length > 0 ? metadata.modulesRun.join(", ") : "none"})`
8809
9670
  )
8810
9671
  );
9672
+ if (metadata.history !== void 0) lines.push(metadata.history);
8811
9673
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
8812
9674
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
8813
9675
  lines.push(
@@ -8815,15 +9677,23 @@ function renderConsoleReport(result) {
8815
9677
  );
8816
9678
  }
8817
9679
  const bySeverityDesc = [...SEVERITIES].reverse();
9680
+ const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
9681
+ const shown = ordered.slice(0, 10);
9682
+ const hidden = ordered.length - shown.length;
8818
9683
  for (const severity of bySeverityDesc) {
8819
- const group = findings.filter((f) => f.severity === severity);
9684
+ const group = shown.filter((f) => f.severity === severity);
8820
9685
  if (group.length === 0) continue;
8821
9686
  lines.push("");
8822
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${group.length})`));
9687
+ lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
8823
9688
  for (const finding3 of group) {
8824
9689
  lines.push(...renderFinding(finding3));
9690
+ lines.push(` docs: rules/${finding3.ruleId}.md`);
8825
9691
  }
8826
9692
  }
9693
+ if (hidden > 0) {
9694
+ lines.push("");
9695
+ lines.push(`${hidden} more \u2014 use --json`);
9696
+ }
8827
9697
  lines.push("");
8828
9698
  if (summary.total === 0) {
8829
9699
  lines.push(pc.green("\u2714 No findings."));
@@ -8843,7 +9713,8 @@ function location2(finding3) {
8843
9713
  if (finding3.file === void 0) return "(project)";
8844
9714
  return finding3.line !== void 0 ? `${finding3.file}:${finding3.line}` : finding3.file;
8845
9715
  }
8846
- function renderHtmlReport(result) {
9716
+ function renderHtmlReport(input) {
9717
+ const result = redactScan(input);
8847
9718
  const sections = [];
8848
9719
  const bySeverityDesc = [...SEVERITIES].reverse();
8849
9720
  for (const severity of bySeverityDesc) {
@@ -8890,7 +9761,7 @@ ${items}`);
8890
9761
 
8891
9762
  // src/reporters/json.ts
8892
9763
  function publicFinding(finding3) {
8893
- const copy = { ...finding3 };
9764
+ const copy = redactFinding(finding3);
8894
9765
  delete copy.aiVerdict;
8895
9766
  delete copy.exploitScenario;
8896
9767
  delete copy.aiReasoning;
@@ -8914,7 +9785,8 @@ function location3(finding3) {
8914
9785
  if (finding3.file === void 0) return "(project)";
8915
9786
  return finding3.line !== void 0 ? `${finding3.file}:${finding3.line}` : finding3.file;
8916
9787
  }
8917
- function renderMarkdownReport(result) {
9788
+ function renderMarkdownReport(input) {
9789
+ const result = redactScan(input);
8918
9790
  const lines = [
8919
9791
  "# FullShield report",
8920
9792
  "",
@@ -8966,7 +9838,8 @@ function sarifLevel(severity) {
8966
9838
  function uri(file) {
8967
9839
  return (file ?? "").replace(/\\/g, "/");
8968
9840
  }
8969
- function renderSarifReport(result) {
9841
+ function renderSarifReport(input) {
9842
+ const result = redactScan(input);
8970
9843
  const rules = /* @__PURE__ */ new Map();
8971
9844
  for (const finding3 of result.findings) {
8972
9845
  if (!rules.has(finding3.ruleId)) {
@@ -9073,6 +9946,257 @@ function validateSarif21(doc) {
9073
9946
  return errors;
9074
9947
  }
9075
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
+
9076
10200
  // src/sbom/cyclonedx.ts
9077
10201
  function validateCycloneDx15(doc) {
9078
10202
  const errors = [];
@@ -9299,4 +10423,4 @@ function buildVex(findings, format, meta = { name: "app", version: "0.0.0" }) {
9299
10423
  return buildOpenVex(findings);
9300
10424
  }
9301
10425
 
9302
- 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 };