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/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'crypto';
3
- import { writeFile, mkdir, readFile, stat, mkdtemp, unlink, readdir } from 'fs/promises';
3
+ import { writeFile, readFile, mkdir, stat, mkdtemp, unlink, readdir } from 'fs/promises';
4
4
  import { resolve, join, dirname, relative, basename, posix } from 'path';
5
5
  import { readFileSync, existsSync, readdirSync, cpSync, mkdirSync, rmSync, statSync, writeFileSync, mkdtempSync, unlinkSync } from 'fs';
6
6
  import { homedir, tmpdir, availableParallelism } from 'os';
@@ -113,7 +113,8 @@ function walk(node, visit) {
113
113
  }
114
114
  }
115
115
  function attachParents(node, parent) {
116
- node.parent = parent;
116
+ const current = node;
117
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
117
118
  for (const key of Object.keys(node)) {
118
119
  if (SKIP_KEYS.has(String(key))) continue;
119
120
  const value = node[key];
@@ -192,8 +193,18 @@ function mcpCfgEnabled() {
192
193
  function taintV2Enabled() {
193
194
  return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
194
195
  }
196
+ function licenseEnabled() {
197
+ return process.env[LICENSE_FLAG] === "1";
198
+ }
199
+ function diffEnabled() {
200
+ return process.env[DIFF_FLAG] === "1";
201
+ }
202
+ var CI_REPORTS_FLAG, DIFF_FLAG, LICENSE_FLAG;
195
203
  var init_experimental = __esm({
196
204
  "src/modules/sast/experimental.ts"() {
205
+ CI_REPORTS_FLAG = "FULLSHIELD_EXPERIMENTAL_CI_REPORTS";
206
+ DIFF_FLAG = "FULLSHIELD_EXPERIMENTAL_DIFF";
207
+ LICENSE_FLAG = "FULLSHIELD_EXPERIMENTAL_LICENSE";
197
208
  }
198
209
  });
199
210
  function isSeverity(value) {
@@ -1267,6 +1278,29 @@ function shortestDependencyPath(target, starts, edges) {
1267
1278
  }
1268
1279
  return void 0;
1269
1280
  }
1281
+ function collectDependencyPaths(target, starts, edges, limit = 8) {
1282
+ const out = [];
1283
+ const queue = [];
1284
+ let steps = 0;
1285
+ for (const start of starts) queue.push([start]);
1286
+ while (queue.length > 0 && out.length < limit && steps < 4e3) {
1287
+ steps += 1;
1288
+ const path = queue.shift();
1289
+ if (path === void 0) continue;
1290
+ const last = path[path.length - 1];
1291
+ if (last === void 0) continue;
1292
+ if (last === target) {
1293
+ if (path.length >= 2) out.push(path);
1294
+ continue;
1295
+ }
1296
+ if (path.length >= 12) continue;
1297
+ for (const next of edges.get(last) ?? []) {
1298
+ if (path.includes(next)) continue;
1299
+ queue.push([...path, next]);
1300
+ }
1301
+ }
1302
+ return out;
1303
+ }
1270
1304
  function reachableFrom(target, roots, edges) {
1271
1305
  if (roots.has(target)) return true;
1272
1306
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -1287,9 +1321,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
1287
1321
  }
1288
1322
  if (graph !== void 0 && graph.edgesKnown) {
1289
1323
  const roots = importedDirectNames(productionUsage, graph);
1290
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
1324
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
1325
+ const path = paths[0];
1291
1326
  if (path !== void 0 && path.length >= 2) {
1292
- return { reachability: "reachable-via-dependency", path };
1327
+ return { reachability: "reachable-via-dependency", path, paths };
1293
1328
  }
1294
1329
  return { reachability: "not-imported" };
1295
1330
  }
@@ -1319,8 +1354,99 @@ function isDevOnly(pkgName, ctx) {
1319
1354
  if (inProd) return false;
1320
1355
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
1321
1356
  }
1357
+ function versionFromSnippet(snippet) {
1358
+ const name = packageNameFromSnippet(snippet);
1359
+ if (name === void 0) return snippet;
1360
+ return snippet.slice(name.length + 1);
1361
+ }
1362
+ function pathFields(classified) {
1363
+ const paths = classified.paths ?? (classified.path !== void 0 ? [classified.path] : []);
1364
+ if (paths.length === 0) return {};
1365
+ return {
1366
+ dependencyPath: paths[0],
1367
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
1368
+ };
1369
+ }
1370
+ function appendOtherPaths(message, paths) {
1371
+ if (paths === void 0 || paths.length < 2) return message;
1372
+ const rest = paths.slice(1).map((p) => p.join(" > ")).join("; ");
1373
+ return `${message} Other paths: ${rest}.`;
1374
+ }
1375
+ function groupPackageAdvisories(findings) {
1376
+ const passthrough = [];
1377
+ const groups = /* @__PURE__ */ new Map();
1378
+ for (const finding6 of findings) {
1379
+ if (finding6.ruleId !== "DEPS-VULN-001" && finding6.ruleId !== "DEPS-MALICIOUS-001") {
1380
+ passthrough.push(finding6);
1381
+ continue;
1382
+ }
1383
+ const name = packageNameFromSnippet(finding6.snippet ?? "") ?? finding6.snippet ?? finding6.id;
1384
+ const advisory = finding6.references?.[0] ?? finding6.id;
1385
+ const key = `${finding6.ruleId}\0${name}\0${advisory}`;
1386
+ const list = groups.get(key);
1387
+ if (list === void 0) groups.set(key, [finding6]);
1388
+ else list.push(finding6);
1389
+ }
1390
+ const merged = [];
1391
+ for (const list of groups.values()) {
1392
+ const only = list[0];
1393
+ if (list.length === 1 && only !== void 0) {
1394
+ merged.push(only);
1395
+ continue;
1396
+ }
1397
+ merged.push(mergeAdvisory(list));
1398
+ }
1399
+ return [...passthrough, ...merged];
1400
+ }
1401
+ function mergeAdvisory(list) {
1402
+ const ranked = [...list].sort(
1403
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || (REACH_RANK[a.reachability ?? "not-imported"] ?? 9) - (REACH_RANK[b.reachability ?? "not-imported"] ?? 9)
1404
+ );
1405
+ const primary = ranked[0];
1406
+ if (primary === void 0) {
1407
+ throw new Error("empty advisory group");
1408
+ }
1409
+ const name = packageNameFromSnippet(primary.snippet ?? "") ?? "package";
1410
+ const versions = [...new Set(list.map((f) => versionFromSnippet(f.snippet ?? "")))].filter((v) => v.length > 0).sort();
1411
+ const paths = [];
1412
+ const seenPath = /* @__PURE__ */ new Set();
1413
+ for (const finding6 of list) {
1414
+ const candidates = finding6.dependencyPaths ?? (finding6.dependencyPath !== void 0 ? [finding6.dependencyPath] : []);
1415
+ for (const path of candidates) {
1416
+ const key = path.join("\0");
1417
+ if (seenPath.has(key)) continue;
1418
+ seenPath.add(key);
1419
+ paths.push(path);
1420
+ }
1421
+ }
1422
+ paths.sort((a, b) => a.length - b.length || a.join(">").localeCompare(b.join(">")));
1423
+ const advisory = primary.references?.[0] ?? "";
1424
+ const onManifest = list.find((f) => f.file === "package.json");
1425
+ const file = onManifest?.file ?? primary.file;
1426
+ const line = onManifest?.line ?? primary.line;
1427
+ const snippet = versions.length > 0 ? `${name}@${versions.join(",")}` : primary.snippet ?? name;
1428
+ let message = primary.message;
1429
+ if (versions.length > 1) {
1430
+ message = `${message} Versions: ${versions.join(", ")}.`;
1431
+ }
1432
+ if (paths.length > 1) {
1433
+ message = `${message} Dependency paths: ${paths.map((p) => p.join(" > ")).join("; ")}.`;
1434
+ }
1435
+ return {
1436
+ ...primary,
1437
+ id: makeFindingId(primary.ruleId, file, `${name}|${advisory}|${versions.join(",")}`),
1438
+ file,
1439
+ line,
1440
+ snippet,
1441
+ message,
1442
+ severity: primary.severity,
1443
+ reachability: primary.reachability,
1444
+ ...paths[0] !== void 0 ? { dependencyPath: paths[0] } : {},
1445
+ ...paths.length > 1 ? { dependencyPaths: paths } : {}
1446
+ };
1447
+ }
1322
1448
  function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1323
- return findings.map((finding6) => {
1449
+ const mapped = findings.map((finding6) => {
1324
1450
  if (finding6.ruleId === "DEPS-MALICIOUS-001") {
1325
1451
  if (finding6.snippet === void 0) return finding6;
1326
1452
  const pkg2 = packageNameFromSnippet(finding6.snippet);
@@ -1330,7 +1456,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1330
1456
  ...finding6,
1331
1457
  severity: "critical",
1332
1458
  reachability: classified2.reachability,
1333
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
1459
+ ...pathFields(classified2)
1334
1460
  };
1335
1461
  }
1336
1462
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
@@ -1340,7 +1466,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1340
1466
  const named = symbols.get(finding6.id) ?? [];
1341
1467
  if (named.length > 0 && ctx.sources !== void 0) {
1342
1468
  const symbolClass = classifySymbolUse(named, ctx.sources);
1343
- classified = { reachability: symbolClass, path: classified.path };
1469
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
1344
1470
  }
1345
1471
  const steps = reachabilitySteps(classified.reachability);
1346
1472
  let severity = steps === 0 ? finding6.severity : downgradeSeverity(finding6.severity, steps);
@@ -1348,6 +1474,7 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1348
1474
  if (classified.path !== void 0) {
1349
1475
  const shown = classified.path.join(" > ");
1350
1476
  message = `${message} Reachable via ${shown}.`;
1477
+ message = appendOtherPaths(message, classified.paths);
1351
1478
  }
1352
1479
  const devOnly = isDevOnly(pkg, ctx);
1353
1480
  if (devOnly) {
@@ -1357,16 +1484,27 @@ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1357
1484
  return {
1358
1485
  ...finding6,
1359
1486
  reachability: classified.reachability,
1360
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
1487
+ ...pathFields(classified),
1361
1488
  severity,
1362
1489
  message
1363
1490
  };
1364
1491
  });
1492
+ return groupPackageAdvisories(mapped);
1365
1493
  }
1494
+ var REACH_RANK;
1366
1495
  var init_reachability = __esm({
1367
1496
  "src/modules/deps/reachability.ts"() {
1368
1497
  init_finding();
1369
1498
  init_symbol_reachability();
1499
+ REACH_RANK = {
1500
+ reachable: 0,
1501
+ "symbol-reachable": 1,
1502
+ "reachable-via-dependency": 2,
1503
+ "imported-not-called": 3,
1504
+ "symbol-not-called": 4,
1505
+ "reachability-unknown": 5,
1506
+ "not-imported": 6
1507
+ };
1370
1508
  }
1371
1509
  });
1372
1510
 
@@ -3294,6 +3432,62 @@ var init_experimental2 = __esm({
3294
3432
  "src/supply/experimental.ts"() {
3295
3433
  }
3296
3434
  });
3435
+ function isCopyleftLicense(license) {
3436
+ return COPYLEFT.test(license);
3437
+ }
3438
+ function licenseText(value) {
3439
+ if (typeof value === "string") return value;
3440
+ if (typeof value === "object" && value !== null && "type" in value) {
3441
+ const type = value.type;
3442
+ if (typeof type === "string") return type;
3443
+ }
3444
+ return void 0;
3445
+ }
3446
+ async function readJson(path) {
3447
+ try {
3448
+ return JSON.parse(await readFile(path, "utf8"));
3449
+ } catch {
3450
+ return void 0;
3451
+ }
3452
+ }
3453
+ async function scanCopyleftLicenses(cwd) {
3454
+ const manifest = await readJson(join(cwd, "package.json"));
3455
+ if (typeof manifest !== "object" || manifest === null) return [];
3456
+ const names = /* @__PURE__ */ new Set();
3457
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
3458
+ const block = manifest[field];
3459
+ if (typeof block === "object" && block !== null && !Array.isArray(block)) {
3460
+ for (const name of Object.keys(block)) names.add(name);
3461
+ }
3462
+ }
3463
+ const findings = [];
3464
+ for (const name of names) {
3465
+ const pkg = await readJson(join(cwd, "node_modules", name, "package.json"));
3466
+ if (typeof pkg !== "object" || pkg === null) continue;
3467
+ const license = licenseText(pkg.license);
3468
+ if (license === void 0 || !isCopyleftLicense(license)) continue;
3469
+ findings.push({
3470
+ id: makeFindingId("DEPS-LICENSE-001", "package.json", `${name}|${license}`),
3471
+ ruleId: "DEPS-LICENSE-001",
3472
+ module: "deps",
3473
+ title: `Copyleft dependency ${name}`,
3474
+ severity: "medium",
3475
+ confidence: "high",
3476
+ file: "package.json",
3477
+ snippet: `${name} ${license}`,
3478
+ message: `${name} is licensed ${license}.`,
3479
+ remediation: "Replace the dependency or get a legal review before shipping it in a commercial product."
3480
+ });
3481
+ }
3482
+ return findings;
3483
+ }
3484
+ var COPYLEFT;
3485
+ var init_license = __esm({
3486
+ "src/modules/deps/license.ts"() {
3487
+ init_finding();
3488
+ COPYLEFT = /(?:^|[^\w])(?:AGPL|GPL)(?:-|[^\w]|$)/i;
3489
+ }
3490
+ });
3297
3491
 
3298
3492
  // src/fixer/eol.ts
3299
3493
  function detectEol(text) {
@@ -3459,7 +3653,7 @@ function walkJs(dir, out = []) {
3459
3653
  }
3460
3654
  return out;
3461
3655
  }
3462
- function readJson(path) {
3656
+ function readJson2(path) {
3463
3657
  return JSON.parse(readFileSync(path, "utf8"));
3464
3658
  }
3465
3659
  function installScriptKeys(scripts) {
@@ -3470,7 +3664,7 @@ function analyzePackageDir(dir) {
3470
3664
  const findings = [];
3471
3665
  const manifestPath = join(dir, "package.json");
3472
3666
  if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) return findings;
3473
- const pkg = readJson(manifestPath);
3667
+ const pkg = readJson2(manifestPath);
3474
3668
  const scripts = pkg.scripts ?? {};
3475
3669
  const keys = installScriptKeys(scripts);
3476
3670
  if (keys.length > 0) {
@@ -3520,8 +3714,8 @@ function analyzePackageDir(dir) {
3520
3714
  return findings;
3521
3715
  }
3522
3716
  function diffPackageVersions(oldDir, newDir) {
3523
- const oldManifest = existsSync(join(oldDir, "package.json")) ? readJson(join(oldDir, "package.json")) : {};
3524
- const nextManifest = existsSync(join(newDir, "package.json")) ? readJson(join(newDir, "package.json")) : {};
3717
+ const oldManifest = existsSync(join(oldDir, "package.json")) ? readJson2(join(oldDir, "package.json")) : {};
3718
+ const nextManifest = existsSync(join(newDir, "package.json")) ? readJson2(join(newDir, "package.json")) : {};
3525
3719
  const oldScripts = oldManifest.scripts ?? {};
3526
3720
  const nextScripts = nextManifest.scripts ?? {};
3527
3721
  const added = INSTALL_SCRIPT_KEYS.filter((k) => typeof nextScripts[k] === "string" && oldScripts[k] === void 0);
@@ -3562,14 +3756,24 @@ function checkLockfileIntegrity(lock) {
3562
3756
  if (name === "") continue;
3563
3757
  const resolved = entry.resolved;
3564
3758
  if (!resolved) continue;
3759
+ if (entry.integrity === void 0 || entry.integrity.length === 0) {
3760
+ findings.push(
3761
+ finding(
3762
+ "SUPPLY-LOCKINTEGRITY-001",
3763
+ "Lockfile entry has no integrity hash",
3764
+ `${name} resolved without an integrity hash`,
3765
+ "package-lock.json"
3766
+ )
3767
+ );
3768
+ }
3565
3769
  try {
3566
- const host = new URL(resolved).hostname;
3567
- if (host !== REGISTRY_HOST) {
3770
+ const url = new URL(resolved);
3771
+ if (url.protocol !== "https:" || url.hostname !== REGISTRY_HOST) {
3568
3772
  findings.push(
3569
3773
  finding(
3570
3774
  "SUPPLY-LOCKURL-001",
3571
3775
  "Lockfile entry resolves from a non-npm registry host",
3572
- `${name} resolved host ${host}`,
3776
+ `${name} resolved host ${url.hostname || resolved}`,
3573
3777
  "package-lock.json"
3574
3778
  )
3575
3779
  );
@@ -3632,7 +3836,7 @@ function packageSources(dir) {
3632
3836
  let scripts = "";
3633
3837
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
3634
3838
  try {
3635
- const pkg = readJson(manifestPath);
3839
+ const pkg = readJson2(manifestPath);
3636
3840
  const block = pkg.scripts ?? {};
3637
3841
  scripts = Object.values(block).join("\n");
3638
3842
  } catch {
@@ -3945,6 +4149,8 @@ var init_deps = __esm({
3945
4149
  init_semver();
3946
4150
  init_typosquat();
3947
4151
  init_experimental2();
4152
+ init_experimental();
4153
+ init_license();
3948
4154
  init_phase31();
3949
4155
  LOCKFILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
3950
4156
  MANIFEST_FIELDS2 = ["dependencies", "devDependencies", "optionalDependencies"];
@@ -4026,6 +4232,7 @@ var init_deps = __esm({
4026
4232
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4027
4233
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
4028
4234
  }
4235
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
4029
4236
  return reached;
4030
4237
  }
4031
4238
  };
@@ -4939,7 +5146,7 @@ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
4939
5146
  }
4940
5147
  return changed;
4941
5148
  }
4942
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5149
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
4943
5150
  attachParents(ast);
4944
5151
  const facts = /* @__PURE__ */ new WeakMap();
4945
5152
  const functions = [];
@@ -4953,7 +5160,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4953
5160
  }
4954
5161
  const seeds = /* @__PURE__ */ new Map();
4955
5162
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
4956
- const returners = new Set(extraReturners);
5163
+ const returners = new Set(extraReturners2);
4957
5164
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
4958
5165
  let changed = false;
4959
5166
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -6944,6 +7151,44 @@ var init_injection = __esm({
6944
7151
  PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
6945
7152
  }
6946
7153
  });
7154
+
7155
+ // src/runtime/lru.ts
7156
+ var LruMap;
7157
+ var init_lru = __esm({
7158
+ "src/runtime/lru.ts"() {
7159
+ LruMap = class {
7160
+ constructor(max) {
7161
+ this.max = max;
7162
+ if (max < 1) throw new Error("LruMap max must be >= 1");
7163
+ }
7164
+ max;
7165
+ map = /* @__PURE__ */ new Map();
7166
+ get size() {
7167
+ return this.map.size;
7168
+ }
7169
+ has(key) {
7170
+ return this.map.has(key);
7171
+ }
7172
+ get(key) {
7173
+ if (!this.map.has(key)) return void 0;
7174
+ const value = this.map.get(key);
7175
+ this.map.delete(key);
7176
+ this.map.set(key, value);
7177
+ return value;
7178
+ }
7179
+ set(key, value) {
7180
+ if (this.map.has(key)) this.map.delete(key);
7181
+ this.map.set(key, value);
7182
+ while (this.map.size > this.max) {
7183
+ const oldest = this.map.keys().next().value;
7184
+ if (oldest === void 0) break;
7185
+ this.map.delete(oldest);
7186
+ }
7187
+ return this;
7188
+ }
7189
+ };
7190
+ }
7191
+ });
6947
7192
  function packageVersion() {
6948
7193
  let dir = dirname(fileURLToPath(import.meta.url));
6949
7194
  for (let depth = 0; depth < 6; depth += 1) {
@@ -6972,9 +7217,15 @@ function parsePoolSize() {
6972
7217
  const raw = process.env.FULLSHIELD_PARSE_WORKERS;
6973
7218
  if (raw !== void 0 && raw !== "") {
6974
7219
  const parsed = Number(raw);
6975
- if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7220
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
6976
7221
  }
6977
- return Math.max(1, availableParallelism() - 1);
7222
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
7223
+ }
7224
+ function workerMemoryMb() {
7225
+ const raw = process.env.FULLSHIELD_PARSE_WORKER_MB;
7226
+ const parsed = raw !== void 0 && raw !== "" ? Number(raw) : 512;
7227
+ if (Number.isFinite(parsed) && parsed >= 64) return Math.floor(parsed);
7228
+ return 512;
6978
7229
  }
6979
7230
  function parseCacheKey(code) {
6980
7231
  return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
@@ -6984,12 +7235,37 @@ function parseOne(code) {
6984
7235
  const hit = cache.get(key);
6985
7236
  if (hit !== void 0) return Promise.resolve(hit);
6986
7237
  return new Promise((resolve8, reject) => {
6987
- const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
6988
- worker.once("message", (ast) => {
7238
+ const worker = new Worker(fileURLToPath(workerUrl), {
7239
+ workerData: { code },
7240
+ resourceLimits: { maxOldGenerationSizeMb: workerMemoryMb() }
7241
+ });
7242
+ let settled = false;
7243
+ const finish = () => {
7244
+ void worker.terminate();
7245
+ };
7246
+ const succeed = (ast) => {
7247
+ if (settled) return;
7248
+ settled = true;
6989
7249
  cache.set(key, ast);
7250
+ finish();
6990
7251
  resolve8(ast);
7252
+ };
7253
+ const fail = (error) => {
7254
+ if (settled) return;
7255
+ settled = true;
7256
+ finish();
7257
+ reject(error);
7258
+ };
7259
+ worker.once("message", (ast) => {
7260
+ succeed(ast);
7261
+ });
7262
+ worker.once("error", (error) => {
7263
+ fail(error);
7264
+ });
7265
+ worker.once("exit", (code2) => {
7266
+ if (settled) return;
7267
+ fail(new Error(`parse worker exited ${code2 ?? "null"}`));
6991
7268
  });
6992
- worker.once("error", reject);
6993
7269
  });
6994
7270
  }
6995
7271
  async function parseWithPool(codes) {
@@ -7013,9 +7289,10 @@ async function parseWithPool(codes) {
7013
7289
  var workerUrl, cache;
7014
7290
  var init_parse_pool = __esm({
7015
7291
  "src/core/parse-pool.ts"() {
7292
+ init_lru();
7016
7293
  init_version();
7017
7294
  workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7018
- cache = /* @__PURE__ */ new Map();
7295
+ cache = new LruMap(64);
7019
7296
  }
7020
7297
  });
7021
7298
  async function parseSource(code) {
@@ -7033,8 +7310,8 @@ function snippetOf(source, node) {
7033
7310
  if (node.range === void 0) return "";
7034
7311
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
7035
7312
  }
7036
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7037
- const facts = analyzeFile(ast, extraReturners);
7313
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7314
+ const facts = analyzeFile(ast, extraReturners2);
7038
7315
  const findings = [];
7039
7316
  for (const rule of sastRules) {
7040
7317
  const ctx = {
@@ -7112,6 +7389,158 @@ function findingsFromTemplate(filename, source) {
7112
7389
  xssRule.create(ctx);
7113
7390
  return findings;
7114
7391
  }
7392
+ function dedupeSastSinks(findings) {
7393
+ const seen = /* @__PURE__ */ new Set();
7394
+ const out = [];
7395
+ for (const finding6 of findings) {
7396
+ if (finding6.module !== "sast" || finding6.line === void 0) {
7397
+ out.push(finding6);
7398
+ continue;
7399
+ }
7400
+ const key = `${finding6.ruleId}\0${finding6.file ?? ""}\0${finding6.line}`;
7401
+ if (seen.has(key)) continue;
7402
+ seen.add(key);
7403
+ out.push(finding6);
7404
+ }
7405
+ return out;
7406
+ }
7407
+ function sastStreamThreshold() {
7408
+ const raw = process.env.FULLSHIELD_SAST_STREAM_AT;
7409
+ if (raw !== void 0 && raw !== "") {
7410
+ const parsed = Number(raw);
7411
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7412
+ }
7413
+ return 4e3;
7414
+ }
7415
+ function sastBatchSize() {
7416
+ const raw = process.env.FULLSHIELD_SAST_BATCH;
7417
+ if (raw !== void 0 && raw !== "") {
7418
+ const parsed = Number(raw);
7419
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7420
+ }
7421
+ return 150;
7422
+ }
7423
+ function skipFinding(rel, message) {
7424
+ return {
7425
+ id: makeFindingId("SAST-SKIP-001", rel, message),
7426
+ ruleId: "SAST-SKIP-001",
7427
+ module: "sast",
7428
+ title: "File skipped by SAST",
7429
+ severity: "info",
7430
+ confidence: "high",
7431
+ file: rel,
7432
+ message,
7433
+ remediation: "Raise sast.maxFileBytes in fullshield.config.json to parse this file, or leave it skipped."
7434
+ };
7435
+ }
7436
+ function skipReason(source, maxBytes) {
7437
+ if (source.includes("\0")) return "it contains NUL bytes";
7438
+ const bytes = Buffer.byteLength(source);
7439
+ if (bytes > maxBytes) return `${bytes} bytes exceeds the SAST parse limit of ${maxBytes} bytes`;
7440
+ return void 0;
7441
+ }
7442
+ async function readScanSource(cwd, rel) {
7443
+ try {
7444
+ return await readFile(join(cwd, rel), "utf8");
7445
+ } catch {
7446
+ return void 0;
7447
+ }
7448
+ }
7449
+ function cloneAst(value) {
7450
+ if (value === null || typeof value !== "object") return void 0;
7451
+ try {
7452
+ return structuredClone(value);
7453
+ } catch {
7454
+ return void 0;
7455
+ }
7456
+ }
7457
+ async function parseInProcess(source) {
7458
+ try {
7459
+ return await parseSource(source);
7460
+ } catch {
7461
+ return void 0;
7462
+ }
7463
+ }
7464
+ async function parseCode(source) {
7465
+ if (!parsePoolEnabled()) return parseInProcess(source);
7466
+ try {
7467
+ const cloned = cloneAst((await parseWithPool([source]))[0]);
7468
+ if (cloned !== void 0) return cloned;
7469
+ } catch {
7470
+ }
7471
+ return parseInProcess(source);
7472
+ }
7473
+ function releaseParsed(parsed) {
7474
+ for (const entry of parsed) {
7475
+ entry.source = "";
7476
+ entry.ast = void 0;
7477
+ }
7478
+ parsed.length = 0;
7479
+ }
7480
+ function extraReturners(rel, ast, files, returning) {
7481
+ const ns = collectRequires(rel, ast, files);
7482
+ const extra = new Set(returning.get(rel));
7483
+ for (const [local, spec] of ns) {
7484
+ const hash = spec.indexOf("#");
7485
+ const file = hash >= 0 ? spec.slice(0, hash) : spec;
7486
+ const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7487
+ if (returning.get(file)?.has(exp)) extra.add(local);
7488
+ }
7489
+ return extra;
7490
+ }
7491
+ async function runSastStreaming(ctx) {
7492
+ const maxBytes = ctx.config.sast.maxFileBytes;
7493
+ const findings = [];
7494
+ const returning = /* @__PURE__ */ new Map();
7495
+ const parseable = [];
7496
+ const fileSet = /* @__PURE__ */ new Set();
7497
+ let projectCsrf = false;
7498
+ for (const rel of ctx.files) {
7499
+ const isSource = SOURCE_EXT.test(rel);
7500
+ const isTemplate = TEMPLATE_EXT.test(rel);
7501
+ if (!isSource && !isTemplate) continue;
7502
+ const source = await readScanSource(ctx.cwd, rel);
7503
+ if (source === void 0) continue;
7504
+ const why = skipReason(source, maxBytes);
7505
+ if (why !== void 0) {
7506
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7507
+ continue;
7508
+ }
7509
+ if (sourceHasCsrfGuard(source)) projectCsrf = true;
7510
+ if (isTemplate) {
7511
+ findings.push(...findingsFromTemplate(rel, source));
7512
+ continue;
7513
+ }
7514
+ const ast = await parseCode(source);
7515
+ if (ast === void 0) continue;
7516
+ returning.set(rel, functionsThatReturnTaint(ast));
7517
+ parseable.push(rel);
7518
+ fileSet.add(rel);
7519
+ }
7520
+ for (const rel of parseable) {
7521
+ const source = await readScanSource(ctx.cwd, rel);
7522
+ if (source === void 0) continue;
7523
+ const ast = await parseCode(source);
7524
+ if (ast === void 0) continue;
7525
+ findings.push(
7526
+ ...findingsFromAst(rel, source, ast, extraReturners(rel, ast, fileSet, returning), projectCsrf)
7527
+ );
7528
+ }
7529
+ const batch = sastBatchSize();
7530
+ for (let i = 0; i < parseable.length; i += batch) {
7531
+ const parsed = [];
7532
+ for (const rel of parseable.slice(i, i + batch)) {
7533
+ const source = await readScanSource(ctx.cwd, rel);
7534
+ if (source === void 0) continue;
7535
+ const ast = await parseCode(source);
7536
+ if (ast === void 0) continue;
7537
+ parsed.push({ file: rel, source, ast });
7538
+ }
7539
+ findings.push(...runInterproc(parsed));
7540
+ releaseParsed(parsed);
7541
+ }
7542
+ return dedupeSastSinks(findings);
7543
+ }
7115
7544
  var sastRules, SOURCE_EXT, sastModule;
7116
7545
  var init_engine = __esm({
7117
7546
  "src/modules/sast/engine.ts"() {
@@ -7153,49 +7582,48 @@ var init_engine = __esm({
7153
7582
  sastModule = {
7154
7583
  name: "sast",
7155
7584
  async run(ctx) {
7585
+ const candidates = ctx.files.filter((rel) => SOURCE_EXT.test(rel) || TEMPLATE_EXT.test(rel));
7586
+ if (candidates.length > sastStreamThreshold()) return runSastStreaming(ctx);
7587
+ const maxBytes = ctx.config.sast.maxFileBytes;
7156
7588
  const findings = [];
7157
7589
  const parsed = [];
7158
- for (const rel of ctx.files) {
7159
- const isSource = SOURCE_EXT.test(rel);
7160
- const isTemplate = TEMPLATE_EXT.test(rel);
7161
- if (!isSource && !isTemplate) continue;
7162
- let source;
7163
- try {
7164
- source = await readFile(join(ctx.cwd, rel), "utf8");
7165
- } catch {
7590
+ for (const rel of candidates) {
7591
+ const source = await readScanSource(ctx.cwd, rel);
7592
+ if (source === void 0) continue;
7593
+ const why = skipReason(source, maxBytes);
7594
+ if (why !== void 0) {
7595
+ findings.push(skipFinding(rel, `Skipped ${rel}: ${why}.`));
7166
7596
  continue;
7167
7597
  }
7168
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
7169
- if (isTemplate) {
7598
+ if (TEMPLATE_EXT.test(rel)) {
7170
7599
  findings.push(...findingsFromTemplate(rel, source));
7171
7600
  continue;
7172
7601
  }
7173
- try {
7174
- const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
7175
- if (ast === void 0) continue;
7176
- parsed.push({ file: rel, source, ast });
7177
- } catch {
7178
- }
7602
+ const ast = await parseCode(source);
7603
+ if (ast === void 0) continue;
7604
+ parsed.push({ file: rel, source, ast });
7179
7605
  }
7180
7606
  const files = new Set(parsed.map((p) => p.file));
7181
7607
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
7182
7608
  const returning = /* @__PURE__ */ new Map();
7183
- for (const p of parsed) {
7184
- returning.set(p.file, functionsThatReturnTaint(p.ast));
7609
+ for (const entry of parsed) {
7610
+ returning.set(entry.file, functionsThatReturnTaint(entry.ast));
7185
7611
  }
7186
- for (const p of parsed) {
7187
- const ns = collectRequires(p.file, p.ast, files);
7188
- const extra = new Set(returning.get(p.file));
7189
- for (const [local, spec] of ns) {
7190
- const hash = spec.indexOf("#");
7191
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
7192
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
7193
- if (returning.get(file)?.has(exp)) extra.add(local);
7194
- }
7195
- findings.push(...findingsFromAst(p.file, p.source, p.ast, extra, projectCsrf));
7612
+ for (const entry of parsed) {
7613
+ findings.push(
7614
+ ...findingsFromAst(
7615
+ entry.file,
7616
+ entry.source,
7617
+ entry.ast,
7618
+ extraReturners(entry.file, entry.ast, files, returning),
7619
+ projectCsrf
7620
+ )
7621
+ );
7196
7622
  }
7197
7623
  findings.push(...runInterproc(parsed));
7198
- return findings;
7624
+ const out = dedupeSastSinks(findings);
7625
+ releaseParsed(parsed);
7626
+ return out;
7199
7627
  }
7200
7628
  };
7201
7629
  }
@@ -8121,16 +8549,37 @@ var init_provider = __esm({
8121
8549
  FIXTURE_COST = 0.01;
8122
8550
  }
8123
8551
  });
8552
+ function isIsoDate(value) {
8553
+ if (!/^\d{4}-\d{2}-\d{2}(T[\d:.+\-Z]+)?$/.test(value)) return false;
8554
+ return Number.isFinite(Date.parse(value));
8555
+ }
8556
+ function expiresAtMs(expires) {
8557
+ if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) return Date.parse(`${expires}T23:59:59.999Z`);
8558
+ return Date.parse(expires);
8559
+ }
8560
+ function validSuppression(value) {
8561
+ if (typeof value !== "object" || value === null) return void 0;
8562
+ const row = value;
8563
+ if (typeof row.ruleId !== "string" || row.ruleId.length === 0) return void 0;
8564
+ if (typeof row.owner !== "string" || row.owner.length === 0) return void 0;
8565
+ if (typeof row.reason !== "string" || row.reason.length === 0) return void 0;
8566
+ if (typeof row.expires !== "string" || !isIsoDate(row.expires)) return void 0;
8567
+ const file = row.file === void 0 ? void 0 : row.file.replace(/\\/g, "/");
8568
+ if (row.file !== void 0 && (typeof row.file !== "string" || row.file.length === 0)) return void 0;
8569
+ return { ruleId: row.ruleId, file, owner: row.owner, reason: row.reason, expires: row.expires };
8570
+ }
8124
8571
  async function readBaseline(path) {
8125
8572
  try {
8126
8573
  const raw = JSON.parse(await readFile(path, "utf8"));
8127
8574
  if (raw.version !== 1 || !Array.isArray(raw.findings)) return void 0;
8575
+ if (raw.suppressions !== void 0 && !Array.isArray(raw.suppressions)) return void 0;
8128
8576
  return raw;
8129
8577
  } catch {
8130
8578
  return void 0;
8131
8579
  }
8132
8580
  }
8133
8581
  async function writeBaseline(path, findings) {
8582
+ const existing = await readBaseline(path);
8134
8583
  const doc = {
8135
8584
  version: 1,
8136
8585
  findings: findings.map((f) => ({
@@ -8138,7 +8587,8 @@ async function writeBaseline(path, findings) {
8138
8587
  ruleId: f.ruleId,
8139
8588
  file: f.file,
8140
8589
  line: f.line
8141
- }))
8590
+ })),
8591
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
8142
8592
  };
8143
8593
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
8144
8594
  `, "utf8");
@@ -8148,6 +8598,30 @@ function applyBaseline(findings, baseline) {
8148
8598
  const known = new Set(baseline.findings.map((f) => f.id));
8149
8599
  return findings.filter((f) => !known.has(f.id));
8150
8600
  }
8601
+ function applySuppressions(findings, rows, now = /* @__PURE__ */ new Date()) {
8602
+ if (rows === void 0 || rows.length === 0) return { findings: [...findings], errors: [] };
8603
+ const errors = [];
8604
+ const active = [];
8605
+ rows.forEach((row, index) => {
8606
+ const parsed = validSuppression(row);
8607
+ if (parsed === void 0) {
8608
+ errors.push(`suppression ${index + 1} needs ruleId, owner, reason, and an ISO expires date`);
8609
+ return;
8610
+ }
8611
+ if (expiresAtMs(parsed.expires) < now.getTime()) {
8612
+ errors.push(
8613
+ `suppression for ${parsed.ruleId} expired on ${parsed.expires} (owner: ${parsed.owner}); renew or remove it`
8614
+ );
8615
+ return;
8616
+ }
8617
+ active.push(parsed);
8618
+ });
8619
+ const kept = findings.filter((finding6) => {
8620
+ const file = (finding6.file ?? "").replace(/\\/g, "/");
8621
+ return !active.some((row) => row.ruleId === finding6.ruleId && (row.file === void 0 || row.file === file));
8622
+ });
8623
+ return { findings: kept, errors };
8624
+ }
8151
8625
  var init_baseline = __esm({
8152
8626
  "src/core/baseline.ts"() {
8153
8627
  }
@@ -8253,6 +8727,32 @@ function validate(raw, source) {
8253
8727
  }
8254
8728
  out.ai = ai;
8255
8729
  }
8730
+ if (record.defaultExcludes !== void 0) {
8731
+ if (typeof record.defaultExcludes !== "boolean") {
8732
+ throw new ConfigError(`${source}: "defaultExcludes" must be a boolean`);
8733
+ }
8734
+ out.defaultExcludes = record.defaultExcludes;
8735
+ }
8736
+ if (record.excludes !== void 0) {
8737
+ if (!Array.isArray(record.excludes) || record.excludes.some((p) => typeof p !== "string")) {
8738
+ throw new ConfigError(`${source}: "excludes" must be an array of glob strings`);
8739
+ }
8740
+ out.excludes = record.excludes;
8741
+ }
8742
+ if (record.sast !== void 0) {
8743
+ if (typeof record.sast !== "object" || record.sast === null || Array.isArray(record.sast)) {
8744
+ throw new ConfigError(`${source}: "sast" must be an object`);
8745
+ }
8746
+ const rawSast = record.sast;
8747
+ const sast = { maxFileBytes: 1024 * 1024 };
8748
+ if (rawSast.maxFileBytes !== void 0) {
8749
+ if (typeof rawSast.maxFileBytes !== "number" || rawSast.maxFileBytes < 1) {
8750
+ throw new ConfigError(`${source}: "sast.maxFileBytes" must be a positive number`);
8751
+ }
8752
+ sast.maxFileBytes = rawSast.maxFileBytes;
8753
+ }
8754
+ out.sast = sast;
8755
+ }
8256
8756
  return out;
8257
8757
  }
8258
8758
  async function loadConfig(cwd, configPath) {
@@ -8287,19 +8787,35 @@ var init_config2 = __esm({
8287
8787
  modules: [...MODULE_NAMES],
8288
8788
  failOn: "high",
8289
8789
  ignore: ["dist/**", "**/*.test.ts"],
8790
+ defaultExcludes: true,
8290
8791
  baseline: ".fullshield-baseline.json",
8291
8792
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
8292
8793
  deps: { offline: false },
8293
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8794
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8795
+ sast: { maxFileBytes: 1024 * 1024 }
8294
8796
  };
8295
8797
  ConfigError = class extends Error {
8296
8798
  };
8297
- KNOWN_KEYS = /* @__PURE__ */ new Set(["modules", "failOn", "ignore", "baseline", "notify", "deps", "ai"]);
8799
+ KNOWN_KEYS = /* @__PURE__ */ new Set([
8800
+ "modules",
8801
+ "failOn",
8802
+ "ignore",
8803
+ "defaultExcludes",
8804
+ "excludes",
8805
+ "baseline",
8806
+ "notify",
8807
+ "deps",
8808
+ "ai",
8809
+ "sast"
8810
+ ]);
8298
8811
  }
8299
8812
  });
8300
8813
  async function walkFiles(options) {
8301
8814
  const ig = ignoreFactory();
8302
- ig.add(ALWAYS_IGNORED);
8815
+ ig.add(HARD_IGNORED);
8816
+ if (options.defaultExcludes !== false) {
8817
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8818
+ }
8303
8819
  try {
8304
8820
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
8305
8821
  } catch {
@@ -8325,11 +8841,24 @@ async function walkFiles(options) {
8325
8841
  await walk5("");
8326
8842
  return files.sort();
8327
8843
  }
8328
- var ignoreFactory, ALWAYS_IGNORED;
8844
+ var ignoreFactory, HARD_IGNORED, DEFAULT_EXCLUDES;
8329
8845
  var init_fileWalker = __esm({
8330
8846
  "src/core/fileWalker.ts"() {
8331
8847
  ignoreFactory = ignoreModule.default;
8332
- ALWAYS_IGNORED = [".git/", "node_modules/", ".fullshield/"];
8848
+ HARD_IGNORED = [".git/", ".fullshield/"];
8849
+ DEFAULT_EXCLUDES = [
8850
+ "node_modules/",
8851
+ "dist/",
8852
+ "build/",
8853
+ ".next/",
8854
+ "coverage/",
8855
+ "vendor/",
8856
+ "vendored/",
8857
+ "third_party/",
8858
+ "third-party/",
8859
+ "compiled/",
8860
+ "*.min.js"
8861
+ ];
8333
8862
  }
8334
8863
  });
8335
8864
  async function listStagedFiles(cwd) {
@@ -8543,7 +9072,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8543
9072
  return "*";
8544
9073
  }
8545
9074
  }
8546
- function expiresAtMs(expires) {
9075
+ function expiresAtMs2(expires) {
8547
9076
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8548
9077
  return Date.parse(`${expires}T23:59:59.999Z`);
8549
9078
  }
@@ -8565,7 +9094,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8565
9094
  });
8566
9095
  const suppressed = /* @__PURE__ */ new Set();
8567
9096
  for (const exception of policy.exceptions ?? []) {
8568
- if (expiresAtMs(exception.expires) < now.getTime()) {
9097
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8569
9098
  errors.push(
8570
9099
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8571
9100
  );
@@ -8634,6 +9163,7 @@ var init_incremental = __esm({
8634
9163
  // src/modules/secrets/history.ts
8635
9164
  var history_exports = {};
8636
9165
  __export(history_exports, {
9166
+ historyCommitLimit: () => historyCommitLimit,
8637
9167
  scanGitHistory: () => scanGitHistory
8638
9168
  });
8639
9169
  function gitError(error) {
@@ -8676,7 +9206,13 @@ function remapHit(hit, file) {
8676
9206
  secret: hit.secret
8677
9207
  };
8678
9208
  }
8679
- async function streamLogChanges(cwd) {
9209
+ function historyCommitLimit(raw = process.env.FULLSHIELD_HISTORY_MAX) {
9210
+ if (raw === void 0 || raw.trim() === "") return 500;
9211
+ const parsed = Number(raw);
9212
+ if (!Number.isInteger(parsed) || parsed < 1) return 500;
9213
+ return Math.min(parsed, 5e3);
9214
+ }
9215
+ async function streamLogChanges(cwd, maxCommits) {
8680
9216
  const changes = [];
8681
9217
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
8682
9218
  const child = spawn(
@@ -8693,6 +9229,8 @@ async function streamLogChanges(cwd) {
8693
9229
  "--format=FSCOMMIT:%H",
8694
9230
  "--no-textconv",
8695
9231
  "--no-ext-diff",
9232
+ "-n",
9233
+ String(maxCommits),
8696
9234
  "HEAD"
8697
9235
  ],
8698
9236
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -8761,8 +9299,14 @@ async function headSecretKeys(cwd) {
8761
9299
  }
8762
9300
  return keys;
8763
9301
  }
8764
- async function scanGitHistory(cwd) {
8765
- const changes = await streamLogChanges(cwd);
9302
+ async function commitCount(cwd) {
9303
+ const raw = await gitOk(cwd, ["rev-list", "--count", "HEAD"]);
9304
+ const total = Number(raw.trim());
9305
+ return Number.isInteger(total) && total >= 0 ? total : 0;
9306
+ }
9307
+ async function scanGitHistory(cwd, maxCommits = historyCommitLimit()) {
9308
+ const total = await commitCount(cwd);
9309
+ const changes = await streamLogChanges(cwd, maxCommits);
8766
9310
  const blobHits = /* @__PURE__ */ new Map();
8767
9311
  const first = /* @__PURE__ */ new Map();
8768
9312
  for (const change of changes) {
@@ -8799,7 +9343,13 @@ async function scanGitHistory(cwd) {
8799
9343
  secret: hit.secret
8800
9344
  });
8801
9345
  }
8802
- return { findings: hits.map((h) => h.finding), hits };
9346
+ const scanned = Math.min(maxCommits, total);
9347
+ const history = total > maxCommits ? `scanned ${scanned} of ${total} commits` : void 0;
9348
+ return {
9349
+ findings: hits.map((h) => h.finding),
9350
+ hits,
9351
+ ...history !== void 0 ? { history } : {}
9352
+ };
8803
9353
  }
8804
9354
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
8805
9355
  var init_history = __esm({
@@ -9094,7 +9644,12 @@ async function runScan(options = {}, modules = builtinModules) {
9094
9644
  options.modules ?? config.modules,
9095
9645
  loadedPolicy?.requiredModules
9096
9646
  );
9097
- let files = await walkFiles({ cwd, ignore: config.ignore });
9647
+ let files = await walkFiles({
9648
+ cwd,
9649
+ ignore: config.ignore,
9650
+ defaultExcludes: config.defaultExcludes,
9651
+ excludes: config.excludes
9652
+ });
9098
9653
  if (options.staged === true) {
9099
9654
  const staged = new Set(await listStagedFiles(cwd));
9100
9655
  files = files.filter((file) => staged.has(file));
@@ -9128,6 +9683,7 @@ async function runScan(options = {}, modules = builtinModules) {
9128
9683
  );
9129
9684
  }
9130
9685
  const extraHits = [];
9686
+ let historyNotice;
9131
9687
  const secretsWanted = modulesRequested.includes("secrets");
9132
9688
  if (secretsAdvEnabled() && secretsWanted) {
9133
9689
  if (options.history === true) {
@@ -9135,6 +9691,7 @@ async function runScan(options = {}, modules = builtinModules) {
9135
9691
  const hist = await scanGitHistory2(cwd);
9136
9692
  extraHits.push(...hist.hits);
9137
9693
  collected.push(...hist.findings);
9694
+ historyNotice = hist.history;
9138
9695
  }
9139
9696
  if (options.bundle !== void 0 && options.bundle.length > 0) {
9140
9697
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -9155,7 +9712,11 @@ async function runScan(options = {}, modules = builtinModules) {
9155
9712
  if (options.updateBaseline === true) {
9156
9713
  await writeBaseline(baselinePath, unsuppressed);
9157
9714
  }
9158
- let findings = sortFindings(applyBaseline(unsuppressed, await readBaseline(baselinePath)));
9715
+ const baselineDoc = await readBaseline(baselinePath);
9716
+ let findings = sortFindings(applyBaseline(unsuppressed, baselineDoc));
9717
+ const suppressed = applySuppressions(findings, baselineDoc?.suppressions);
9718
+ findings = sortFindings(suppressed.findings);
9719
+ const suppressionErrors = suppressed.errors;
9159
9720
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
9160
9721
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
9161
9722
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -9227,10 +9788,12 @@ async function runScan(options = {}, modules = builtinModules) {
9227
9788
  cwd,
9228
9789
  filesScanned: files.length,
9229
9790
  modulesRequested: [...modulesRequested],
9230
- modulesRun
9791
+ modulesRun,
9792
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
9231
9793
  },
9232
9794
  config,
9233
- policy: policyResult
9795
+ policy: policyResult,
9796
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
9234
9797
  };
9235
9798
  }
9236
9799
  function scan(options = {}) {
@@ -9546,6 +10109,32 @@ function finding4(ruleId, title, message, file, severity = "high") {
9546
10109
  remediation: "Pin actions by SHA, drop pull_request_target, tighten permissions, close 0.0.0.0/0, encrypt storage, drop privileged."
9547
10110
  };
9548
10111
  }
10112
+ function runBodies(text) {
10113
+ const bodies = [];
10114
+ const lines = text.split(/\r?\n/);
10115
+ for (let i = 0; i < lines.length; i += 1) {
10116
+ const match = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(lines[i] ?? "");
10117
+ if (match === null) continue;
10118
+ const indent = match[1]?.length ?? 0;
10119
+ let body = match[2] ?? "";
10120
+ if (body === "|" || body === ">") {
10121
+ const block = [];
10122
+ for (let j = i + 1; j < lines.length; j += 1) {
10123
+ const line = lines[j] ?? "";
10124
+ if (line.trim() === "") {
10125
+ block.push(line);
10126
+ continue;
10127
+ }
10128
+ const lead = line.match(/^\s*/)?.[0].length ?? 0;
10129
+ if (lead <= indent) break;
10130
+ block.push(line);
10131
+ }
10132
+ body = block.join("\n");
10133
+ }
10134
+ bodies.push(body);
10135
+ }
10136
+ return bodies;
10137
+ }
9549
10138
  function walk3(dir, out = []) {
9550
10139
  if (!existsSync(dir)) return out;
9551
10140
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -9573,6 +10162,28 @@ function scanIac(dir, opts = {}) {
9573
10162
  if (/pull_request_target/.test(text)) {
9574
10163
  findings.push(finding4("IAC-GHA-PRTARGET-001", "pull_request_target workflow", "untrusted PR can run with base secrets", rel, "critical"));
9575
10164
  }
10165
+ if (/pull_request_target/.test(text) && /actions\/checkout/.test(text) && /github\.event\.pull_request/.test(text)) {
10166
+ findings.push(
10167
+ finding4(
10168
+ "IAC-GHA-PRCHECKOUT-001",
10169
+ "pull_request_target checks out pull-request code",
10170
+ "untrusted head can run with the base repository token",
10171
+ rel,
10172
+ "critical"
10173
+ )
10174
+ );
10175
+ }
10176
+ if (runBodies(text).some((body) => /\$\{\{\s*github\.event\./.test(body))) {
10177
+ findings.push(
10178
+ finding4(
10179
+ "IAC-GHA-INJECT-001",
10180
+ "Workflow run step interpolates github.event",
10181
+ "untrusted event text is passed to the shell",
10182
+ rel,
10183
+ "critical"
10184
+ )
10185
+ );
10186
+ }
9576
10187
  if (/permissions:\s*\n\s+contents:\s*write/.test(text) || /permissions:\s+write-all/.test(text)) {
9577
10188
  findings.push(finding4("IAC-GHA-PERMS-001", "Overly broad GitHub Actions permissions", "write-all or contents: write", rel));
9578
10189
  }
@@ -9723,6 +10334,7 @@ var init_compliance = __esm({
9723
10334
  ROW("SUPPLY-BEHAVIOR-001", "A06:2021 Vulnerable Components", "CWE-829", "V14.2.1", "PCI DSS 6.3.3"),
9724
10335
  ROW("SUPPLY-COOLDOWN-001", "A06:2021 Vulnerable Components", "CWE-1104", "V14.2.1", "PCI DSS 6.3.3"),
9725
10336
  ROW("SUPPLY-LOCKURL-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
10337
+ ROW("SUPPLY-LOCKINTEGRITY-001", "A08:2021 Integrity Failures", "CWE-353", "V14.2.3", "PCI DSS 6.3.3"),
9726
10338
  ROW("SUPPLY-CONFUSION-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9727
10339
  ROW("SUPPLY-PROVENANCE-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9728
10340
  ROW("SUPPLY-WORM-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3 / 12.10"),
@@ -9731,6 +10343,9 @@ var init_compliance = __esm({
9731
10343
  ROW("IAC-GHA-PRTARGET-001", "A01:2021 Broken Access Control", "CWE-269", "V4.1.1", "PCI DSS 7.2"),
9732
10344
  ROW("IAC-GHA-PERMS-001", "A01:2021 Broken Access Control", "CWE-250", "V4.1.1", "PCI DSS 7.2"),
9733
10345
  ROW("IAC-GHA-SECRETLOG-001", "A09:2021 Security Logging Failures", "CWE-532", "V7.1.1", "PCI DSS 3.2 / 10.2"),
10346
+ ROW("IAC-GHA-INJECT-001", "A03:2021 Injection", "CWE-94", "V5.2.4", "PCI DSS 6.2.4"),
10347
+ ROW("IAC-GHA-PRCHECKOUT-001", "A01:2021 Broken Access Control", "CWE-829", "V4.1.1", "PCI DSS 6.2.4"),
10348
+ ROW("DEPS-LICENSE-001", "A06:2021 Vulnerable Components", "CWE-1104", "V14.2.1", "PCI DSS 6.3.3"),
9734
10349
  ROW("IAC-TF-S3PUBLIC-001", "A01:2021 Broken Access Control", "CWE-284", "V12.1.1", "PCI DSS 1.3 / 3.2"),
9735
10350
  ROW("IAC-TF-SGOPEN-001", "A05:2021 Security Misconfiguration", "CWE-284", "V1.4.1", "PCI DSS 1.2 / 1.3"),
9736
10351
  ROW("IAC-TF-UNENCRYPTED-001", "A02:2021 Cryptographic Failures", "CWE-311", "V6.2.1", "PCI DSS 3.4 / 4.2"),
@@ -9757,6 +10372,7 @@ var init_compliance = __esm({
9757
10372
  ROW("SAST-TIMING-001", "A07:2021 Identification Failures", "CWE-208", "V2.10.3", "PCI DSS 8.3"),
9758
10373
  ROW("SAST-HASH-001", "A02:2021 Cryptographic Failures", "CWE-916", "V6.2.2", "PCI DSS 8.3.1"),
9759
10374
  ROW("SAST-GQL-001", "A04:2021 Insecure Design", "CWE-770/200", "V13.1.1", "PCI DSS 6.2.4"),
10375
+ ROW("SAST-SKIP-001", "A05:2021 Security Misconfiguration", "CWE-693", "V1.1.1", "PCI DSS 6.2.4"),
9760
10376
  ROW("CONFIG-TRUSTPROXY-001", "A07:2021 Identification Failures", "CWE-290", "V2.2.1", "PCI DSS 8.3.4"),
9761
10377
  ROW("SECRET-BUNDLE-001", "A02:2021 Cryptographic Failures", "CWE-798", "V6.4.1", "PCI DSS 3.2 / 8.3"),
9762
10378
  ROW("DLP-SECRET-001", "A02:2021 Cryptographic Failures", "CWE-200", "V8.3.4", "PCI DSS 3.2 / 8.3"),
@@ -9776,6 +10392,23 @@ var init_compliance = __esm({
9776
10392
  byId = new Map(RULE_MAP.map((r) => [r.ruleId, r]));
9777
10393
  }
9778
10394
  });
10395
+ function nodeSqliteAvailable() {
10396
+ try {
10397
+ require2("node:sqlite");
10398
+ return true;
10399
+ } catch {
10400
+ return false;
10401
+ }
10402
+ }
10403
+ function nodeSqliteUnavailableMessage(feature) {
10404
+ return `${feature} requires Node 22.13+ (node:sqlite is not available on Node ${process.versions.node})`;
10405
+ }
10406
+ var require2;
10407
+ var init_sqlite = __esm({
10408
+ "src/core/sqlite.ts"() {
10409
+ require2 = createRequire(import.meta.url);
10410
+ }
10411
+ });
9779
10412
 
9780
10413
  // src/dashboard/index.ts
9781
10414
  var dashboard_exports = {};
@@ -9789,7 +10422,7 @@ __export(dashboard_exports, {
9789
10422
  startDashboard: () => startDashboard
9790
10423
  });
9791
10424
  function sqliteDb(file) {
9792
- const { DatabaseSync } = require2("node:sqlite");
10425
+ const { DatabaseSync } = require3("node:sqlite");
9793
10426
  let db;
9794
10427
  try {
9795
10428
  db = new DatabaseSync(file);
@@ -9840,6 +10473,9 @@ function sqliteDb(file) {
9840
10473
  };
9841
10474
  }
9842
10475
  function openHistory(dir) {
10476
+ if (!nodeSqliteAvailable()) {
10477
+ throw new Error(nodeSqliteUnavailableMessage("history"));
10478
+ }
9843
10479
  const root = join(dir, ".fullshield");
9844
10480
  mkdirSync(root, { recursive: true });
9845
10481
  return sqliteDb(join(root, "history.db"));
@@ -9941,10 +10577,11 @@ function startDashboard(opts) {
9941
10577
  });
9942
10578
  });
9943
10579
  }
9944
- var require2;
10580
+ var require3;
9945
10581
  var init_dashboard = __esm({
9946
10582
  "src/dashboard/index.ts"() {
9947
- require2 = createRequire(import.meta.url);
10583
+ init_sqlite();
10584
+ require3 = createRequire(import.meta.url);
9948
10585
  }
9949
10586
  });
9950
10587
  function finding5(ruleId, title, message, file, severity = "high") {
@@ -11169,6 +11806,153 @@ ${target.message}`
11169
11806
  // src/cli/program.ts
11170
11807
  init_version();
11171
11808
 
11809
+ // src/core/fingerprint.ts
11810
+ init_finding();
11811
+ function lineIndependentFingerprint(finding6) {
11812
+ const file = (finding6.file ?? "").replace(/\\/g, "/");
11813
+ const text = (finding6.snippet ?? finding6.message ?? "").replace(/\s+/g, " ").trim();
11814
+ return makeFindingId(finding6.ruleId, file, `${finding6.severity}\0${text}`);
11815
+ }
11816
+
11817
+ // src/reporters/ci.ts
11818
+ init_finding();
11819
+
11820
+ // src/reporters/redact.ts
11821
+ init_mask();
11822
+ 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;
11823
+ function redactSecrets2(text) {
11824
+ return text.replace(SECRET, (match) => maskSecret(match));
11825
+ }
11826
+ function redactFinding(finding6) {
11827
+ return {
11828
+ ...finding6,
11829
+ title: redactSecrets2(finding6.title),
11830
+ message: redactSecrets2(finding6.message),
11831
+ remediation: redactSecrets2(finding6.remediation),
11832
+ snippet: finding6.snippet === void 0 ? void 0 : redactSecrets2(finding6.snippet)
11833
+ };
11834
+ }
11835
+ function redactScan(result) {
11836
+ return { ...result, findings: result.findings.map(redactFinding) };
11837
+ }
11838
+
11839
+ // src/reporters/ci.ts
11840
+ var MESSAGE_CAP = 4e3;
11841
+ function posix2(file) {
11842
+ return file.replace(/\\/g, "/");
11843
+ }
11844
+ function clip(text) {
11845
+ return text.length <= MESSAGE_CAP ? text : `${text.slice(0, MESSAGE_CAP)}\u2026`;
11846
+ }
11847
+ function githubEscape(value, property) {
11848
+ let out = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A");
11849
+ if (property) out = out.replace(/,/g, "%2C");
11850
+ return out;
11851
+ }
11852
+ function githubLevel(severity) {
11853
+ if (severity === "critical" || severity === "high") return "error";
11854
+ if (severity === "medium") return "warning";
11855
+ return "notice";
11856
+ }
11857
+ function gitlabSeverity(severity) {
11858
+ if (severity === "info") return "info";
11859
+ if (severity === "low") return "minor";
11860
+ if (severity === "medium") return "major";
11861
+ if (severity === "high") return "critical";
11862
+ return "blocker";
11863
+ }
11864
+ function xmlEscape(value) {
11865
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
11866
+ }
11867
+ function oneLine(value) {
11868
+ return value.replace(/\s+/g, " ").trim();
11869
+ }
11870
+ function renderGithubAnnotations(input) {
11871
+ const result = redactScan(input);
11872
+ if (result.findings.length === 0) {
11873
+ return "::notice title=FullShield::No findings\n";
11874
+ }
11875
+ const lines = result.findings.map((finding6) => {
11876
+ const props = [];
11877
+ if (finding6.file !== void 0) props.push(`file=${githubEscape(posix2(finding6.file), true)}`);
11878
+ if (finding6.line !== void 0) props.push(`line=${finding6.line}`);
11879
+ if (finding6.column !== void 0) props.push(`col=${finding6.column + 1}`);
11880
+ props.push(`title=${githubEscape(finding6.ruleId, true)}`);
11881
+ const message = githubEscape(clip(finding6.message), false);
11882
+ return `::${githubLevel(finding6.severity)} ${props.join(",")}::${message}`;
11883
+ });
11884
+ return `${lines.join("\n")}
11885
+ `;
11886
+ }
11887
+ function junitCase(finding6) {
11888
+ const file = finding6.file !== void 0 ? posix2(finding6.file) : "fullshield";
11889
+ const attrs = [
11890
+ `name="${xmlEscape(finding6.ruleId)}"`,
11891
+ `classname="${xmlEscape(file)}"`,
11892
+ `file="${xmlEscape(file)}"`
11893
+ ];
11894
+ if (finding6.line !== void 0) attrs.push(`line="${finding6.line}"`);
11895
+ const open = `<testcase ${attrs.join(" ")}>`;
11896
+ if (!meetsThreshold(finding6.severity, "medium")) {
11897
+ return `${open}<skipped message="${xmlEscape(oneLine(clip(finding6.message)))}"/></testcase>`;
11898
+ }
11899
+ const detail = clip(`${finding6.message}${finding6.remediation.length > 0 ? `
11900
+ Fix: ${finding6.remediation}` : ""}`);
11901
+ return `${open}<failure type="${finding6.severity}" message="${xmlEscape(oneLine(clip(finding6.message)))}">${xmlEscape(detail)}</failure></testcase>`;
11902
+ }
11903
+ function renderJunitReport(input) {
11904
+ const result = redactScan(input);
11905
+ const header = '<?xml version="1.0" encoding="UTF-8"?>';
11906
+ if (result.findings.length === 0) {
11907
+ return `${header}
11908
+ <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>
11909
+ `;
11910
+ }
11911
+ const groups = /* @__PURE__ */ new Map();
11912
+ for (const finding6 of result.findings) {
11913
+ const list = groups.get(finding6.module) ?? [];
11914
+ list.push(finding6);
11915
+ groups.set(finding6.module, list);
11916
+ }
11917
+ let failures = 0;
11918
+ let skipped = 0;
11919
+ const suites = [];
11920
+ for (const [name, findings] of groups) {
11921
+ let suiteFailures = 0;
11922
+ let suiteSkipped = 0;
11923
+ const cases = findings.map((finding6) => {
11924
+ if (meetsThreshold(finding6.severity, "medium")) suiteFailures += 1;
11925
+ else suiteSkipped += 1;
11926
+ return junitCase(finding6);
11927
+ });
11928
+ failures += suiteFailures;
11929
+ skipped += suiteSkipped;
11930
+ suites.push(
11931
+ `<testsuite name="${xmlEscape(name)}" tests="${findings.length}" failures="${suiteFailures}" skipped="${suiteSkipped}">${cases.join("")}</testsuite>`
11932
+ );
11933
+ }
11934
+ return `${header}
11935
+ <testsuites name="fullshield" tests="${result.findings.length}" failures="${failures}" skipped="${skipped}">${suites.join("")}</testsuites>
11936
+ `;
11937
+ }
11938
+ function renderGitlabCodeQuality(input) {
11939
+ const result = redactScan(input);
11940
+ const report = result.findings.map((finding6) => {
11941
+ const path = finding6.file !== void 0 ? posix2(finding6.file) : ".";
11942
+ const location4 = { path };
11943
+ if (finding6.line !== void 0) location4.lines = { begin: finding6.line };
11944
+ return {
11945
+ description: clip(finding6.message),
11946
+ check_name: finding6.ruleId,
11947
+ fingerprint: lineIndependentFingerprint(finding6),
11948
+ severity: gitlabSeverity(finding6.severity),
11949
+ location: location4
11950
+ };
11951
+ });
11952
+ return `${JSON.stringify(report, null, 2)}
11953
+ `;
11954
+ }
11955
+
11172
11956
  // src/reporters/console.ts
11173
11957
  init_finding();
11174
11958
  var SEVERITY_COLOR = {
@@ -11210,7 +11994,8 @@ function renderFinding(finding6) {
11210
11994
  }
11211
11995
  return lines;
11212
11996
  }
11213
- function renderConsoleReport(result) {
11997
+ function renderConsoleReport(input) {
11998
+ const result = redactScan(input);
11214
11999
  const { findings, summary, metadata } = result;
11215
12000
  const lines = [];
11216
12001
  lines.push(
@@ -11218,6 +12003,7 @@ function renderConsoleReport(result) {
11218
12003
  ` \u2014 scanned ${metadata.filesScanned} files in ${metadata.durationMs}ms (modules: ${metadata.modulesRun.length > 0 ? metadata.modulesRun.join(", ") : "none"})`
11219
12004
  )
11220
12005
  );
12006
+ if (metadata.history !== void 0) lines.push(metadata.history);
11221
12007
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
11222
12008
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
11223
12009
  lines.push(
@@ -11261,7 +12047,8 @@ function location2(finding6) {
11261
12047
  if (finding6.file === void 0) return "(project)";
11262
12048
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
11263
12049
  }
11264
- function renderHtmlReport(result) {
12050
+ function renderHtmlReport(input) {
12051
+ const result = redactScan(input);
11265
12052
  const sections = [];
11266
12053
  const bySeverityDesc = [...SEVERITIES].reverse();
11267
12054
  for (const severity of bySeverityDesc) {
@@ -11308,7 +12095,7 @@ ${items}`);
11308
12095
 
11309
12096
  // src/reporters/json.ts
11310
12097
  function publicFinding(finding6) {
11311
- const copy = { ...finding6 };
12098
+ const copy = redactFinding(finding6);
11312
12099
  delete copy.aiVerdict;
11313
12100
  delete copy.exploitScenario;
11314
12101
  delete copy.aiReasoning;
@@ -11332,7 +12119,8 @@ function location3(finding6) {
11332
12119
  if (finding6.file === void 0) return "(project)";
11333
12120
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
11334
12121
  }
11335
- function renderMarkdownReport(result) {
12122
+ function renderMarkdownReport(input) {
12123
+ const result = redactScan(input);
11336
12124
  const lines = [
11337
12125
  "# FullShield report",
11338
12126
  "",
@@ -11384,7 +12172,8 @@ function sarifLevel(severity) {
11384
12172
  function uri(file) {
11385
12173
  return (file ?? "").replace(/\\/g, "/");
11386
12174
  }
11387
- function renderSarifReport(result) {
12175
+ function renderSarifReport(input) {
12176
+ const result = redactScan(input);
11388
12177
  const rules = /* @__PURE__ */ new Map();
11389
12178
  for (const finding6 of result.findings) {
11390
12179
  if (!rules.has(finding6.ruleId)) {
@@ -11451,7 +12240,137 @@ function renderSarifReport(result) {
11451
12240
  return JSON.stringify(doc, null, 2);
11452
12241
  }
11453
12242
 
12243
+ // src/diff/index.ts
12244
+ init_finding();
12245
+ init_experimental();
12246
+ function posix3(file) {
12247
+ return (file ?? "").replace(/\\/g, "/");
12248
+ }
12249
+ function diffKey(finding6) {
12250
+ return lineIndependentFingerprint(finding6);
12251
+ }
12252
+ function groupById(findings) {
12253
+ const groups = /* @__PURE__ */ new Map();
12254
+ for (const finding6 of findings) {
12255
+ if (finding6.id === void 0 || finding6.id.length === 0) continue;
12256
+ const list = groups.get(finding6.id) ?? [];
12257
+ list.push(finding6);
12258
+ groups.set(finding6.id, list);
12259
+ }
12260
+ return groups;
12261
+ }
12262
+ function pairMoves(removed, added) {
12263
+ const removedIds = groupById(removed);
12264
+ const addedIds = groupById(added);
12265
+ const moved = [];
12266
+ for (const [id, group] of removedIds) {
12267
+ const match = addedIds.get(id);
12268
+ if (group.length === 1 && match?.length === 1 && group[0] !== void 0 && match[0] !== void 0) {
12269
+ moved.push({ before: group[0], after: match[0] });
12270
+ }
12271
+ }
12272
+ return moved;
12273
+ }
12274
+ function byLocation(a, b) {
12275
+ return (a.line ?? 0) - (b.line ?? 0) || a.id.localeCompare(b.id) || a.ruleId.localeCompare(b.ruleId);
12276
+ }
12277
+ function groupFindings(findings) {
12278
+ const groups = /* @__PURE__ */ new Map();
12279
+ for (const finding6 of findings) {
12280
+ const key = diffKey(finding6);
12281
+ const list = groups.get(key) ?? [];
12282
+ list.push(finding6);
12283
+ groups.set(key, list);
12284
+ }
12285
+ return groups;
12286
+ }
12287
+ function diffScans(before, after) {
12288
+ const beforeGroups = groupFindings(before);
12289
+ const afterGroups = groupFindings(after);
12290
+ const unchanged = [];
12291
+ const added = [];
12292
+ const removed = [];
12293
+ const moved = [];
12294
+ const keys = [];
12295
+ for (const key of beforeGroups.keys()) keys.push(key);
12296
+ for (const key of afterGroups.keys()) {
12297
+ if (!beforeGroups.has(key)) keys.push(key);
12298
+ }
12299
+ for (const key of keys) {
12300
+ const left = [...beforeGroups.get(key) ?? []].sort(byLocation);
12301
+ const right = [...afterGroups.get(key) ?? []].sort(byLocation);
12302
+ const paired = Math.min(left.length, right.length);
12303
+ for (let i = 0; i < paired; i += 1) {
12304
+ const earlier = left[i];
12305
+ const later = right[i];
12306
+ if (earlier === void 0 || later === void 0) continue;
12307
+ if ((earlier.line ?? 0) === (later.line ?? 0)) unchanged.push(later);
12308
+ else moved.push({ before: earlier, after: later });
12309
+ }
12310
+ for (const finding6 of left.slice(paired)) removed.push(finding6);
12311
+ for (const finding6 of right.slice(paired)) added.push(finding6);
12312
+ }
12313
+ const idMoves = pairMoves(removed, added);
12314
+ const movedBefore = new Set(idMoves.map((item) => item.before));
12315
+ const movedAfter = new Set(idMoves.map((item) => item.after));
12316
+ return {
12317
+ unchanged,
12318
+ added: added.filter((finding6) => !movedAfter.has(finding6)),
12319
+ removed: removed.filter((finding6) => !movedBefore.has(finding6)),
12320
+ moved: [...moved, ...idMoves]
12321
+ };
12322
+ }
12323
+ function parseScanDocument(text) {
12324
+ let parsed;
12325
+ try {
12326
+ parsed = JSON.parse(text);
12327
+ } catch {
12328
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
12329
+ }
12330
+ const list = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings) ? parsed.findings : void 0;
12331
+ if (list === void 0) {
12332
+ throw new Error("scan JSON must be an object with a findings array or a findings array");
12333
+ }
12334
+ return list.map((item) => {
12335
+ if (typeof item !== "object" || item === null) {
12336
+ throw new Error("scan JSON finding is missing ruleId or severity");
12337
+ }
12338
+ const record = item;
12339
+ const ruleId = record.ruleId;
12340
+ const severity = record.severity;
12341
+ if (typeof ruleId !== "string" || ruleId.length === 0 || typeof severity !== "string") {
12342
+ throw new Error("scan JSON finding is missing ruleId or severity");
12343
+ }
12344
+ if (!isSeverity(severity)) {
12345
+ throw new Error(`scan JSON finding has an unknown severity "${severity}"`);
12346
+ }
12347
+ if (record.line !== void 0 && (typeof record.line !== "number" || !Number.isInteger(record.line) || record.line < 0)) {
12348
+ throw new Error("scan JSON finding line must be an integer >= 0");
12349
+ }
12350
+ return item;
12351
+ });
12352
+ }
12353
+ function addedFails(diff, failOn) {
12354
+ return diff.added.some((finding6) => finding6.source !== "ai" && meetsThreshold(finding6.severity, failOn));
12355
+ }
12356
+ var ADDED_CAP = 20;
12357
+ function addedLine(finding6) {
12358
+ const where = finding6.file !== void 0 ? ` ${posix3(finding6.file)}:${finding6.line ?? 0}` : "";
12359
+ return `${finding6.severity} ${finding6.ruleId}${where}`;
12360
+ }
12361
+ function renderDiffText(diff) {
12362
+ const lines = [
12363
+ `fullshield diff: unchanged ${diff.unchanged.length}, added ${diff.added.length}, removed ${diff.removed.length}, moved ${diff.moved.length}`
12364
+ ];
12365
+ if (diff.added.length === 0) return lines.join("\n");
12366
+ lines.push("added:");
12367
+ for (const finding6 of diff.added.slice(0, ADDED_CAP)) lines.push(addedLine(finding6));
12368
+ if (diff.added.length > ADDED_CAP) lines.push(`${diff.added.length - ADDED_CAP} more \u2014 use --json`);
12369
+ return lines.join("\n");
12370
+ }
12371
+
11454
12372
  // src/cli/program.ts
12373
+ init_experimental();
11455
12374
  init_policy();
11456
12375
 
11457
12376
  // src/sbom/index.ts
@@ -12126,7 +13045,7 @@ function stateForGrace(sshd) {
12126
13045
 
12127
13046
  // src/cli/commands/doctor.ts
12128
13047
  init_config2();
12129
- var MIN_NODE = [22, 13, 0];
13048
+ var MIN_NODE = [20, 11, 0];
12130
13049
  var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
12131
13050
  var FLAGS = [
12132
13051
  "FULLSHIELD_EXPERIMENTAL_SAST_GAPS",
@@ -12139,6 +13058,9 @@ var FLAGS = [
12139
13058
  "FULLSHIELD_EXPERIMENTAL_POLICY",
12140
13059
  "FULLSHIELD_EXPERIMENTAL_HOST",
12141
13060
  "FULLSHIELD_EXPERIMENTAL_TAINT_V2",
13061
+ "FULLSHIELD_EXPERIMENTAL_CI_REPORTS",
13062
+ "FULLSHIELD_EXPERIMENTAL_DIFF",
13063
+ "FULLSHIELD_EXPERIMENTAL_LICENSE",
12142
13064
  "FULLSHIELD_PARSE_POOL"
12143
13065
  ];
12144
13066
  function nodeOk(version) {
@@ -12154,7 +13076,7 @@ function nodeOk(version) {
12154
13076
  async function runDoctor(cwd, options = {}) {
12155
13077
  const lines = [];
12156
13078
  const version = process.versions.node;
12157
- lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 22.13.0)"}`);
13079
+ lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 20.11.0)"}`);
12158
13080
  const locks = LOCKFILES.filter((name) => existsSync(join(cwd, name)));
12159
13081
  lines.push(locks.length > 0 ? `lockfile: ${locks.join(", ")}` : "lockfile: none");
12160
13082
  const cacheDir = join(homedir(), ".cache", "fullshield", "osv");
@@ -12200,6 +13122,16 @@ var KNOWN = [
12200
13122
  test: /cannot read config/,
12201
13123
  code: "FS-E004",
12202
13124
  hint: "Check the path passed to --config."
13125
+ },
13126
+ {
13127
+ test: /FULLSHIELD_EXPERIMENTAL_CI_REPORTS/,
13128
+ code: "FS-E005",
13129
+ hint: "Set FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1 or pass --experimental-ci-reports."
13130
+ },
13131
+ {
13132
+ test: /FULLSHIELD_EXPERIMENTAL_DIFF|scan JSON|cannot read diff/,
13133
+ code: "FS-E006",
13134
+ hint: "Set FULLSHIELD_EXPERIMENTAL_DIFF=1 or pass --experimental-diff. Both inputs must be scan --json files."
12203
13135
  }
12204
13136
  ];
12205
13137
  var DOCS = "https://github.com/kathanp/fullshield/blob/main/docs/errors.md";
@@ -12251,7 +13183,16 @@ function parseFailOn(value) {
12251
13183
  function createProgram(io = defaultIO) {
12252
13184
  const program = new Command();
12253
13185
  program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion()).option("--debug", "print stack traces on CLI errors");
12254
- program.command("scan").description("Scan a project directory for security findings").argument("[path]", "directory to scan", ".").option("--json", "machine-readable JSON output").option("--fail-on <severity>", "exit 1 if any finding is at or above this severity").option("--modules <modules>", "comma-separated subset of modules to run").option("--staged", "scan only files staged in git (pre-commit mode)").option("--offline", "use the OSV cache only (no network)").option("--offline-safe", "alias of --offline").option("--config <path>", "path to fullshield.config.json").option("--sarif <file>", "write a SARIF 2.1.0 report to this path").option("--markdown <file>", "write a Markdown report to this path").option("--html <file>", "write an HTML report to this path").option("--update-baseline", "record current findings as accepted (CI fails only on new ones)").option("--ai [mode]", "experimental: explain findings (off = zero AI network calls)").option("--emit-virtual-patches [file]", "experimental: write .fullshield/vpatches.json").option("--changed", "experimental: scan git staged/changed files only").option("--since <ref>", "experimental: scan files changed since a git ref").option(
13186
+ program.command("scan").description("Scan a project directory for security findings").argument("[path]", "directory to scan", ".").option("--json", "machine-readable JSON output").option("--fail-on <severity>", "exit 1 if any finding is at or above this severity").option("--modules <modules>", "comma-separated subset of modules to run").option("--staged", "scan only files staged in git (pre-commit mode)").option("--offline", "use the OSV cache only (no network)").option("--offline-safe", "alias of --offline").option("--config <path>", "path to fullshield.config.json").option("--sarif <file>", "write a SARIF 2.1.0 report to this path").option("--markdown <file>", "write a Markdown report to this path").option("--html <file>", "write an HTML report to this path").option(
13187
+ "--experimental-ci-reports",
13188
+ "experimental: GitHub annotations, JUnit, and GitLab Code Quality (FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
13189
+ ).option(
13190
+ "--github-annotations <file>",
13191
+ "experimental: GitHub Actions workflow commands (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
13192
+ ).option("--junit <file>", "experimental: JUnit XML (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)").option(
13193
+ "--gitlab <file>",
13194
+ "experimental: GitLab Code Quality JSON (requires FULLSHIELD_EXPERIMENTAL_CI_REPORTS=1)"
13195
+ ).option("--update-baseline", "record current findings as accepted (CI fails only on new ones)").option("--ai [mode]", "experimental: explain findings (off = zero AI network calls)").option("--emit-virtual-patches [file]", "experimental: write .fullshield/vpatches.json").option("--changed", "experimental: scan git staged/changed files only").option("--since <ref>", "experimental: scan files changed since a git ref").option(
12255
13196
  "--experimental-sast-gaps",
12256
13197
  "experimental: ORM raw / sh -c / execa / zx sinks (FULLSHIELD_EXPERIMENTAL_SAST_GAPS=1)"
12257
13198
  ).option(
@@ -12310,6 +13251,15 @@ function createProgram(io = defaultIO) {
12310
13251
  if (opts.prSummary === true && process.env[POLICY_FLAG] !== "1") {
12311
13252
  throw new Error(`--pr-summary requires ${POLICY_FLAG}=1`);
12312
13253
  }
13254
+ if (opts.experimentalCiReports === true) {
13255
+ process.env[CI_REPORTS_FLAG] = "1";
13256
+ }
13257
+ const wantsCiReport = opts.githubAnnotations !== void 0 || opts.junit !== void 0 || opts.gitlab !== void 0;
13258
+ if (wantsCiReport && process.env[CI_REPORTS_FLAG] !== "1") {
13259
+ throw new Error(
13260
+ `--github-annotations, --junit, and --gitlab require ${CI_REPORTS_FLAG}=1`
13261
+ );
13262
+ }
12313
13263
  const result = await scan({
12314
13264
  cwd: resolve(path),
12315
13265
  modules,
@@ -12337,6 +13287,15 @@ function createProgram(io = defaultIO) {
12337
13287
  if (opts.html !== void 0) {
12338
13288
  await writeFile(resolve(opts.html), renderHtmlReport(result), "utf8");
12339
13289
  }
13290
+ if (opts.githubAnnotations !== void 0) {
13291
+ await writeFile(resolve(opts.githubAnnotations), renderGithubAnnotations(result), "utf8");
13292
+ }
13293
+ if (opts.junit !== void 0) {
13294
+ await writeFile(resolve(opts.junit), renderJunitReport(result), "utf8");
13295
+ }
13296
+ if (opts.gitlab !== void 0) {
13297
+ await writeFile(resolve(opts.gitlab), renderGitlabCodeQuality(result), "utf8");
13298
+ }
12340
13299
  await writeLastScan(resolve(path), result);
12341
13300
  if (opts.emitVirtualPatches !== void 0) {
12342
13301
  if (process.env.FULLSHIELD_EXPERIMENTAL_VPATCH !== "1") {
@@ -12358,6 +13317,12 @@ function createProgram(io = defaultIO) {
12358
13317
  }
12359
13318
  process.exitCode = 1;
12360
13319
  }
13320
+ if (result.suppressionErrors !== void 0 && result.suppressionErrors.length > 0) {
13321
+ for (const message of result.suppressionErrors) {
13322
+ io.err(`fullshield: ${message}`);
13323
+ }
13324
+ process.exitCode = 1;
13325
+ }
12361
13326
  const failOn = failOnOverride ?? result.policy?.failOn ?? result.config.failOn;
12362
13327
  if (result.findings.some((f) => failsCi(f, failOn))) {
12363
13328
  process.exitCode = 1;
@@ -12748,8 +13713,8 @@ function createProgram(io = defaultIO) {
12748
13713
  let name = "app";
12749
13714
  let version = "0.0.0";
12750
13715
  try {
12751
- const { readFile: readFile19 } = await import('fs/promises');
12752
- const pkg = JSON.parse(await readFile19(join(cwd, "package.json"), "utf8"));
13716
+ const { readFile: readFile21 } = await import('fs/promises');
13717
+ const pkg = JSON.parse(await readFile21(join(cwd, "package.json"), "utf8"));
12753
13718
  if (typeof pkg.name === "string") name = pkg.name;
12754
13719
  if (typeof pkg.version === "string") version = pkg.version;
12755
13720
  } catch {
@@ -12770,6 +13735,31 @@ function createProgram(io = defaultIO) {
12770
13735
  process.exitCode = 2;
12771
13736
  }
12772
13737
  });
13738
+ program.command("diff").description("Experimental comparison of two scan --json files (FULLSHIELD_EXPERIMENTAL_DIFF=1)").argument("<before>", "earlier scan JSON").argument("<after>", "later scan JSON").option("--experimental-diff", "enable this command (FULLSHIELD_EXPERIMENTAL_DIFF=1)").option("--json", "print the machine diff").option("--out <file>", "write the machine diff to this file").option("--fail-on <severity>", "exit 1 when an added finding is at or above this severity", "high").action(
13739
+ async (beforePath, afterPath, opts) => {
13740
+ try {
13741
+ if (opts.experimentalDiff === true) process.env[DIFF_FLAG] = "1";
13742
+ if (!diffEnabled()) throw new Error(`diff requires ${DIFF_FLAG}=1`);
13743
+ const failOn = parseFailOn(opts.failOn ?? "high");
13744
+ let beforeText;
13745
+ let afterText;
13746
+ try {
13747
+ beforeText = await readFile(resolve(beforePath), "utf8");
13748
+ afterText = await readFile(resolve(afterPath), "utf8");
13749
+ } catch {
13750
+ throw new Error("cannot read diff input");
13751
+ }
13752
+ const diff = diffScans(parseScanDocument(beforeText), parseScanDocument(afterText));
13753
+ const json = JSON.stringify(diff, null, 2);
13754
+ if (opts.out !== void 0) await writeFile(resolve(opts.out), json, "utf8");
13755
+ io.out(opts.json === true ? json : renderDiffText(diff));
13756
+ if (addedFails(diff, failOn)) process.exitCode = 1;
13757
+ } catch (error) {
13758
+ io.err(formatCliError(error));
13759
+ process.exitCode = 2;
13760
+ }
13761
+ }
13762
+ );
12773
13763
  program.command("doctor").description("Report Node, lockfile, cache, flags, config, and whether OSV is offline").argument("[path]", "project directory", ".").option("--offline", "do not describe OSV as allowed to use the network").option("--config <file>", "explicit config file").action(async (path, opts) => {
12774
13764
  try {
12775
13765
  io.out(await runDoctor(resolve(path), opts));