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/cli/index.js CHANGED
@@ -1,13 +1,14 @@
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
- import { readFileSync, cpSync, existsSync, mkdirSync, rmSync, readdirSync, statSync, writeFileSync, mkdtempSync, unlinkSync } from 'fs';
6
- import { tmpdir, homedir } from 'os';
5
+ import { readFileSync, existsSync, readdirSync, cpSync, mkdirSync, rmSync, statSync, writeFileSync, mkdtempSync, unlinkSync } from 'fs';
6
+ import { homedir, tmpdir, availableParallelism } from 'os';
7
+ import { fileURLToPath } from 'url';
8
+ import { Worker } from 'worker_threads';
7
9
  import ignoreModule from 'ignore';
8
10
  import { execFile, spawnSync, execFileSync, spawn } from 'child_process';
9
11
  import { promisify } from 'util';
10
- import { fileURLToPath } from 'url';
11
12
  import { createInterface } from 'readline';
12
13
  import { performance } from 'perf_hooks';
13
14
  import { createRequire } from 'module';
@@ -112,7 +113,8 @@ function walk(node, visit) {
112
113
  }
113
114
  }
114
115
  function attachParents(node, parent) {
115
- node.parent = parent;
116
+ const current = node;
117
+ if (parent !== void 0 || current.parent === void 0) current.parent = parent;
116
118
  for (const key of Object.keys(node)) {
117
119
  if (SKIP_KEYS.has(String(key))) continue;
118
120
  const value = node[key];
@@ -188,8 +190,21 @@ function classesEnabled() {
188
190
  function mcpCfgEnabled() {
189
191
  return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
190
192
  }
193
+ function taintV2Enabled() {
194
+ return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
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;
191
203
  var init_experimental = __esm({
192
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";
193
208
  }
194
209
  });
195
210
  function isSeverity(value) {
@@ -1077,6 +1092,105 @@ var init_pins = __esm({
1077
1092
  }
1078
1093
  });
1079
1094
 
1095
+ // src/modules/deps/symbol-reachability.ts
1096
+ function affectedFunctionNames(vuln) {
1097
+ const names = /* @__PURE__ */ new Set();
1098
+ for (const affected of vuln.affected ?? []) {
1099
+ const list = affected.ecosystem_specific?.affected_functions;
1100
+ if (!Array.isArray(list)) continue;
1101
+ for (const item of list) {
1102
+ if (typeof item === "string" && item.length > 0) names.add(item);
1103
+ }
1104
+ }
1105
+ return [...names];
1106
+ }
1107
+ function escapeRegExp(value) {
1108
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1109
+ }
1110
+ function called(source, local) {
1111
+ return new RegExp(`\\b${escapeRegExp(local)}\\s*\\(`).test(source);
1112
+ }
1113
+ function memberCalled(source, object, member) {
1114
+ return new RegExp(`\\b${escapeRegExp(object)}\\s*\\.\\s*${escapeRegExp(member)}\\s*\\(`).test(source);
1115
+ }
1116
+ function sourceCallsAffectedSymbol(source, symbol) {
1117
+ const esc2 = escapeRegExp(symbol);
1118
+ const named = new RegExp(
1119
+ `import\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`,
1120
+ "g"
1121
+ );
1122
+ let match;
1123
+ while ((match = named.exec(source)) !== null) {
1124
+ const parts = (match[1] ?? "").split(",");
1125
+ for (const part of parts) {
1126
+ const bits = part.trim().split(/\s+as\s+/);
1127
+ const imported = bits[0]?.trim();
1128
+ const local = (bits[1] ?? bits[0])?.trim();
1129
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
1130
+ }
1131
+ }
1132
+ const star = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"][^'"]+['"]/g;
1133
+ while ((match = star.exec(source)) !== null) {
1134
+ const local = match[1];
1135
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
1136
+ }
1137
+ const subDefault = new RegExp(
1138
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"][^'"]+/${esc2}['"]`
1139
+ );
1140
+ const sub = subDefault.exec(source);
1141
+ if (sub?.[1] !== void 0 && called(source, sub[1])) return true;
1142
+ const reqMember = new RegExp(
1143
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+['"]\\s*\\)\\s*\\.\\s*${esc2}`
1144
+ );
1145
+ const reqHit = reqMember.exec(source);
1146
+ if (reqHit?.[1] !== void 0 && called(source, reqHit[1])) return true;
1147
+ const reqNs = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
1148
+ while ((match = reqNs.exec(source)) !== null) {
1149
+ const local = match[1];
1150
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
1151
+ }
1152
+ const reqNamed = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
1153
+ while ((match = reqNamed.exec(source)) !== null) {
1154
+ const parts = (match[1] ?? "").split(",");
1155
+ for (const part of parts) {
1156
+ const bits = part.trim().split(/\s*:\s*/);
1157
+ const imported = bits[0]?.trim();
1158
+ const local = (bits[1] ?? bits[0])?.trim();
1159
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
1160
+ }
1161
+ }
1162
+ const reqSub = new RegExp(
1163
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+/${esc2}['"]\\s*\\)`
1164
+ );
1165
+ const subReq = reqSub.exec(source);
1166
+ if (subReq?.[1] !== void 0 && called(source, subReq[1])) return true;
1167
+ const barrel = new RegExp(`export\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`, "g");
1168
+ while ((match = barrel.exec(source)) !== null) {
1169
+ const parts = (match[1] ?? "").split(",");
1170
+ for (const part of parts) {
1171
+ const bits = part.trim().split(/\s+as\s+/);
1172
+ const exported = (bits[1] ?? bits[0])?.trim();
1173
+ if (exported === symbol) {
1174
+ const local = bits[0]?.trim();
1175
+ if (local !== void 0 && called(source, local)) return true;
1176
+ }
1177
+ }
1178
+ }
1179
+ return false;
1180
+ }
1181
+ function classifySymbolUse(symbols, sources) {
1182
+ for (const symbol of symbols) {
1183
+ for (const source of sources.values()) {
1184
+ if (sourceCallsAffectedSymbol(source, symbol)) return "symbol-reachable";
1185
+ }
1186
+ }
1187
+ return "symbol-not-called";
1188
+ }
1189
+ var init_symbol_reachability = __esm({
1190
+ "src/modules/deps/symbol-reachability.ts"() {
1191
+ }
1192
+ });
1193
+
1080
1194
  // src/modules/deps/reachability.ts
1081
1195
  function packageNameFromSnippet(snippet) {
1082
1196
  if (snippet.startsWith("@")) {
@@ -1106,9 +1220,9 @@ function isProductionSource(rel) {
1106
1220
  }
1107
1221
  function indexPackageUsage(sources) {
1108
1222
  const usage = /* @__PURE__ */ new Map();
1109
- const bump = (pkg, called) => {
1223
+ const bump = (pkg, called2) => {
1110
1224
  const prev = usage.get(pkg) ?? { called: false };
1111
- usage.set(pkg, { imported: true, called: prev.called || called });
1225
+ usage.set(pkg, { imported: true, called: prev.called || called2 });
1112
1226
  };
1113
1227
  const nsRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
1114
1228
  const namedRe = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
@@ -1164,6 +1278,29 @@ function shortestDependencyPath(target, starts, edges) {
1164
1278
  }
1165
1279
  return void 0;
1166
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
+ }
1167
1304
  function reachableFrom(target, roots, edges) {
1168
1305
  if (roots.has(target)) return true;
1169
1306
  return shortestDependencyPath(target, roots, edges) !== void 0;
@@ -1184,9 +1321,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
1184
1321
  }
1185
1322
  if (graph !== void 0 && graph.edgesKnown) {
1186
1323
  const roots = importedDirectNames(productionUsage, graph);
1187
- const path = shortestDependencyPath(pkgName, roots, graph.edges);
1324
+ const paths = collectDependencyPaths(pkgName, roots, graph.edges);
1325
+ const path = paths[0];
1188
1326
  if (path !== void 0 && path.length >= 2) {
1189
- return { reachability: "reachable-via-dependency", path };
1327
+ return { reachability: "reachable-via-dependency", path, paths };
1190
1328
  }
1191
1329
  return { reachability: "not-imported" };
1192
1330
  }
@@ -1196,10 +1334,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
1196
1334
  return { reachability: "not-imported" };
1197
1335
  }
1198
1336
  function reachabilitySteps(reachability) {
1199
- if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown") {
1337
+ if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
1200
1338
  return 0;
1201
1339
  }
1202
- if (reachability === "imported-not-called") return 1;
1340
+ if (reachability === "imported-not-called" || reachability === "symbol-not-called") return 1;
1203
1341
  return 2;
1204
1342
  }
1205
1343
  function downgradeSeverity(severity, steps) {
@@ -1216,8 +1354,99 @@ function isDevOnly(pkgName, ctx) {
1216
1354
  if (inProd) return false;
1217
1355
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
1218
1356
  }
1219
- function applyReachability(findings, ctx) {
1220
- return findings.map((finding6) => {
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
+ }
1448
+ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1449
+ const mapped = findings.map((finding6) => {
1221
1450
  if (finding6.ruleId === "DEPS-MALICIOUS-001") {
1222
1451
  if (finding6.snippet === void 0) return finding6;
1223
1452
  const pkg2 = packageNameFromSnippet(finding6.snippet);
@@ -1227,19 +1456,25 @@ function applyReachability(findings, ctx) {
1227
1456
  ...finding6,
1228
1457
  severity: "critical",
1229
1458
  reachability: classified2.reachability,
1230
- ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
1459
+ ...pathFields(classified2)
1231
1460
  };
1232
1461
  }
1233
1462
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
1234
1463
  const pkg = packageNameFromSnippet(finding6.snippet);
1235
1464
  if (pkg === void 0 || pkg.length === 0) return finding6;
1236
- const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
1465
+ let classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
1466
+ const named = symbols.get(finding6.id) ?? [];
1467
+ if (named.length > 0 && ctx.sources !== void 0) {
1468
+ const symbolClass = classifySymbolUse(named, ctx.sources);
1469
+ classified = { reachability: symbolClass, path: classified.path, paths: classified.paths };
1470
+ }
1237
1471
  const steps = reachabilitySteps(classified.reachability);
1238
1472
  let severity = steps === 0 ? finding6.severity : downgradeSeverity(finding6.severity, steps);
1239
1473
  let message = finding6.message;
1240
1474
  if (classified.path !== void 0) {
1241
1475
  const shown = classified.path.join(" > ");
1242
1476
  message = `${message} Reachable via ${shown}.`;
1477
+ message = appendOtherPaths(message, classified.paths);
1243
1478
  }
1244
1479
  const devOnly = isDevOnly(pkg, ctx);
1245
1480
  if (devOnly) {
@@ -1249,15 +1484,27 @@ function applyReachability(findings, ctx) {
1249
1484
  return {
1250
1485
  ...finding6,
1251
1486
  reachability: classified.reachability,
1252
- ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
1487
+ ...pathFields(classified),
1253
1488
  severity,
1254
1489
  message
1255
1490
  };
1256
1491
  });
1492
+ return groupPackageAdvisories(mapped);
1257
1493
  }
1494
+ var REACH_RANK;
1258
1495
  var init_reachability = __esm({
1259
1496
  "src/modules/deps/reachability.ts"() {
1260
1497
  init_finding();
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
+ };
1261
1508
  }
1262
1509
  });
1263
1510
 
@@ -3185,6 +3432,62 @@ var init_experimental2 = __esm({
3185
3432
  "src/supply/experimental.ts"() {
3186
3433
  }
3187
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
+ });
3188
3491
 
3189
3492
  // src/fixer/eol.ts
3190
3493
  function detectEol(text) {
@@ -3350,7 +3653,7 @@ function walkJs(dir, out = []) {
3350
3653
  }
3351
3654
  return out;
3352
3655
  }
3353
- function readJson(path) {
3656
+ function readJson2(path) {
3354
3657
  return JSON.parse(readFileSync(path, "utf8"));
3355
3658
  }
3356
3659
  function installScriptKeys(scripts) {
@@ -3361,7 +3664,7 @@ function analyzePackageDir(dir) {
3361
3664
  const findings = [];
3362
3665
  const manifestPath = join(dir, "package.json");
3363
3666
  if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) return findings;
3364
- const pkg = readJson(manifestPath);
3667
+ const pkg = readJson2(manifestPath);
3365
3668
  const scripts = pkg.scripts ?? {};
3366
3669
  const keys = installScriptKeys(scripts);
3367
3670
  if (keys.length > 0) {
@@ -3411,8 +3714,8 @@ function analyzePackageDir(dir) {
3411
3714
  return findings;
3412
3715
  }
3413
3716
  function diffPackageVersions(oldDir, newDir) {
3414
- const oldManifest = existsSync(join(oldDir, "package.json")) ? readJson(join(oldDir, "package.json")) : {};
3415
- 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")) : {};
3416
3719
  const oldScripts = oldManifest.scripts ?? {};
3417
3720
  const nextScripts = nextManifest.scripts ?? {};
3418
3721
  const added = INSTALL_SCRIPT_KEYS.filter((k) => typeof nextScripts[k] === "string" && oldScripts[k] === void 0);
@@ -3453,14 +3756,24 @@ function checkLockfileIntegrity(lock) {
3453
3756
  if (name === "") continue;
3454
3757
  const resolved = entry.resolved;
3455
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
+ }
3456
3769
  try {
3457
- const host = new URL(resolved).hostname;
3458
- if (host !== REGISTRY_HOST) {
3770
+ const url = new URL(resolved);
3771
+ if (url.protocol !== "https:" || url.hostname !== REGISTRY_HOST) {
3459
3772
  findings.push(
3460
3773
  finding(
3461
3774
  "SUPPLY-LOCKURL-001",
3462
3775
  "Lockfile entry resolves from a non-npm registry host",
3463
- `${name} resolved host ${host}`,
3776
+ `${name} resolved host ${url.hostname || resolved}`,
3464
3777
  "package-lock.json"
3465
3778
  )
3466
3779
  );
@@ -3523,7 +3836,7 @@ function packageSources(dir) {
3523
3836
  let scripts = "";
3524
3837
  if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
3525
3838
  try {
3526
- const pkg = readJson(manifestPath);
3839
+ const pkg = readJson2(manifestPath);
3527
3840
  const block = pkg.scripts ?? {};
3528
3841
  scripts = Object.values(block).join("\n");
3529
3842
  } catch {
@@ -3822,7 +4135,7 @@ async function reachabilityCtx(cwd, files, graph) {
3822
4135
  } catch {
3823
4136
  }
3824
4137
  }
3825
- return { productionUsage: indexPackageUsage(prodSources), graph };
4138
+ return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
3826
4139
  }
3827
4140
  var LOCKFILE_NAMES, MANIFEST_FIELDS2, MALICIOUS_REMEDIATION, depsModule;
3828
4141
  var init_deps = __esm({
@@ -3832,9 +4145,12 @@ var init_deps = __esm({
3832
4145
  init_osv();
3833
4146
  init_pins();
3834
4147
  init_reachability();
4148
+ init_symbol_reachability();
3835
4149
  init_semver();
3836
4150
  init_typosquat();
3837
4151
  init_experimental2();
4152
+ init_experimental();
4153
+ init_license();
3838
4154
  init_phase31();
3839
4155
  LOCKFILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
3840
4156
  MANIFEST_FIELDS2 = ["dependencies", "devDependencies", "optionalDependencies"];
@@ -3871,14 +4187,17 @@ var init_deps = __esm({
3871
4187
  findings2.push(typosquatFinding("package.json", hit));
3872
4188
  }
3873
4189
  const osvHits2 = await queryOsv(pins, osvOpts);
4190
+ const symbols2 = /* @__PURE__ */ new Map();
3874
4191
  for (const hit of osvHits2) {
3875
4192
  for (const vuln of hit.vulns) {
3876
- findings2.push(
3877
- isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name))
3878
- );
4193
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
4194
+ const names = affectedFunctionNames(vuln);
4195
+ if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols2.set(finding6.id, names);
4196
+ findings2.push(finding6);
3879
4197
  }
3880
4198
  }
3881
- return applyReachability(findings2, await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest)));
4199
+ const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4200
+ return applyReachability(findings2, noLockCtx, symbols2);
3882
4201
  }
3883
4202
  let contents;
3884
4203
  try {
@@ -3899,18 +4218,21 @@ var init_deps = __esm({
3899
4218
  }
3900
4219
  }
3901
4220
  const osvHits = await queryOsv(packages, osvOpts);
4221
+ const symbols = /* @__PURE__ */ new Map();
3902
4222
  for (const hit of osvHits) {
3903
4223
  for (const vuln of hit.vulns) {
3904
- findings.push(
3905
- isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name))
3906
- );
4224
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4225
+ const names = affectedFunctionNames(vuln);
4226
+ if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols.set(finding6.id, names);
4227
+ findings.push(finding6);
3907
4228
  }
3908
4229
  }
3909
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
4230
+ let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
3910
4231
  if (experimentalVex()) {
3911
4232
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
3912
4233
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
3913
4234
  }
4235
+ if (licenseEnabled()) reached.push(...await scanCopyleftLicenses(ctx.cwd));
3914
4236
  return reached;
3915
4237
  }
3916
4238
  };
@@ -4489,6 +4811,14 @@ function isXssSanitizerCall(node) {
4489
4811
  }
4490
4812
  return false;
4491
4813
  }
4814
+ function enclosingClass(node) {
4815
+ let current = node.parent;
4816
+ while (current !== void 0) {
4817
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
4818
+ current = current.parent;
4819
+ }
4820
+ return void 0;
4821
+ }
4492
4822
  function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4493
4823
  if (node === void 0) return false;
4494
4824
  if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
@@ -4500,8 +4830,34 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4500
4830
  case "Identifier":
4501
4831
  return tainted.has(node.name);
4502
4832
  case "MemberExpression":
4833
+ if (taintV2Enabled() && node.object.type === "ThisExpression") {
4834
+ const cls = enclosingClass(node);
4835
+ const name = propName(node);
4836
+ if (cls !== void 0 && name !== void 0 && taintedThisFields.get(cls)?.has(name) === true) {
4837
+ return true;
4838
+ }
4839
+ }
4503
4840
  return exprIsTainted(node.object, tainted, returners);
4841
+ case "ArrayExpression":
4842
+ if (!taintV2Enabled()) return false;
4843
+ return node.elements.some((el) => {
4844
+ if (el === null) return false;
4845
+ if (el.type === "SpreadElement") return exprIsTainted(el.argument, tainted, returners);
4846
+ return exprIsTainted(el, tainted, returners);
4847
+ });
4848
+ case "ObjectExpression":
4849
+ if (!taintV2Enabled()) return false;
4850
+ return node.properties.some((prop) => {
4851
+ if (prop.type === "SpreadElement") return exprIsTainted(prop.argument, tainted, returners);
4852
+ if (prop.type !== "Property") return false;
4853
+ return exprIsTainted(prop.value, tainted, returners);
4854
+ });
4504
4855
  case "CallExpression":
4856
+ if (taintV2Enabled() && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
4857
+ return node.arguments.some(
4858
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
4859
+ );
4860
+ }
4505
4861
  if (isSchemaValidatorCall(node)) return false;
4506
4862
  if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
4507
4863
  if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
@@ -4731,7 +5087,66 @@ function scopeReturnsTaint(scope, tainted, returners) {
4731
5087
  });
4732
5088
  return yes;
4733
5089
  }
4734
- function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5090
+ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5091
+ let changed = false;
5092
+ const mark = (fn, name) => {
5093
+ if (name === void 0) return;
5094
+ const set = seeds.get(fn) ?? /* @__PURE__ */ new Set();
5095
+ if (set.has(name)) return;
5096
+ set.add(name);
5097
+ seeds.set(fn, set);
5098
+ changed = true;
5099
+ };
5100
+ for (const fn of functions) {
5101
+ const f = facts.get(fn);
5102
+ if (f === void 0) continue;
5103
+ let parent = fn.parent;
5104
+ while (parent !== void 0 && !isFunctionNode(parent)) {
5105
+ parent = parent.parent;
5106
+ }
5107
+ if (parent !== void 0 && isFunctionNode(parent)) {
5108
+ const outer = facts.get(parent);
5109
+ if (outer !== void 0) {
5110
+ for (const name of outer.tainted) mark(fn, name);
5111
+ }
5112
+ }
5113
+ walkScope(fn, (node) => {
5114
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") {
5115
+ if (node.left.object.type === "ThisExpression" && exprIsTainted(node.right, f.tainted, returners)) {
5116
+ const cls = enclosingClass(node);
5117
+ const field = propName(node.left);
5118
+ if (cls !== void 0 && field !== void 0) {
5119
+ const fields = taintedThisFields.get(cls) ?? /* @__PURE__ */ new Set();
5120
+ if (!fields.has(field)) {
5121
+ fields.add(field);
5122
+ taintedThisFields.set(cls, fields);
5123
+ changed = true;
5124
+ }
5125
+ }
5126
+ }
5127
+ }
5128
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5129
+ const method = propName(node.callee);
5130
+ if (method === void 0) return;
5131
+ if (CALLBACK_METHODS.has(method) && exprIsTainted(node.callee.object, f.tainted, returners)) {
5132
+ const callback = node.arguments[0];
5133
+ if (callback !== void 0 && isFunctionNode(callback)) {
5134
+ mark(callback, boundNames(callback.params[0])[0]);
5135
+ }
5136
+ }
5137
+ if ((method === "call" || method === "apply") && node.callee.object.type === "Identifier") {
5138
+ const target = nameToFn.get(node.callee.object.name);
5139
+ if (target === void 0 || !isFunctionNode(target)) return;
5140
+ const arg = node.arguments[1];
5141
+ if (arg !== void 0 && arg.type !== "SpreadElement" && exprIsTainted(arg, f.tainted, returners)) {
5142
+ mark(target, boundNames(target.params[0])[0]);
5143
+ }
5144
+ }
5145
+ });
5146
+ }
5147
+ return changed;
5148
+ }
5149
+ function analyzeFile(ast, extraReturners2 = NO_RETURNERS) {
4735
5150
  attachParents(ast);
4736
5151
  const facts = /* @__PURE__ */ new WeakMap();
4737
5152
  const functions = [];
@@ -4745,7 +5160,7 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4745
5160
  }
4746
5161
  const seeds = /* @__PURE__ */ new Map();
4747
5162
  for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
4748
- const returners = new Set(extraReturners);
5163
+ const returners = new Set(extraReturners2);
4749
5164
  for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
4750
5165
  let changed = false;
4751
5166
  facts.set(ast, analyzeScope(ast, [], returners));
@@ -4785,6 +5200,9 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4785
5200
  });
4786
5201
  });
4787
5202
  }
5203
+ if (taintV2Enabled() && seedTaintV2(functions, facts, seeds, returners, nameToFn)) {
5204
+ changed = true;
5205
+ }
4788
5206
  if (!changed) break;
4789
5207
  }
4790
5208
  return facts;
@@ -4843,7 +5261,7 @@ function nodeIsNosqlSanitized(node, map) {
4843
5261
  if (node.type === "Identifier") return factsFor(node, map).nosqlSanitized.has(node.name);
4844
5262
  return false;
4845
5263
  }
4846
- var SCHEMA_ROOT, NO_RETURNERS, MAX_TAINT_HOPS;
5264
+ var SCHEMA_ROOT, NO_RETURNERS, CALLBACK_METHODS, taintedThisFields, MAX_TAINT_HOPS;
4847
5265
  var init_taint = __esm({
4848
5266
  "src/modules/sast/taint.ts"() {
4849
5267
  init_ast();
@@ -4852,6 +5270,18 @@ var init_taint = __esm({
4852
5270
  init_mcp2();
4853
5271
  SCHEMA_ROOT = /^(z|zod|joi|yup|schema)$/i;
4854
5272
  NO_RETURNERS = /* @__PURE__ */ new Set();
5273
+ CALLBACK_METHODS = /* @__PURE__ */ new Set([
5274
+ "then",
5275
+ "catch",
5276
+ "finally",
5277
+ "map",
5278
+ "forEach",
5279
+ "filter",
5280
+ "flatMap",
5281
+ "find",
5282
+ "reduce"
5283
+ ]);
5284
+ taintedThisFields = /* @__PURE__ */ new WeakMap();
4855
5285
  MAX_TAINT_HOPS = 5;
4856
5286
  }
4857
5287
  });
@@ -4936,6 +5366,21 @@ function requireCall(node) {
4936
5366
  }
4937
5367
  return void 0;
4938
5368
  }
5369
+ function reexportsOf(ast) {
5370
+ const out = /* @__PURE__ */ new Map();
5371
+ walk(ast, (node) => {
5372
+ if (node.type !== "ExportNamedDeclaration" || node.source === null || node.source === void 0) return;
5373
+ const spec = literalString(node.source);
5374
+ if (spec === void 0) return;
5375
+ for (const specNode of node.specifiers) {
5376
+ if (specNode.type !== "ExportSpecifier") continue;
5377
+ const exported = specNode.exported.type === "Identifier" ? specNode.exported.name : void 0;
5378
+ const local = specNode.local.type === "Identifier" ? specNode.local.name : exported;
5379
+ if (exported !== void 0 && local !== void 0) out.set(exported, { spec, name: local });
5380
+ }
5381
+ });
5382
+ return out;
5383
+ }
4939
5384
  function resolveRelative(fromFile, spec, files) {
4940
5385
  if (!spec.startsWith(".")) return void 0;
4941
5386
  const base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
@@ -5013,6 +5458,22 @@ function runInterproc(parsed) {
5013
5458
  functions.set(`${fn.file}#${fn.name}`, fn);
5014
5459
  }
5015
5460
  }
5461
+ if (taintV2Enabled()) {
5462
+ const barrels = /* @__PURE__ */ new Map();
5463
+ for (const p of parsed) barrels.set(p.file, reexportsOf(p.ast));
5464
+ for (const ns of requires.values()) {
5465
+ for (const [local, target] of ns) {
5466
+ const hash = target.indexOf("#");
5467
+ if (hash < 0) continue;
5468
+ const file = target.slice(0, hash);
5469
+ const name = target.slice(hash + 1);
5470
+ const re = barrels.get(file)?.get(name);
5471
+ if (re === void 0) continue;
5472
+ const resolved = resolveRelative(file, re.spec, files);
5473
+ if (resolved !== void 0) ns.set(local, `${resolved}#${re.name}`);
5474
+ }
5475
+ }
5476
+ }
5016
5477
  const queue = [];
5017
5478
  for (const p of parsed) {
5018
5479
  const facts = analyzeFile(p.ast);
@@ -5149,6 +5610,7 @@ var init_interproc = __esm({
5149
5610
  "src/modules/sast/interproc.ts"() {
5150
5611
  init_finding();
5151
5612
  init_ast();
5613
+ init_experimental();
5152
5614
  init_taint();
5153
5615
  MAX_HOPS = 5;
5154
5616
  }
@@ -5546,7 +6008,10 @@ function isMongoReceiver(node, source) {
5546
6008
  const obj = node.callee.object;
5547
6009
  if (obj.type === "Identifier") {
5548
6010
  if (isCollectionName(obj.name)) return true;
5549
- if (isPascalModel(obj.name)) return !fileHasSqlOrm;
6011
+ if (isPascalModel(obj.name)) {
6012
+ if (/extends\s+BaseEntity\b/.test(source)) return false;
6013
+ return !fileHasSqlOrm;
6014
+ }
5550
6015
  return fileHasMongo;
5551
6016
  }
5552
6017
  if (obj.type === "MemberExpression") {
@@ -6686,6 +7151,150 @@ var init_injection = __esm({
6686
7151
  PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
6687
7152
  }
6688
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
+ });
7192
+ function packageVersion() {
7193
+ let dir = dirname(fileURLToPath(import.meta.url));
7194
+ for (let depth = 0; depth < 6; depth += 1) {
7195
+ try {
7196
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
7197
+ const pkg = JSON.parse(raw);
7198
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7199
+ return pkg.version;
7200
+ }
7201
+ } catch {
7202
+ }
7203
+ const parent = dirname(dir);
7204
+ if (parent === dir) break;
7205
+ dir = parent;
7206
+ }
7207
+ throw new Error("fullshield package.json not found");
7208
+ }
7209
+ var init_version = __esm({
7210
+ "src/core/version.ts"() {
7211
+ }
7212
+ });
7213
+ function parsePoolEnabled() {
7214
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
7215
+ }
7216
+ function parsePoolSize() {
7217
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7218
+ if (raw !== void 0 && raw !== "") {
7219
+ const parsed = Number(raw);
7220
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.min(4, Math.floor(parsed));
7221
+ }
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;
7229
+ }
7230
+ function parseCacheKey(code) {
7231
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
7232
+ }
7233
+ function parseOne(code) {
7234
+ const key = parseCacheKey(code);
7235
+ const hit = cache.get(key);
7236
+ if (hit !== void 0) return Promise.resolve(hit);
7237
+ return new Promise((resolve8, reject) => {
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;
7249
+ cache.set(key, ast);
7250
+ finish();
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"}`));
7268
+ });
7269
+ });
7270
+ }
7271
+ async function parseWithPool(codes) {
7272
+ const size = parsePoolSize();
7273
+ const out = new Array(codes.length);
7274
+ let next = 0;
7275
+ async function run() {
7276
+ for (; ; ) {
7277
+ const index = next;
7278
+ next += 1;
7279
+ if (index >= codes.length) return;
7280
+ const code = codes[index];
7281
+ if (code === void 0) return;
7282
+ out[index] = await parseOne(code);
7283
+ }
7284
+ }
7285
+ const workers = Math.min(size, Math.max(1, codes.length));
7286
+ await Promise.all(Array.from({ length: workers }, () => run()));
7287
+ return out;
7288
+ }
7289
+ var workerUrl, cache;
7290
+ var init_parse_pool = __esm({
7291
+ "src/core/parse-pool.ts"() {
7292
+ init_lru();
7293
+ init_version();
7294
+ workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7295
+ cache = new LruMap(64);
7296
+ }
7297
+ });
6689
7298
  async function parseSource(code) {
6690
7299
  const { parse } = await import('@typescript-eslint/typescript-estree');
6691
7300
  return parse(code, {
@@ -6701,8 +7310,8 @@ function snippetOf(source, node) {
6701
7310
  if (node.range === void 0) return "";
6702
7311
  return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
6703
7312
  }
6704
- function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
6705
- const facts = analyzeFile(ast, extraReturners);
7313
+ function findingsFromAst(filename, source, ast, extraReturners2 = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
7314
+ const facts = analyzeFile(ast, extraReturners2);
6706
7315
  const findings = [];
6707
7316
  for (const rule of sastRules) {
6708
7317
  const ctx = {
@@ -6780,6 +7389,158 @@ function findingsFromTemplate(filename, source) {
6780
7389
  xssRule.create(ctx);
6781
7390
  return findings;
6782
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
+ }
6783
7544
  var sastRules, SOURCE_EXT, sastModule;
6784
7545
  var init_engine = __esm({
6785
7546
  "src/modules/sast/engine.ts"() {
@@ -6801,6 +7562,7 @@ var init_engine = __esm({
6801
7562
  init_xss();
6802
7563
  init_phase29();
6803
7564
  init_injection();
7565
+ init_parse_pool();
6804
7566
  sastRules = [
6805
7567
  sqliRule,
6806
7568
  cmdiRule,
@@ -6820,48 +7582,48 @@ var init_engine = __esm({
6820
7582
  sastModule = {
6821
7583
  name: "sast",
6822
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;
6823
7588
  const findings = [];
6824
7589
  const parsed = [];
6825
- for (const rel of ctx.files) {
6826
- const isSource = SOURCE_EXT.test(rel);
6827
- const isTemplate = TEMPLATE_EXT.test(rel);
6828
- if (!isSource && !isTemplate) continue;
6829
- let source;
6830
- try {
6831
- source = await readFile(join(ctx.cwd, rel), "utf8");
6832
- } 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}.`));
6833
7596
  continue;
6834
7597
  }
6835
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
6836
- if (isTemplate) {
7598
+ if (TEMPLATE_EXT.test(rel)) {
6837
7599
  findings.push(...findingsFromTemplate(rel, source));
6838
7600
  continue;
6839
7601
  }
6840
- try {
6841
- const ast = await parseSource(source);
6842
- parsed.push({ file: rel, source, ast });
6843
- } catch {
6844
- }
7602
+ const ast = await parseCode(source);
7603
+ if (ast === void 0) continue;
7604
+ parsed.push({ file: rel, source, ast });
6845
7605
  }
6846
7606
  const files = new Set(parsed.map((p) => p.file));
6847
7607
  const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
6848
7608
  const returning = /* @__PURE__ */ new Map();
6849
- for (const p of parsed) {
6850
- returning.set(p.file, functionsThatReturnTaint(p.ast));
7609
+ for (const entry of parsed) {
7610
+ returning.set(entry.file, functionsThatReturnTaint(entry.ast));
6851
7611
  }
6852
- for (const p of parsed) {
6853
- const ns = collectRequires(p.file, p.ast, files);
6854
- const extra = new Set(returning.get(p.file));
6855
- for (const [local, spec] of ns) {
6856
- const hash = spec.indexOf("#");
6857
- const file = hash >= 0 ? spec.slice(0, hash) : spec;
6858
- const exp = hash >= 0 ? spec.slice(hash + 1) : local;
6859
- if (returning.get(file)?.has(exp)) extra.add(local);
6860
- }
6861
- 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
+ );
6862
7622
  }
6863
7623
  findings.push(...runInterproc(parsed));
6864
- return findings;
7624
+ const out = dedupeSastSinks(findings);
7625
+ releaseParsed(parsed);
7626
+ return out;
6865
7627
  }
6866
7628
  };
6867
7629
  }
@@ -7690,7 +8452,7 @@ function createAiClient(options) {
7690
8452
  const useFixtures = options.useFixtures ?? !isLiveAi();
7691
8453
  const model = options.model ?? process.env.FULLSHIELD_AI_MODEL ?? (useFixtures ? "recorded-fixture" : "qwen2.5-coder:7b");
7692
8454
  const provider = options.provider ?? process.env.FULLSHIELD_AI_PROVIDER ?? (useFixtures ? "fixture" : "ollama");
7693
- const cache = /* @__PURE__ */ new Map();
8455
+ const cache2 = /* @__PURE__ */ new Map();
7694
8456
  let spentUsd = 0;
7695
8457
  let networkCalls = 0;
7696
8458
  const liveCost = provider === "ollama" ? 0 : FIXTURE_COST;
@@ -7710,7 +8472,7 @@ function createAiClient(options) {
7710
8472
  const redactedPrompt = `${modeInstruction(req.mode)}
7711
8473
  ${wrapUntrustedCode(req.prompt)}`;
7712
8474
  const key = cacheKey(req.model ?? model, redactedPrompt);
7713
- const cached = cache.get(key);
8475
+ const cached = cache2.get(key);
7714
8476
  if (cached !== void 0) {
7715
8477
  return { ...cached, cached: true, costUsd: 0 };
7716
8478
  }
@@ -7724,7 +8486,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7724
8486
  cached: false,
7725
8487
  redactedPrompt
7726
8488
  };
7727
- cache.set(key, response2);
8489
+ cache2.set(key, response2);
7728
8490
  return response2;
7729
8491
  }
7730
8492
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -7774,7 +8536,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7774
8536
  cached: false,
7775
8537
  redactedPrompt
7776
8538
  };
7777
- cache.set(key, response);
8539
+ cache2.set(key, response);
7778
8540
  return response;
7779
8541
  }
7780
8542
  };
@@ -7787,16 +8549,37 @@ var init_provider = __esm({
7787
8549
  FIXTURE_COST = 0.01;
7788
8550
  }
7789
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
+ }
7790
8571
  async function readBaseline(path) {
7791
8572
  try {
7792
8573
  const raw = JSON.parse(await readFile(path, "utf8"));
7793
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;
7794
8576
  return raw;
7795
8577
  } catch {
7796
8578
  return void 0;
7797
8579
  }
7798
8580
  }
7799
8581
  async function writeBaseline(path, findings) {
8582
+ const existing = await readBaseline(path);
7800
8583
  const doc = {
7801
8584
  version: 1,
7802
8585
  findings: findings.map((f) => ({
@@ -7804,7 +8587,8 @@ async function writeBaseline(path, findings) {
7804
8587
  ruleId: f.ruleId,
7805
8588
  file: f.file,
7806
8589
  line: f.line
7807
- }))
8590
+ })),
8591
+ ...existing?.suppressions !== void 0 ? { suppressions: existing.suppressions } : {}
7808
8592
  };
7809
8593
  await writeFile(path, `${JSON.stringify(doc, null, 2)}
7810
8594
  `, "utf8");
@@ -7814,6 +8598,30 @@ function applyBaseline(findings, baseline) {
7814
8598
  const known = new Set(baseline.findings.map((f) => f.id));
7815
8599
  return findings.filter((f) => !known.has(f.id));
7816
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
+ }
7817
8625
  var init_baseline = __esm({
7818
8626
  "src/core/baseline.ts"() {
7819
8627
  }
@@ -7919,6 +8727,32 @@ function validate(raw, source) {
7919
8727
  }
7920
8728
  out.ai = ai;
7921
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
+ }
7922
8756
  return out;
7923
8757
  }
7924
8758
  async function loadConfig(cwd, configPath) {
@@ -7953,19 +8787,35 @@ var init_config2 = __esm({
7953
8787
  modules: [...MODULE_NAMES],
7954
8788
  failOn: "high",
7955
8789
  ignore: ["dist/**", "**/*.test.ts"],
8790
+ defaultExcludes: true,
7956
8791
  baseline: ".fullshield-baseline.json",
7957
8792
  notify: { slackWebhookEnv: "FULLSHIELD_SLACK_WEBHOOK" },
7958
8793
  deps: { offline: false },
7959
- ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 }
8794
+ ai: { enabled: false, maxCostUsd: 1, maxFindings: 50 },
8795
+ sast: { maxFileBytes: 1024 * 1024 }
7960
8796
  };
7961
8797
  ConfigError = class extends Error {
7962
8798
  };
7963
- 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
+ ]);
7964
8811
  }
7965
8812
  });
7966
8813
  async function walkFiles(options) {
7967
8814
  const ig = ignoreFactory();
7968
- ig.add(ALWAYS_IGNORED);
8815
+ ig.add(HARD_IGNORED);
8816
+ if (options.defaultExcludes !== false) {
8817
+ ig.add(options.excludes ?? DEFAULT_EXCLUDES);
8818
+ }
7969
8819
  try {
7970
8820
  ig.add(await readFile(join(options.cwd, ".gitignore"), "utf8"));
7971
8821
  } catch {
@@ -7991,11 +8841,24 @@ async function walkFiles(options) {
7991
8841
  await walk5("");
7992
8842
  return files.sort();
7993
8843
  }
7994
- var ignoreFactory, ALWAYS_IGNORED;
8844
+ var ignoreFactory, HARD_IGNORED, DEFAULT_EXCLUDES;
7995
8845
  var init_fileWalker = __esm({
7996
8846
  "src/core/fileWalker.ts"() {
7997
8847
  ignoreFactory = ignoreModule.default;
7998
- 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
+ ];
7999
8862
  }
8000
8863
  });
8001
8864
  async function listStagedFiles(cwd) {
@@ -8050,27 +8913,6 @@ var init_suppression = __esm({
8050
8913
  IGNORE = /fullshield-ignore\s+([A-Z0-9-]+)\s*:\s*(\S+)/;
8051
8914
  }
8052
8915
  });
8053
- function packageVersion() {
8054
- let dir = dirname(fileURLToPath(import.meta.url));
8055
- for (let depth = 0; depth < 6; depth += 1) {
8056
- try {
8057
- const raw = readFileSync(join(dir, "package.json"), "utf8");
8058
- const pkg = JSON.parse(raw);
8059
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
8060
- return pkg.version;
8061
- }
8062
- } catch {
8063
- }
8064
- const parent = dirname(dir);
8065
- if (parent === dir) break;
8066
- dir = parent;
8067
- }
8068
- throw new Error("fullshield package.json not found");
8069
- }
8070
- var init_version = __esm({
8071
- "src/core/version.ts"() {
8072
- }
8073
- });
8074
8916
 
8075
8917
  // src/policy/prSummary.ts
8076
8918
  function ruleDocsUrl(ruleId) {
@@ -8230,7 +9072,7 @@ function resolvePolicyBranch(cwd, env = process.env) {
8230
9072
  return "*";
8231
9073
  }
8232
9074
  }
8233
- function expiresAtMs(expires) {
9075
+ function expiresAtMs2(expires) {
8234
9076
  if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
8235
9077
  return Date.parse(`${expires}T23:59:59.999Z`);
8236
9078
  }
@@ -8252,7 +9094,7 @@ function applyPolicy(findings, policy, ctx = {}) {
8252
9094
  });
8253
9095
  const suppressed = /* @__PURE__ */ new Set();
8254
9096
  for (const exception of policy.exceptions ?? []) {
8255
- if (expiresAtMs(exception.expires) < now.getTime()) {
9097
+ if (expiresAtMs2(exception.expires) < now.getTime()) {
8256
9098
  errors.push(
8257
9099
  `policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
8258
9100
  );
@@ -8321,6 +9163,7 @@ var init_incremental = __esm({
8321
9163
  // src/modules/secrets/history.ts
8322
9164
  var history_exports = {};
8323
9165
  __export(history_exports, {
9166
+ historyCommitLimit: () => historyCommitLimit,
8324
9167
  scanGitHistory: () => scanGitHistory
8325
9168
  });
8326
9169
  function gitError(error) {
@@ -8363,7 +9206,13 @@ function remapHit(hit, file) {
8363
9206
  secret: hit.secret
8364
9207
  };
8365
9208
  }
8366
- 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) {
8367
9216
  const changes = [];
8368
9217
  await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
8369
9218
  const child = spawn(
@@ -8380,6 +9229,8 @@ async function streamLogChanges(cwd) {
8380
9229
  "--format=FSCOMMIT:%H",
8381
9230
  "--no-textconv",
8382
9231
  "--no-ext-diff",
9232
+ "-n",
9233
+ String(maxCommits),
8383
9234
  "HEAD"
8384
9235
  ],
8385
9236
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -8448,8 +9299,14 @@ async function headSecretKeys(cwd) {
8448
9299
  }
8449
9300
  return keys;
8450
9301
  }
8451
- async function scanGitHistory(cwd) {
8452
- 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);
8453
9310
  const blobHits = /* @__PURE__ */ new Map();
8454
9311
  const first = /* @__PURE__ */ new Map();
8455
9312
  for (const change of changes) {
@@ -8486,7 +9343,13 @@ async function scanGitHistory(cwd) {
8486
9343
  secret: hit.secret
8487
9344
  });
8488
9345
  }
8489
- 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
+ };
8490
9353
  }
8491
9354
  var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
8492
9355
  var init_history = __esm({
@@ -8781,7 +9644,12 @@ async function runScan(options = {}, modules = builtinModules) {
8781
9644
  options.modules ?? config.modules,
8782
9645
  loadedPolicy?.requiredModules
8783
9646
  );
8784
- 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
+ });
8785
9653
  if (options.staged === true) {
8786
9654
  const staged = new Set(await listStagedFiles(cwd));
8787
9655
  files = files.filter((file) => staged.has(file));
@@ -8815,6 +9683,7 @@ async function runScan(options = {}, modules = builtinModules) {
8815
9683
  );
8816
9684
  }
8817
9685
  const extraHits = [];
9686
+ let historyNotice;
8818
9687
  const secretsWanted = modulesRequested.includes("secrets");
8819
9688
  if (secretsAdvEnabled() && secretsWanted) {
8820
9689
  if (options.history === true) {
@@ -8822,6 +9691,7 @@ async function runScan(options = {}, modules = builtinModules) {
8822
9691
  const hist = await scanGitHistory2(cwd);
8823
9692
  extraHits.push(...hist.hits);
8824
9693
  collected.push(...hist.findings);
9694
+ historyNotice = hist.history;
8825
9695
  }
8826
9696
  if (options.bundle !== void 0 && options.bundle.length > 0) {
8827
9697
  const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
@@ -8842,7 +9712,11 @@ async function runScan(options = {}, modules = builtinModules) {
8842
9712
  if (options.updateBaseline === true) {
8843
9713
  await writeBaseline(baselinePath, unsuppressed);
8844
9714
  }
8845
- 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;
8846
9720
  if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
8847
9721
  const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
8848
9722
  const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
@@ -8914,10 +9788,12 @@ async function runScan(options = {}, modules = builtinModules) {
8914
9788
  cwd,
8915
9789
  filesScanned: files.length,
8916
9790
  modulesRequested: [...modulesRequested],
8917
- modulesRun
9791
+ modulesRun,
9792
+ ...historyNotice !== void 0 ? { history: historyNotice } : {}
8918
9793
  },
8919
9794
  config,
8920
- policy: policyResult
9795
+ policy: policyResult,
9796
+ ...suppressionErrors.length > 0 ? { suppressionErrors } : {}
8921
9797
  };
8922
9798
  }
8923
9799
  function scan(options = {}) {
@@ -9233,6 +10109,32 @@ function finding4(ruleId, title, message, file, severity = "high") {
9233
10109
  remediation: "Pin actions by SHA, drop pull_request_target, tighten permissions, close 0.0.0.0/0, encrypt storage, drop privileged."
9234
10110
  };
9235
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
+ }
9236
10138
  function walk3(dir, out = []) {
9237
10139
  if (!existsSync(dir)) return out;
9238
10140
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -9260,6 +10162,28 @@ function scanIac(dir, opts = {}) {
9260
10162
  if (/pull_request_target/.test(text)) {
9261
10163
  findings.push(finding4("IAC-GHA-PRTARGET-001", "pull_request_target workflow", "untrusted PR can run with base secrets", rel, "critical"));
9262
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
+ }
9263
10187
  if (/permissions:\s*\n\s+contents:\s*write/.test(text) || /permissions:\s+write-all/.test(text)) {
9264
10188
  findings.push(finding4("IAC-GHA-PERMS-001", "Overly broad GitHub Actions permissions", "write-all or contents: write", rel));
9265
10189
  }
@@ -9410,6 +10334,7 @@ var init_compliance = __esm({
9410
10334
  ROW("SUPPLY-BEHAVIOR-001", "A06:2021 Vulnerable Components", "CWE-829", "V14.2.1", "PCI DSS 6.3.3"),
9411
10335
  ROW("SUPPLY-COOLDOWN-001", "A06:2021 Vulnerable Components", "CWE-1104", "V14.2.1", "PCI DSS 6.3.3"),
9412
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"),
9413
10338
  ROW("SUPPLY-CONFUSION-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9414
10339
  ROW("SUPPLY-PROVENANCE-001", "A08:2021 Integrity Failures", "CWE-494", "V14.2.3", "PCI DSS 6.3.3"),
9415
10340
  ROW("SUPPLY-WORM-001", "A06:2021 Vulnerable Components", "CWE-506", "V14.2.1", "PCI DSS 6.3.3 / 12.10"),
@@ -9418,6 +10343,9 @@ var init_compliance = __esm({
9418
10343
  ROW("IAC-GHA-PRTARGET-001", "A01:2021 Broken Access Control", "CWE-269", "V4.1.1", "PCI DSS 7.2"),
9419
10344
  ROW("IAC-GHA-PERMS-001", "A01:2021 Broken Access Control", "CWE-250", "V4.1.1", "PCI DSS 7.2"),
9420
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"),
9421
10349
  ROW("IAC-TF-S3PUBLIC-001", "A01:2021 Broken Access Control", "CWE-284", "V12.1.1", "PCI DSS 1.3 / 3.2"),
9422
10350
  ROW("IAC-TF-SGOPEN-001", "A05:2021 Security Misconfiguration", "CWE-284", "V1.4.1", "PCI DSS 1.2 / 1.3"),
9423
10351
  ROW("IAC-TF-UNENCRYPTED-001", "A02:2021 Cryptographic Failures", "CWE-311", "V6.2.1", "PCI DSS 3.4 / 4.2"),
@@ -9444,6 +10372,7 @@ var init_compliance = __esm({
9444
10372
  ROW("SAST-TIMING-001", "A07:2021 Identification Failures", "CWE-208", "V2.10.3", "PCI DSS 8.3"),
9445
10373
  ROW("SAST-HASH-001", "A02:2021 Cryptographic Failures", "CWE-916", "V6.2.2", "PCI DSS 8.3.1"),
9446
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"),
9447
10376
  ROW("CONFIG-TRUSTPROXY-001", "A07:2021 Identification Failures", "CWE-290", "V2.2.1", "PCI DSS 8.3.4"),
9448
10377
  ROW("SECRET-BUNDLE-001", "A02:2021 Cryptographic Failures", "CWE-798", "V6.4.1", "PCI DSS 3.2 / 8.3"),
9449
10378
  ROW("DLP-SECRET-001", "A02:2021 Cryptographic Failures", "CWE-200", "V8.3.4", "PCI DSS 3.2 / 8.3"),
@@ -9463,6 +10392,23 @@ var init_compliance = __esm({
9463
10392
  byId = new Map(RULE_MAP.map((r) => [r.ruleId, r]));
9464
10393
  }
9465
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
+ });
9466
10412
 
9467
10413
  // src/dashboard/index.ts
9468
10414
  var dashboard_exports = {};
@@ -9476,7 +10422,7 @@ __export(dashboard_exports, {
9476
10422
  startDashboard: () => startDashboard
9477
10423
  });
9478
10424
  function sqliteDb(file) {
9479
- const { DatabaseSync } = require2("node:sqlite");
10425
+ const { DatabaseSync } = require3("node:sqlite");
9480
10426
  let db;
9481
10427
  try {
9482
10428
  db = new DatabaseSync(file);
@@ -9527,6 +10473,9 @@ function sqliteDb(file) {
9527
10473
  };
9528
10474
  }
9529
10475
  function openHistory(dir) {
10476
+ if (!nodeSqliteAvailable()) {
10477
+ throw new Error(nodeSqliteUnavailableMessage("history"));
10478
+ }
9530
10479
  const root = join(dir, ".fullshield");
9531
10480
  mkdirSync(root, { recursive: true });
9532
10481
  return sqliteDb(join(root, "history.db"));
@@ -9628,10 +10577,11 @@ function startDashboard(opts) {
9628
10577
  });
9629
10578
  });
9630
10579
  }
9631
- var require2;
10580
+ var require3;
9632
10581
  var init_dashboard = __esm({
9633
10582
  "src/dashboard/index.ts"() {
9634
- require2 = createRequire(import.meta.url);
10583
+ init_sqlite();
10584
+ require3 = createRequire(import.meta.url);
9635
10585
  }
9636
10586
  });
9637
10587
  function finding5(ruleId, title, message, file, severity = "high") {
@@ -10856,6 +11806,153 @@ ${target.message}`
10856
11806
  // src/cli/program.ts
10857
11807
  init_version();
10858
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
+
10859
11956
  // src/reporters/console.ts
10860
11957
  init_finding();
10861
11958
  var SEVERITY_COLOR = {
@@ -10897,7 +11994,8 @@ function renderFinding(finding6) {
10897
11994
  }
10898
11995
  return lines;
10899
11996
  }
10900
- function renderConsoleReport(result) {
11997
+ function renderConsoleReport(input) {
11998
+ const result = redactScan(input);
10901
11999
  const { findings, summary, metadata } = result;
10902
12000
  const lines = [];
10903
12001
  lines.push(
@@ -10905,6 +12003,7 @@ function renderConsoleReport(result) {
10905
12003
  ` \u2014 scanned ${metadata.filesScanned} files in ${metadata.durationMs}ms (modules: ${metadata.modulesRun.length > 0 ? metadata.modulesRun.join(", ") : "none"})`
10906
12004
  )
10907
12005
  );
12006
+ if (metadata.history !== void 0) lines.push(metadata.history);
10908
12007
  if (metadata.modulesRun.length < metadata.modulesRequested.length) {
10909
12008
  const pending = metadata.modulesRequested.filter((m) => !metadata.modulesRun.includes(m));
10910
12009
  lines.push(
@@ -10912,15 +12011,23 @@ function renderConsoleReport(result) {
10912
12011
  );
10913
12012
  }
10914
12013
  const bySeverityDesc = [...SEVERITIES].reverse();
12014
+ const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
12015
+ const shown = ordered.slice(0, 10);
12016
+ const hidden = ordered.length - shown.length;
10915
12017
  for (const severity of bySeverityDesc) {
10916
- const group = findings.filter((f) => f.severity === severity);
12018
+ const group = shown.filter((f) => f.severity === severity);
10917
12019
  if (group.length === 0) continue;
10918
12020
  lines.push("");
10919
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${group.length})`));
12021
+ lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
10920
12022
  for (const finding6 of group) {
10921
12023
  lines.push(...renderFinding(finding6));
12024
+ lines.push(` docs: rules/${finding6.ruleId}.md`);
10922
12025
  }
10923
12026
  }
12027
+ if (hidden > 0) {
12028
+ lines.push("");
12029
+ lines.push(`${hidden} more \u2014 use --json`);
12030
+ }
10924
12031
  lines.push("");
10925
12032
  if (summary.total === 0) {
10926
12033
  lines.push(pc.green("\u2714 No findings."));
@@ -10940,7 +12047,8 @@ function location2(finding6) {
10940
12047
  if (finding6.file === void 0) return "(project)";
10941
12048
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
10942
12049
  }
10943
- function renderHtmlReport(result) {
12050
+ function renderHtmlReport(input) {
12051
+ const result = redactScan(input);
10944
12052
  const sections = [];
10945
12053
  const bySeverityDesc = [...SEVERITIES].reverse();
10946
12054
  for (const severity of bySeverityDesc) {
@@ -10987,7 +12095,7 @@ ${items}`);
10987
12095
 
10988
12096
  // src/reporters/json.ts
10989
12097
  function publicFinding(finding6) {
10990
- const copy = { ...finding6 };
12098
+ const copy = redactFinding(finding6);
10991
12099
  delete copy.aiVerdict;
10992
12100
  delete copy.exploitScenario;
10993
12101
  delete copy.aiReasoning;
@@ -11011,7 +12119,8 @@ function location3(finding6) {
11011
12119
  if (finding6.file === void 0) return "(project)";
11012
12120
  return finding6.line !== void 0 ? `${finding6.file}:${finding6.line}` : finding6.file;
11013
12121
  }
11014
- function renderMarkdownReport(result) {
12122
+ function renderMarkdownReport(input) {
12123
+ const result = redactScan(input);
11015
12124
  const lines = [
11016
12125
  "# FullShield report",
11017
12126
  "",
@@ -11063,7 +12172,8 @@ function sarifLevel(severity) {
11063
12172
  function uri(file) {
11064
12173
  return (file ?? "").replace(/\\/g, "/");
11065
12174
  }
11066
- function renderSarifReport(result) {
12175
+ function renderSarifReport(input) {
12176
+ const result = redactScan(input);
11067
12177
  const rules = /* @__PURE__ */ new Map();
11068
12178
  for (const finding6 of result.findings) {
11069
12179
  if (!rules.has(finding6.ruleId)) {
@@ -11130,7 +12240,137 @@ function renderSarifReport(result) {
11130
12240
  return JSON.stringify(doc, null, 2);
11131
12241
  }
11132
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
+
11133
12372
  // src/cli/program.ts
12373
+ init_experimental();
11134
12374
  init_policy();
11135
12375
 
11136
12376
  // src/sbom/index.ts
@@ -11803,6 +13043,111 @@ function stateForGrace(sshd) {
11803
13043
  return sshd.loginGraceTimeZero ? "logingracetime 0" : "logingracetime not 0";
11804
13044
  }
11805
13045
 
13046
+ // src/cli/commands/doctor.ts
13047
+ init_config2();
13048
+ var MIN_NODE = [20, 11, 0];
13049
+ var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
13050
+ var FLAGS = [
13051
+ "FULLSHIELD_EXPERIMENTAL_SAST_GAPS",
13052
+ "FULLSHIELD_EXPERIMENTAL_FRAMEWORKS",
13053
+ "FULLSHIELD_EXPERIMENTAL_CLASSES",
13054
+ "FULLSHIELD_EXPERIMENTAL_SECRETS_ADV",
13055
+ "FULLSHIELD_EXPERIMENTAL_VEX",
13056
+ "FULLSHIELD_EXPERIMENTAL_DLP",
13057
+ "FULLSHIELD_EXPERIMENTAL_MCPCFG",
13058
+ "FULLSHIELD_EXPERIMENTAL_POLICY",
13059
+ "FULLSHIELD_EXPERIMENTAL_HOST",
13060
+ "FULLSHIELD_EXPERIMENTAL_TAINT_V2",
13061
+ "FULLSHIELD_EXPERIMENTAL_CI_REPORTS",
13062
+ "FULLSHIELD_EXPERIMENTAL_DIFF",
13063
+ "FULLSHIELD_EXPERIMENTAL_LICENSE",
13064
+ "FULLSHIELD_PARSE_POOL"
13065
+ ];
13066
+ function nodeOk(version) {
13067
+ const parts = version.split(".").map((part) => Number(part));
13068
+ for (let i = 0; i < MIN_NODE.length; i += 1) {
13069
+ const got = parts[i] ?? 0;
13070
+ const need = MIN_NODE[i] ?? 0;
13071
+ if (got > need) return true;
13072
+ if (got < need) return false;
13073
+ }
13074
+ return true;
13075
+ }
13076
+ async function runDoctor(cwd, options = {}) {
13077
+ const lines = [];
13078
+ const version = process.versions.node;
13079
+ lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 20.11.0)"}`);
13080
+ const locks = LOCKFILES.filter((name) => existsSync(join(cwd, name)));
13081
+ lines.push(locks.length > 0 ? `lockfile: ${locks.join(", ")}` : "lockfile: none");
13082
+ const cacheDir = join(homedir(), ".cache", "fullshield", "osv");
13083
+ let cacheFiles = 0;
13084
+ if (existsSync(cacheDir)) {
13085
+ cacheFiles = readdirSync(cacheDir).length;
13086
+ }
13087
+ lines.push(`cache: ${existsSync(cacheDir) ? cacheDir : "absent"} (${cacheFiles} files)`);
13088
+ const on = FLAGS.filter((name) => process.env[name] === "1");
13089
+ lines.push(on.length > 0 ? `flags: ${on.join(", ")}` : "flags: none");
13090
+ try {
13091
+ const config = await loadConfig(cwd, options.config);
13092
+ lines.push(`config: valid (failOn ${config.failOn}, offline ${config.deps.offline})`);
13093
+ } catch (error) {
13094
+ const message = error instanceof Error ? error.message : String(error);
13095
+ lines.push(`config: invalid (${message})`);
13096
+ }
13097
+ const offline = options.offline === true;
13098
+ lines.push(
13099
+ offline ? "osv: network skipped (--offline); reachability uses the local lockfile only" : "osv: network allowed; reachability still uses the local lockfile"
13100
+ );
13101
+ return lines.join("\n");
13102
+ }
13103
+
13104
+ // src/cli/errors.ts
13105
+ var KNOWN = [
13106
+ {
13107
+ test: /invalid --fail-on/,
13108
+ code: "FS-E001",
13109
+ hint: "Use one of critical, high, medium, low, info."
13110
+ },
13111
+ {
13112
+ test: /unknown module/,
13113
+ code: "FS-E002",
13114
+ hint: "Pass a comma-separated list from `fullshield scan --help`."
13115
+ },
13116
+ {
13117
+ test: /not a directory/,
13118
+ code: "FS-E003",
13119
+ hint: "Pass the project directory to scan."
13120
+ },
13121
+ {
13122
+ test: /cannot read config/,
13123
+ code: "FS-E004",
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."
13135
+ }
13136
+ ];
13137
+ var DOCS = "https://github.com/kathanp/fullshield/blob/main/docs/errors.md";
13138
+ function formatCliError(error, debug = process.argv.includes("--debug")) {
13139
+ const message = error instanceof Error ? error.message : String(error);
13140
+ const known = KNOWN.find((item) => item.test.test(message));
13141
+ const code = known?.code ?? "FS-E000";
13142
+ const hint = known !== void 0 ? ` \u2014 ${known.hint} ${DOCS}#${code.toLowerCase()}` : "";
13143
+ const line = `fullshield: ${code} ${message}${hint}`;
13144
+ if (debug && error instanceof Error && error.stack !== void 0) {
13145
+ return `${line}
13146
+ ${error.stack}`;
13147
+ }
13148
+ return line;
13149
+ }
13150
+
11806
13151
  // src/cli/program.ts
11807
13152
  var defaultIO = {
11808
13153
  out: (text) => process.stdout.write(`${text}
@@ -11837,8 +13182,17 @@ function parseFailOn(value) {
11837
13182
  }
11838
13183
  function createProgram(io = defaultIO) {
11839
13184
  const program = new Command();
11840
- program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion());
11841
- 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(
13185
+ program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion()).option("--debug", "print stack traces on CLI errors");
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(
11842
13196
  "--experimental-sast-gaps",
11843
13197
  "experimental: ORM raw / sh -c / execa / zx sinks (FULLSHIELD_EXPERIMENTAL_SAST_GAPS=1)"
11844
13198
  ).option(
@@ -11897,6 +13251,15 @@ function createProgram(io = defaultIO) {
11897
13251
  if (opts.prSummary === true && process.env[POLICY_FLAG] !== "1") {
11898
13252
  throw new Error(`--pr-summary requires ${POLICY_FLAG}=1`);
11899
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
+ }
11900
13263
  const result = await scan({
11901
13264
  cwd: resolve(path),
11902
13265
  modules,
@@ -11924,6 +13287,15 @@ function createProgram(io = defaultIO) {
11924
13287
  if (opts.html !== void 0) {
11925
13288
  await writeFile(resolve(opts.html), renderHtmlReport(result), "utf8");
11926
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
+ }
11927
13299
  await writeLastScan(resolve(path), result);
11928
13300
  if (opts.emitVirtualPatches !== void 0) {
11929
13301
  if (process.env.FULLSHIELD_EXPERIMENTAL_VPATCH !== "1") {
@@ -11945,12 +13317,18 @@ function createProgram(io = defaultIO) {
11945
13317
  }
11946
13318
  process.exitCode = 1;
11947
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
+ }
11948
13326
  const failOn = failOnOverride ?? result.policy?.failOn ?? result.config.failOn;
11949
13327
  if (result.findings.some((f) => failsCi(f, failOn))) {
11950
13328
  process.exitCode = 1;
11951
13329
  }
11952
13330
  } catch (error) {
11953
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13331
+ io.err(formatCliError(error));
11954
13332
  process.exitCode = 2;
11955
13333
  }
11956
13334
  });
@@ -11995,7 +13373,7 @@ function createProgram(io = defaultIO) {
11995
13373
  io.out("Dry run. Re-run with --apply to write safe fixes.");
11996
13374
  }
11997
13375
  } catch (error) {
11998
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13376
+ io.err(formatCliError(error));
11999
13377
  process.exitCode = 2;
12000
13378
  }
12001
13379
  });
@@ -12008,7 +13386,7 @@ function createProgram(io = defaultIO) {
12008
13386
  io.out(`Wrote ${result.written.join(", ")}`);
12009
13387
  }
12010
13388
  } catch (error) {
12011
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13389
+ io.err(formatCliError(error));
12012
13390
  process.exitCode = 2;
12013
13391
  }
12014
13392
  });
@@ -12037,7 +13415,7 @@ function createProgram(io = defaultIO) {
12037
13415
  }
12038
13416
  io.out(formatSummary(summary));
12039
13417
  } catch (error) {
12040
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13418
+ io.err(formatCliError(error));
12041
13419
  process.exitCode = 2;
12042
13420
  }
12043
13421
  });
@@ -12057,7 +13435,7 @@ function createProgram(io = defaultIO) {
12057
13435
  io.out(json);
12058
13436
  }
12059
13437
  } catch (error) {
12060
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13438
+ io.err(formatCliError(error));
12061
13439
  process.exitCode = 2;
12062
13440
  }
12063
13441
  });
@@ -12076,7 +13454,7 @@ function createProgram(io = defaultIO) {
12076
13454
  }
12077
13455
  if (findings.some((f) => meetsThreshold(f.severity, "high"))) process.exitCode = 1;
12078
13456
  } catch (error) {
12079
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13457
+ io.err(formatCliError(error));
12080
13458
  process.exitCode = 2;
12081
13459
  }
12082
13460
  });
@@ -12111,7 +13489,7 @@ function createProgram(io = defaultIO) {
12111
13489
  const gate = failOn ?? result.config.failOn;
12112
13490
  if (fresh.some((f) => meetsThreshold(f.severity, gate))) process.exitCode = 1;
12113
13491
  } catch (error) {
12114
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13492
+ io.err(formatCliError(error));
12115
13493
  process.exitCode = 2;
12116
13494
  }
12117
13495
  });
@@ -12124,7 +13502,7 @@ function createProgram(io = defaultIO) {
12124
13502
  const model = buildThreatModel2(resolve(path), { experimental: true });
12125
13503
  io.out(JSON.stringify({ routes: model.routes.length, stride: model.stride, mermaid: model.mermaid }));
12126
13504
  } catch (error) {
12127
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13505
+ io.err(formatCliError(error));
12128
13506
  process.exitCode = 2;
12129
13507
  }
12130
13508
  });
@@ -12154,7 +13532,7 @@ function createProgram(io = defaultIO) {
12154
13532
  const started = await startDashboard2({ dir: resolve(path), experimental: true });
12155
13533
  io.out(`dashboard listening on http://127.0.0.1:${started.port} (Host must be localhost or 127.0.0.1)`);
12156
13534
  } catch (error) {
12157
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13535
+ io.err(formatCliError(error));
12158
13536
  process.exitCode = 2;
12159
13537
  }
12160
13538
  });
@@ -12167,7 +13545,7 @@ function createProgram(io = defaultIO) {
12167
13545
  const findings = scanIac2(resolve(path), { experimental: true });
12168
13546
  io.out(JSON.stringify({ findings }));
12169
13547
  } catch (error) {
12170
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13548
+ io.err(formatCliError(error));
12171
13549
  process.exitCode = 2;
12172
13550
  }
12173
13551
  });
@@ -12196,7 +13574,7 @@ function createProgram(io = defaultIO) {
12196
13574
  const req = JSON.parse(text);
12197
13575
  io.out(JSON.stringify(await handleMcpRequest2(session, req)));
12198
13576
  } catch (error) {
12199
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13577
+ io.err(formatCliError(error));
12200
13578
  process.exitCode = 2;
12201
13579
  }
12202
13580
  });
@@ -12210,7 +13588,7 @@ function createProgram(io = defaultIO) {
12210
13588
  const result = await confirmTarget2(url, { experimental: true, routes: [] });
12211
13589
  io.out(JSON.stringify({ ok: true, confirmations: result.confirmations }));
12212
13590
  } catch (error) {
12213
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13591
+ io.err(formatCliError(error));
12214
13592
  process.exitCode = 2;
12215
13593
  }
12216
13594
  });
@@ -12233,7 +13611,7 @@ function createProgram(io = defaultIO) {
12233
13611
  }
12234
13612
  io.out(`static checks passed for ${pkg}; next step would be npm install --ignore-scripts`);
12235
13613
  } catch (error) {
12236
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13614
+ io.err(formatCliError(error));
12237
13615
  process.exitCode = 2;
12238
13616
  }
12239
13617
  });
@@ -12253,7 +13631,7 @@ function createProgram(io = defaultIO) {
12253
13631
  });
12254
13632
  io.out(JSON.stringify({ prs: result.prs, stoppedOnTestFailure: result.stoppedOnTestFailure, merged: result.merged }));
12255
13633
  } catch (error) {
12256
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13634
+ io.err(formatCliError(error));
12257
13635
  process.exitCode = 2;
12258
13636
  }
12259
13637
  });
@@ -12303,7 +13681,7 @@ function createProgram(io = defaultIO) {
12303
13681
  }
12304
13682
  if (result.findings.some((f) => meetsThreshold(f.severity, "high"))) process.exitCode = 1;
12305
13683
  } catch (error) {
12306
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13684
+ io.err(formatCliError(error));
12307
13685
  process.exitCode = 2;
12308
13686
  }
12309
13687
  });
@@ -12317,7 +13695,7 @@ function createProgram(io = defaultIO) {
12317
13695
  io.out(`wrote honeytokens (.env.backup). decoys: /admin.php /.git/config /wp-login.php`);
12318
13696
  io.out(`canary prefix ${canaries.awsKey.slice(0, 4)}****`);
12319
13697
  } catch (error) {
12320
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13698
+ io.err(formatCliError(error));
12321
13699
  process.exitCode = 2;
12322
13700
  }
12323
13701
  });
@@ -12335,8 +13713,8 @@ function createProgram(io = defaultIO) {
12335
13713
  let name = "app";
12336
13714
  let version = "0.0.0";
12337
13715
  try {
12338
- const { readFile: readFile19 } = await import('fs/promises');
12339
- 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"));
12340
13718
  if (typeof pkg.name === "string") name = pkg.name;
12341
13719
  if (typeof pkg.version === "string") version = pkg.version;
12342
13720
  } catch {
@@ -12353,7 +13731,40 @@ function createProgram(io = defaultIO) {
12353
13731
  io.out(json);
12354
13732
  }
12355
13733
  } catch (error) {
12356
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
13734
+ io.err(formatCliError(error));
13735
+ process.exitCode = 2;
13736
+ }
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
+ );
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) => {
13764
+ try {
13765
+ io.out(await runDoctor(resolve(path), opts));
13766
+ } catch (error) {
13767
+ io.err(formatCliError(error));
12357
13768
  process.exitCode = 2;
12358
13769
  }
12359
13770
  });