fullshield 0.1.3 → 0.2.6

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
@@ -2,12 +2,13 @@
2
2
  import { createHash } from 'crypto';
3
3
  import { writeFile, mkdir, readFile, 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';
@@ -188,6 +189,9 @@ function classesEnabled() {
188
189
  function mcpCfgEnabled() {
189
190
  return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
190
191
  }
192
+ function taintV2Enabled() {
193
+ return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
194
+ }
191
195
  var init_experimental = __esm({
192
196
  "src/modules/sast/experimental.ts"() {
193
197
  }
@@ -1077,6 +1081,105 @@ var init_pins = __esm({
1077
1081
  }
1078
1082
  });
1079
1083
 
1084
+ // src/modules/deps/symbol-reachability.ts
1085
+ function affectedFunctionNames(vuln) {
1086
+ const names = /* @__PURE__ */ new Set();
1087
+ for (const affected of vuln.affected ?? []) {
1088
+ const list = affected.ecosystem_specific?.affected_functions;
1089
+ if (!Array.isArray(list)) continue;
1090
+ for (const item of list) {
1091
+ if (typeof item === "string" && item.length > 0) names.add(item);
1092
+ }
1093
+ }
1094
+ return [...names];
1095
+ }
1096
+ function escapeRegExp(value) {
1097
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1098
+ }
1099
+ function called(source, local) {
1100
+ return new RegExp(`\\b${escapeRegExp(local)}\\s*\\(`).test(source);
1101
+ }
1102
+ function memberCalled(source, object, member) {
1103
+ return new RegExp(`\\b${escapeRegExp(object)}\\s*\\.\\s*${escapeRegExp(member)}\\s*\\(`).test(source);
1104
+ }
1105
+ function sourceCallsAffectedSymbol(source, symbol) {
1106
+ const esc2 = escapeRegExp(symbol);
1107
+ const named = new RegExp(
1108
+ `import\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`,
1109
+ "g"
1110
+ );
1111
+ let match;
1112
+ while ((match = named.exec(source)) !== null) {
1113
+ const parts = (match[1] ?? "").split(",");
1114
+ for (const part of parts) {
1115
+ const bits = part.trim().split(/\s+as\s+/);
1116
+ const imported = bits[0]?.trim();
1117
+ const local = (bits[1] ?? bits[0])?.trim();
1118
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
1119
+ }
1120
+ }
1121
+ const star = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"][^'"]+['"]/g;
1122
+ while ((match = star.exec(source)) !== null) {
1123
+ const local = match[1];
1124
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
1125
+ }
1126
+ const subDefault = new RegExp(
1127
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"][^'"]+/${esc2}['"]`
1128
+ );
1129
+ const sub = subDefault.exec(source);
1130
+ if (sub?.[1] !== void 0 && called(source, sub[1])) return true;
1131
+ const reqMember = new RegExp(
1132
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+['"]\\s*\\)\\s*\\.\\s*${esc2}`
1133
+ );
1134
+ const reqHit = reqMember.exec(source);
1135
+ if (reqHit?.[1] !== void 0 && called(source, reqHit[1])) return true;
1136
+ const reqNs = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
1137
+ while ((match = reqNs.exec(source)) !== null) {
1138
+ const local = match[1];
1139
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
1140
+ }
1141
+ const reqNamed = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
1142
+ while ((match = reqNamed.exec(source)) !== null) {
1143
+ const parts = (match[1] ?? "").split(",");
1144
+ for (const part of parts) {
1145
+ const bits = part.trim().split(/\s*:\s*/);
1146
+ const imported = bits[0]?.trim();
1147
+ const local = (bits[1] ?? bits[0])?.trim();
1148
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
1149
+ }
1150
+ }
1151
+ const reqSub = new RegExp(
1152
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+/${esc2}['"]\\s*\\)`
1153
+ );
1154
+ const subReq = reqSub.exec(source);
1155
+ if (subReq?.[1] !== void 0 && called(source, subReq[1])) return true;
1156
+ const barrel = new RegExp(`export\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`, "g");
1157
+ while ((match = barrel.exec(source)) !== null) {
1158
+ const parts = (match[1] ?? "").split(",");
1159
+ for (const part of parts) {
1160
+ const bits = part.trim().split(/\s+as\s+/);
1161
+ const exported = (bits[1] ?? bits[0])?.trim();
1162
+ if (exported === symbol) {
1163
+ const local = bits[0]?.trim();
1164
+ if (local !== void 0 && called(source, local)) return true;
1165
+ }
1166
+ }
1167
+ }
1168
+ return false;
1169
+ }
1170
+ function classifySymbolUse(symbols, sources) {
1171
+ for (const symbol of symbols) {
1172
+ for (const source of sources.values()) {
1173
+ if (sourceCallsAffectedSymbol(source, symbol)) return "symbol-reachable";
1174
+ }
1175
+ }
1176
+ return "symbol-not-called";
1177
+ }
1178
+ var init_symbol_reachability = __esm({
1179
+ "src/modules/deps/symbol-reachability.ts"() {
1180
+ }
1181
+ });
1182
+
1080
1183
  // src/modules/deps/reachability.ts
1081
1184
  function packageNameFromSnippet(snippet) {
1082
1185
  if (snippet.startsWith("@")) {
@@ -1106,9 +1209,9 @@ function isProductionSource(rel) {
1106
1209
  }
1107
1210
  function indexPackageUsage(sources) {
1108
1211
  const usage = /* @__PURE__ */ new Map();
1109
- const bump = (pkg, called) => {
1212
+ const bump = (pkg, called2) => {
1110
1213
  const prev = usage.get(pkg) ?? { called: false };
1111
- usage.set(pkg, { imported: true, called: prev.called || called });
1214
+ usage.set(pkg, { imported: true, called: prev.called || called2 });
1112
1215
  };
1113
1216
  const nsRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
1114
1217
  const namedRe = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
@@ -1196,10 +1299,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
1196
1299
  return { reachability: "not-imported" };
1197
1300
  }
1198
1301
  function reachabilitySteps(reachability) {
1199
- if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown") {
1302
+ if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
1200
1303
  return 0;
1201
1304
  }
1202
- if (reachability === "imported-not-called") return 1;
1305
+ if (reachability === "imported-not-called" || reachability === "symbol-not-called") return 1;
1203
1306
  return 2;
1204
1307
  }
1205
1308
  function downgradeSeverity(severity, steps) {
@@ -1216,7 +1319,7 @@ function isDevOnly(pkgName, ctx) {
1216
1319
  if (inProd) return false;
1217
1320
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
1218
1321
  }
1219
- function applyReachability(findings, ctx) {
1322
+ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
1220
1323
  return findings.map((finding6) => {
1221
1324
  if (finding6.ruleId === "DEPS-MALICIOUS-001") {
1222
1325
  if (finding6.snippet === void 0) return finding6;
@@ -1233,7 +1336,12 @@ function applyReachability(findings, ctx) {
1233
1336
  if (finding6.ruleId !== "DEPS-VULN-001" || finding6.snippet === void 0) return finding6;
1234
1337
  const pkg = packageNameFromSnippet(finding6.snippet);
1235
1338
  if (pkg === void 0 || pkg.length === 0) return finding6;
1236
- const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
1339
+ let classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
1340
+ const named = symbols.get(finding6.id) ?? [];
1341
+ if (named.length > 0 && ctx.sources !== void 0) {
1342
+ const symbolClass = classifySymbolUse(named, ctx.sources);
1343
+ classified = { reachability: symbolClass, path: classified.path };
1344
+ }
1237
1345
  const steps = reachabilitySteps(classified.reachability);
1238
1346
  let severity = steps === 0 ? finding6.severity : downgradeSeverity(finding6.severity, steps);
1239
1347
  let message = finding6.message;
@@ -1258,6 +1366,7 @@ function applyReachability(findings, ctx) {
1258
1366
  var init_reachability = __esm({
1259
1367
  "src/modules/deps/reachability.ts"() {
1260
1368
  init_finding();
1369
+ init_symbol_reachability();
1261
1370
  }
1262
1371
  });
1263
1372
 
@@ -3822,7 +3931,7 @@ async function reachabilityCtx(cwd, files, graph) {
3822
3931
  } catch {
3823
3932
  }
3824
3933
  }
3825
- return { productionUsage: indexPackageUsage(prodSources), graph };
3934
+ return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
3826
3935
  }
3827
3936
  var LOCKFILE_NAMES, MANIFEST_FIELDS2, MALICIOUS_REMEDIATION, depsModule;
3828
3937
  var init_deps = __esm({
@@ -3832,6 +3941,7 @@ var init_deps = __esm({
3832
3941
  init_osv();
3833
3942
  init_pins();
3834
3943
  init_reachability();
3944
+ init_symbol_reachability();
3835
3945
  init_semver();
3836
3946
  init_typosquat();
3837
3947
  init_experimental2();
@@ -3871,14 +3981,17 @@ var init_deps = __esm({
3871
3981
  findings2.push(typosquatFinding("package.json", hit));
3872
3982
  }
3873
3983
  const osvHits2 = await queryOsv(pins, osvOpts);
3984
+ const symbols2 = /* @__PURE__ */ new Map();
3874
3985
  for (const hit of osvHits2) {
3875
3986
  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
- );
3987
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
3988
+ const names = affectedFunctionNames(vuln);
3989
+ if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols2.set(finding6.id, names);
3990
+ findings2.push(finding6);
3879
3991
  }
3880
3992
  }
3881
- return applyReachability(findings2, await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest)));
3993
+ const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
3994
+ return applyReachability(findings2, noLockCtx, symbols2);
3882
3995
  }
3883
3996
  let contents;
3884
3997
  try {
@@ -3899,14 +4012,16 @@ var init_deps = __esm({
3899
4012
  }
3900
4013
  }
3901
4014
  const osvHits = await queryOsv(packages, osvOpts);
4015
+ const symbols = /* @__PURE__ */ new Map();
3902
4016
  for (const hit of osvHits) {
3903
4017
  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
- );
4018
+ const finding6 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4019
+ const names = affectedFunctionNames(vuln);
4020
+ if (names.length > 0 && finding6.ruleId === "DEPS-VULN-001") symbols.set(finding6.id, names);
4021
+ findings.push(finding6);
3907
4022
  }
3908
4023
  }
3909
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
4024
+ let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
3910
4025
  if (experimentalVex()) {
3911
4026
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
3912
4027
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
@@ -4489,6 +4604,14 @@ function isXssSanitizerCall(node) {
4489
4604
  }
4490
4605
  return false;
4491
4606
  }
4607
+ function enclosingClass(node) {
4608
+ let current = node.parent;
4609
+ while (current !== void 0) {
4610
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
4611
+ current = current.parent;
4612
+ }
4613
+ return void 0;
4614
+ }
4492
4615
  function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4493
4616
  if (node === void 0) return false;
4494
4617
  if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
@@ -4500,8 +4623,34 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
4500
4623
  case "Identifier":
4501
4624
  return tainted.has(node.name);
4502
4625
  case "MemberExpression":
4626
+ if (taintV2Enabled() && node.object.type === "ThisExpression") {
4627
+ const cls = enclosingClass(node);
4628
+ const name = propName(node);
4629
+ if (cls !== void 0 && name !== void 0 && taintedThisFields.get(cls)?.has(name) === true) {
4630
+ return true;
4631
+ }
4632
+ }
4503
4633
  return exprIsTainted(node.object, tainted, returners);
4634
+ case "ArrayExpression":
4635
+ if (!taintV2Enabled()) return false;
4636
+ return node.elements.some((el) => {
4637
+ if (el === null) return false;
4638
+ if (el.type === "SpreadElement") return exprIsTainted(el.argument, tainted, returners);
4639
+ return exprIsTainted(el, tainted, returners);
4640
+ });
4641
+ case "ObjectExpression":
4642
+ if (!taintV2Enabled()) return false;
4643
+ return node.properties.some((prop) => {
4644
+ if (prop.type === "SpreadElement") return exprIsTainted(prop.argument, tainted, returners);
4645
+ if (prop.type !== "Property") return false;
4646
+ return exprIsTainted(prop.value, tainted, returners);
4647
+ });
4504
4648
  case "CallExpression":
4649
+ if (taintV2Enabled() && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
4650
+ return node.arguments.some(
4651
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
4652
+ );
4653
+ }
4505
4654
  if (isSchemaValidatorCall(node)) return false;
4506
4655
  if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
4507
4656
  if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
@@ -4731,6 +4880,65 @@ function scopeReturnsTaint(scope, tainted, returners) {
4731
4880
  });
4732
4881
  return yes;
4733
4882
  }
4883
+ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
4884
+ let changed = false;
4885
+ const mark = (fn, name) => {
4886
+ if (name === void 0) return;
4887
+ const set = seeds.get(fn) ?? /* @__PURE__ */ new Set();
4888
+ if (set.has(name)) return;
4889
+ set.add(name);
4890
+ seeds.set(fn, set);
4891
+ changed = true;
4892
+ };
4893
+ for (const fn of functions) {
4894
+ const f = facts.get(fn);
4895
+ if (f === void 0) continue;
4896
+ let parent = fn.parent;
4897
+ while (parent !== void 0 && !isFunctionNode(parent)) {
4898
+ parent = parent.parent;
4899
+ }
4900
+ if (parent !== void 0 && isFunctionNode(parent)) {
4901
+ const outer = facts.get(parent);
4902
+ if (outer !== void 0) {
4903
+ for (const name of outer.tainted) mark(fn, name);
4904
+ }
4905
+ }
4906
+ walkScope(fn, (node) => {
4907
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") {
4908
+ if (node.left.object.type === "ThisExpression" && exprIsTainted(node.right, f.tainted, returners)) {
4909
+ const cls = enclosingClass(node);
4910
+ const field = propName(node.left);
4911
+ if (cls !== void 0 && field !== void 0) {
4912
+ const fields = taintedThisFields.get(cls) ?? /* @__PURE__ */ new Set();
4913
+ if (!fields.has(field)) {
4914
+ fields.add(field);
4915
+ taintedThisFields.set(cls, fields);
4916
+ changed = true;
4917
+ }
4918
+ }
4919
+ }
4920
+ }
4921
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
4922
+ const method = propName(node.callee);
4923
+ if (method === void 0) return;
4924
+ if (CALLBACK_METHODS.has(method) && exprIsTainted(node.callee.object, f.tainted, returners)) {
4925
+ const callback = node.arguments[0];
4926
+ if (callback !== void 0 && isFunctionNode(callback)) {
4927
+ mark(callback, boundNames(callback.params[0])[0]);
4928
+ }
4929
+ }
4930
+ if ((method === "call" || method === "apply") && node.callee.object.type === "Identifier") {
4931
+ const target = nameToFn.get(node.callee.object.name);
4932
+ if (target === void 0 || !isFunctionNode(target)) return;
4933
+ const arg = node.arguments[1];
4934
+ if (arg !== void 0 && arg.type !== "SpreadElement" && exprIsTainted(arg, f.tainted, returners)) {
4935
+ mark(target, boundNames(target.params[0])[0]);
4936
+ }
4937
+ }
4938
+ });
4939
+ }
4940
+ return changed;
4941
+ }
4734
4942
  function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4735
4943
  attachParents(ast);
4736
4944
  const facts = /* @__PURE__ */ new WeakMap();
@@ -4785,6 +4993,9 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
4785
4993
  });
4786
4994
  });
4787
4995
  }
4996
+ if (taintV2Enabled() && seedTaintV2(functions, facts, seeds, returners, nameToFn)) {
4997
+ changed = true;
4998
+ }
4788
4999
  if (!changed) break;
4789
5000
  }
4790
5001
  return facts;
@@ -4843,7 +5054,7 @@ function nodeIsNosqlSanitized(node, map) {
4843
5054
  if (node.type === "Identifier") return factsFor(node, map).nosqlSanitized.has(node.name);
4844
5055
  return false;
4845
5056
  }
4846
- var SCHEMA_ROOT, NO_RETURNERS, MAX_TAINT_HOPS;
5057
+ var SCHEMA_ROOT, NO_RETURNERS, CALLBACK_METHODS, taintedThisFields, MAX_TAINT_HOPS;
4847
5058
  var init_taint = __esm({
4848
5059
  "src/modules/sast/taint.ts"() {
4849
5060
  init_ast();
@@ -4852,6 +5063,18 @@ var init_taint = __esm({
4852
5063
  init_mcp2();
4853
5064
  SCHEMA_ROOT = /^(z|zod|joi|yup|schema)$/i;
4854
5065
  NO_RETURNERS = /* @__PURE__ */ new Set();
5066
+ CALLBACK_METHODS = /* @__PURE__ */ new Set([
5067
+ "then",
5068
+ "catch",
5069
+ "finally",
5070
+ "map",
5071
+ "forEach",
5072
+ "filter",
5073
+ "flatMap",
5074
+ "find",
5075
+ "reduce"
5076
+ ]);
5077
+ taintedThisFields = /* @__PURE__ */ new WeakMap();
4855
5078
  MAX_TAINT_HOPS = 5;
4856
5079
  }
4857
5080
  });
@@ -4936,6 +5159,21 @@ function requireCall(node) {
4936
5159
  }
4937
5160
  return void 0;
4938
5161
  }
5162
+ function reexportsOf(ast) {
5163
+ const out = /* @__PURE__ */ new Map();
5164
+ walk(ast, (node) => {
5165
+ if (node.type !== "ExportNamedDeclaration" || node.source === null || node.source === void 0) return;
5166
+ const spec = literalString(node.source);
5167
+ if (spec === void 0) return;
5168
+ for (const specNode of node.specifiers) {
5169
+ if (specNode.type !== "ExportSpecifier") continue;
5170
+ const exported = specNode.exported.type === "Identifier" ? specNode.exported.name : void 0;
5171
+ const local = specNode.local.type === "Identifier" ? specNode.local.name : exported;
5172
+ if (exported !== void 0 && local !== void 0) out.set(exported, { spec, name: local });
5173
+ }
5174
+ });
5175
+ return out;
5176
+ }
4939
5177
  function resolveRelative(fromFile, spec, files) {
4940
5178
  if (!spec.startsWith(".")) return void 0;
4941
5179
  const base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
@@ -5013,6 +5251,22 @@ function runInterproc(parsed) {
5013
5251
  functions.set(`${fn.file}#${fn.name}`, fn);
5014
5252
  }
5015
5253
  }
5254
+ if (taintV2Enabled()) {
5255
+ const barrels = /* @__PURE__ */ new Map();
5256
+ for (const p of parsed) barrels.set(p.file, reexportsOf(p.ast));
5257
+ for (const ns of requires.values()) {
5258
+ for (const [local, target] of ns) {
5259
+ const hash = target.indexOf("#");
5260
+ if (hash < 0) continue;
5261
+ const file = target.slice(0, hash);
5262
+ const name = target.slice(hash + 1);
5263
+ const re = barrels.get(file)?.get(name);
5264
+ if (re === void 0) continue;
5265
+ const resolved = resolveRelative(file, re.spec, files);
5266
+ if (resolved !== void 0) ns.set(local, `${resolved}#${re.name}`);
5267
+ }
5268
+ }
5269
+ }
5016
5270
  const queue = [];
5017
5271
  for (const p of parsed) {
5018
5272
  const facts = analyzeFile(p.ast);
@@ -5149,6 +5403,7 @@ var init_interproc = __esm({
5149
5403
  "src/modules/sast/interproc.ts"() {
5150
5404
  init_finding();
5151
5405
  init_ast();
5406
+ init_experimental();
5152
5407
  init_taint();
5153
5408
  MAX_HOPS = 5;
5154
5409
  }
@@ -5546,7 +5801,10 @@ function isMongoReceiver(node, source) {
5546
5801
  const obj = node.callee.object;
5547
5802
  if (obj.type === "Identifier") {
5548
5803
  if (isCollectionName(obj.name)) return true;
5549
- if (isPascalModel(obj.name)) return !fileHasSqlOrm;
5804
+ if (isPascalModel(obj.name)) {
5805
+ if (/extends\s+BaseEntity\b/.test(source)) return false;
5806
+ return !fileHasSqlOrm;
5807
+ }
5550
5808
  return fileHasMongo;
5551
5809
  }
5552
5810
  if (obj.type === "MemberExpression") {
@@ -6686,6 +6944,80 @@ var init_injection = __esm({
6686
6944
  PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
6687
6945
  }
6688
6946
  });
6947
+ function packageVersion() {
6948
+ let dir = dirname(fileURLToPath(import.meta.url));
6949
+ for (let depth = 0; depth < 6; depth += 1) {
6950
+ try {
6951
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
6952
+ const pkg = JSON.parse(raw);
6953
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
6954
+ return pkg.version;
6955
+ }
6956
+ } catch {
6957
+ }
6958
+ const parent = dirname(dir);
6959
+ if (parent === dir) break;
6960
+ dir = parent;
6961
+ }
6962
+ throw new Error("fullshield package.json not found");
6963
+ }
6964
+ var init_version = __esm({
6965
+ "src/core/version.ts"() {
6966
+ }
6967
+ });
6968
+ function parsePoolEnabled() {
6969
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
6970
+ }
6971
+ function parsePoolSize() {
6972
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
6973
+ if (raw !== void 0 && raw !== "") {
6974
+ const parsed = Number(raw);
6975
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
6976
+ }
6977
+ return Math.max(1, availableParallelism() - 1);
6978
+ }
6979
+ function parseCacheKey(code) {
6980
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
6981
+ }
6982
+ function parseOne(code) {
6983
+ const key = parseCacheKey(code);
6984
+ const hit = cache.get(key);
6985
+ if (hit !== void 0) return Promise.resolve(hit);
6986
+ return new Promise((resolve8, reject) => {
6987
+ const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
6988
+ worker.once("message", (ast) => {
6989
+ cache.set(key, ast);
6990
+ resolve8(ast);
6991
+ });
6992
+ worker.once("error", reject);
6993
+ });
6994
+ }
6995
+ async function parseWithPool(codes) {
6996
+ const size = parsePoolSize();
6997
+ const out = new Array(codes.length);
6998
+ let next = 0;
6999
+ async function run() {
7000
+ for (; ; ) {
7001
+ const index = next;
7002
+ next += 1;
7003
+ if (index >= codes.length) return;
7004
+ const code = codes[index];
7005
+ if (code === void 0) return;
7006
+ out[index] = await parseOne(code);
7007
+ }
7008
+ }
7009
+ const workers = Math.min(size, Math.max(1, codes.length));
7010
+ await Promise.all(Array.from({ length: workers }, () => run()));
7011
+ return out;
7012
+ }
7013
+ var workerUrl, cache;
7014
+ var init_parse_pool = __esm({
7015
+ "src/core/parse-pool.ts"() {
7016
+ init_version();
7017
+ workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7018
+ cache = /* @__PURE__ */ new Map();
7019
+ }
7020
+ });
6689
7021
  async function parseSource(code) {
6690
7022
  const { parse } = await import('@typescript-eslint/typescript-estree');
6691
7023
  return parse(code, {
@@ -6801,6 +7133,7 @@ var init_engine = __esm({
6801
7133
  init_xss();
6802
7134
  init_phase29();
6803
7135
  init_injection();
7136
+ init_parse_pool();
6804
7137
  sastRules = [
6805
7138
  sqliRule,
6806
7139
  cmdiRule,
@@ -6838,7 +7171,8 @@ var init_engine = __esm({
6838
7171
  continue;
6839
7172
  }
6840
7173
  try {
6841
- const ast = await parseSource(source);
7174
+ const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
7175
+ if (ast === void 0) continue;
6842
7176
  parsed.push({ file: rel, source, ast });
6843
7177
  } catch {
6844
7178
  }
@@ -7690,7 +8024,7 @@ function createAiClient(options) {
7690
8024
  const useFixtures = options.useFixtures ?? !isLiveAi();
7691
8025
  const model = options.model ?? process.env.FULLSHIELD_AI_MODEL ?? (useFixtures ? "recorded-fixture" : "qwen2.5-coder:7b");
7692
8026
  const provider = options.provider ?? process.env.FULLSHIELD_AI_PROVIDER ?? (useFixtures ? "fixture" : "ollama");
7693
- const cache = /* @__PURE__ */ new Map();
8027
+ const cache2 = /* @__PURE__ */ new Map();
7694
8028
  let spentUsd = 0;
7695
8029
  let networkCalls = 0;
7696
8030
  const liveCost = provider === "ollama" ? 0 : FIXTURE_COST;
@@ -7710,7 +8044,7 @@ function createAiClient(options) {
7710
8044
  const redactedPrompt = `${modeInstruction(req.mode)}
7711
8045
  ${wrapUntrustedCode(req.prompt)}`;
7712
8046
  const key = cacheKey(req.model ?? model, redactedPrompt);
7713
- const cached = cache.get(key);
8047
+ const cached = cache2.get(key);
7714
8048
  if (cached !== void 0) {
7715
8049
  return { ...cached, cached: true, costUsd: 0 };
7716
8050
  }
@@ -7724,7 +8058,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7724
8058
  cached: false,
7725
8059
  redactedPrompt
7726
8060
  };
7727
- cache.set(key, response2);
8061
+ cache2.set(key, response2);
7728
8062
  return response2;
7729
8063
  }
7730
8064
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -7774,7 +8108,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7774
8108
  cached: false,
7775
8109
  redactedPrompt
7776
8110
  };
7777
- cache.set(key, response);
8111
+ cache2.set(key, response);
7778
8112
  return response;
7779
8113
  }
7780
8114
  };
@@ -8050,27 +8384,6 @@ var init_suppression = __esm({
8050
8384
  IGNORE = /fullshield-ignore\s+([A-Z0-9-]+)\s*:\s*(\S+)/;
8051
8385
  }
8052
8386
  });
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
8387
 
8075
8388
  // src/policy/prSummary.ts
8076
8389
  function ruleDocsUrl(ruleId) {
@@ -10912,15 +11225,23 @@ function renderConsoleReport(result) {
10912
11225
  );
10913
11226
  }
10914
11227
  const bySeverityDesc = [...SEVERITIES].reverse();
11228
+ const ordered = bySeverityDesc.flatMap((severity) => findings.filter((f) => f.severity === severity));
11229
+ const shown = ordered.slice(0, 10);
11230
+ const hidden = ordered.length - shown.length;
10915
11231
  for (const severity of bySeverityDesc) {
10916
- const group = findings.filter((f) => f.severity === severity);
11232
+ const group = shown.filter((f) => f.severity === severity);
10917
11233
  if (group.length === 0) continue;
10918
11234
  lines.push("");
10919
- lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${group.length})`));
11235
+ lines.push(SEVERITY_COLOR[severity](`${severity.toUpperCase()} (${findings.filter((f) => f.severity === severity).length})`));
10920
11236
  for (const finding6 of group) {
10921
11237
  lines.push(...renderFinding(finding6));
11238
+ lines.push(` docs: rules/${finding6.ruleId}.md`);
10922
11239
  }
10923
11240
  }
11241
+ if (hidden > 0) {
11242
+ lines.push("");
11243
+ lines.push(`${hidden} more \u2014 use --json`);
11244
+ }
10924
11245
  lines.push("");
10925
11246
  if (summary.total === 0) {
10926
11247
  lines.push(pc.green("\u2714 No findings."));
@@ -11803,6 +12124,98 @@ function stateForGrace(sshd) {
11803
12124
  return sshd.loginGraceTimeZero ? "logingracetime 0" : "logingracetime not 0";
11804
12125
  }
11805
12126
 
12127
+ // src/cli/commands/doctor.ts
12128
+ init_config2();
12129
+ var MIN_NODE = [22, 13, 0];
12130
+ var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
12131
+ var FLAGS = [
12132
+ "FULLSHIELD_EXPERIMENTAL_SAST_GAPS",
12133
+ "FULLSHIELD_EXPERIMENTAL_FRAMEWORKS",
12134
+ "FULLSHIELD_EXPERIMENTAL_CLASSES",
12135
+ "FULLSHIELD_EXPERIMENTAL_SECRETS_ADV",
12136
+ "FULLSHIELD_EXPERIMENTAL_VEX",
12137
+ "FULLSHIELD_EXPERIMENTAL_DLP",
12138
+ "FULLSHIELD_EXPERIMENTAL_MCPCFG",
12139
+ "FULLSHIELD_EXPERIMENTAL_POLICY",
12140
+ "FULLSHIELD_EXPERIMENTAL_HOST",
12141
+ "FULLSHIELD_EXPERIMENTAL_TAINT_V2",
12142
+ "FULLSHIELD_PARSE_POOL"
12143
+ ];
12144
+ function nodeOk(version) {
12145
+ const parts = version.split(".").map((part) => Number(part));
12146
+ for (let i = 0; i < MIN_NODE.length; i += 1) {
12147
+ const got = parts[i] ?? 0;
12148
+ const need = MIN_NODE[i] ?? 0;
12149
+ if (got > need) return true;
12150
+ if (got < need) return false;
12151
+ }
12152
+ return true;
12153
+ }
12154
+ async function runDoctor(cwd, options = {}) {
12155
+ const lines = [];
12156
+ const version = process.versions.node;
12157
+ lines.push(`node: ${version} ${nodeOk(version) ? "ok" : "too old (need >= 22.13.0)"}`);
12158
+ const locks = LOCKFILES.filter((name) => existsSync(join(cwd, name)));
12159
+ lines.push(locks.length > 0 ? `lockfile: ${locks.join(", ")}` : "lockfile: none");
12160
+ const cacheDir = join(homedir(), ".cache", "fullshield", "osv");
12161
+ let cacheFiles = 0;
12162
+ if (existsSync(cacheDir)) {
12163
+ cacheFiles = readdirSync(cacheDir).length;
12164
+ }
12165
+ lines.push(`cache: ${existsSync(cacheDir) ? cacheDir : "absent"} (${cacheFiles} files)`);
12166
+ const on = FLAGS.filter((name) => process.env[name] === "1");
12167
+ lines.push(on.length > 0 ? `flags: ${on.join(", ")}` : "flags: none");
12168
+ try {
12169
+ const config = await loadConfig(cwd, options.config);
12170
+ lines.push(`config: valid (failOn ${config.failOn}, offline ${config.deps.offline})`);
12171
+ } catch (error) {
12172
+ const message = error instanceof Error ? error.message : String(error);
12173
+ lines.push(`config: invalid (${message})`);
12174
+ }
12175
+ const offline = options.offline === true;
12176
+ lines.push(
12177
+ offline ? "osv: network skipped (--offline); reachability uses the local lockfile only" : "osv: network allowed; reachability still uses the local lockfile"
12178
+ );
12179
+ return lines.join("\n");
12180
+ }
12181
+
12182
+ // src/cli/errors.ts
12183
+ var KNOWN = [
12184
+ {
12185
+ test: /invalid --fail-on/,
12186
+ code: "FS-E001",
12187
+ hint: "Use one of critical, high, medium, low, info."
12188
+ },
12189
+ {
12190
+ test: /unknown module/,
12191
+ code: "FS-E002",
12192
+ hint: "Pass a comma-separated list from `fullshield scan --help`."
12193
+ },
12194
+ {
12195
+ test: /not a directory/,
12196
+ code: "FS-E003",
12197
+ hint: "Pass the project directory to scan."
12198
+ },
12199
+ {
12200
+ test: /cannot read config/,
12201
+ code: "FS-E004",
12202
+ hint: "Check the path passed to --config."
12203
+ }
12204
+ ];
12205
+ var DOCS = "https://github.com/kathanp/fullshield/blob/main/docs/errors.md";
12206
+ function formatCliError(error, debug = process.argv.includes("--debug")) {
12207
+ const message = error instanceof Error ? error.message : String(error);
12208
+ const known = KNOWN.find((item) => item.test.test(message));
12209
+ const code = known?.code ?? "FS-E000";
12210
+ const hint = known !== void 0 ? ` \u2014 ${known.hint} ${DOCS}#${code.toLowerCase()}` : "";
12211
+ const line = `fullshield: ${code} ${message}${hint}`;
12212
+ if (debug && error instanceof Error && error.stack !== void 0) {
12213
+ return `${line}
12214
+ ${error.stack}`;
12215
+ }
12216
+ return line;
12217
+ }
12218
+
11806
12219
  // src/cli/program.ts
11807
12220
  var defaultIO = {
11808
12221
  out: (text) => process.stdout.write(`${text}
@@ -11837,7 +12250,7 @@ function parseFailOn(value) {
11837
12250
  }
11838
12251
  function createProgram(io = defaultIO) {
11839
12252
  const program = new Command();
11840
- program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion());
12253
+ program.name("fullshield").description("Full-cycle security scanner for Node.js/TypeScript backends").version(packageVersion()).option("--debug", "print stack traces on CLI errors");
11841
12254
  program.command("scan").description("Scan a project directory for security findings").argument("[path]", "directory to scan", ".").option("--json", "machine-readable JSON output").option("--fail-on <severity>", "exit 1 if any finding is at or above this severity").option("--modules <modules>", "comma-separated subset of modules to run").option("--staged", "scan only files staged in git (pre-commit mode)").option("--offline", "use the OSV cache only (no network)").option("--offline-safe", "alias of --offline").option("--config <path>", "path to fullshield.config.json").option("--sarif <file>", "write a SARIF 2.1.0 report to this path").option("--markdown <file>", "write a Markdown report to this path").option("--html <file>", "write an HTML report to this path").option("--update-baseline", "record current findings as accepted (CI fails only on new ones)").option("--ai [mode]", "experimental: explain findings (off = zero AI network calls)").option("--emit-virtual-patches [file]", "experimental: write .fullshield/vpatches.json").option("--changed", "experimental: scan git staged/changed files only").option("--since <ref>", "experimental: scan files changed since a git ref").option(
11842
12255
  "--experimental-sast-gaps",
11843
12256
  "experimental: ORM raw / sh -c / execa / zx sinks (FULLSHIELD_EXPERIMENTAL_SAST_GAPS=1)"
@@ -11950,7 +12363,7 @@ function createProgram(io = defaultIO) {
11950
12363
  process.exitCode = 1;
11951
12364
  }
11952
12365
  } catch (error) {
11953
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12366
+ io.err(formatCliError(error));
11954
12367
  process.exitCode = 2;
11955
12368
  }
11956
12369
  });
@@ -11995,7 +12408,7 @@ function createProgram(io = defaultIO) {
11995
12408
  io.out("Dry run. Re-run with --apply to write safe fixes.");
11996
12409
  }
11997
12410
  } catch (error) {
11998
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12411
+ io.err(formatCliError(error));
11999
12412
  process.exitCode = 2;
12000
12413
  }
12001
12414
  });
@@ -12008,7 +12421,7 @@ function createProgram(io = defaultIO) {
12008
12421
  io.out(`Wrote ${result.written.join(", ")}`);
12009
12422
  }
12010
12423
  } catch (error) {
12011
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12424
+ io.err(formatCliError(error));
12012
12425
  process.exitCode = 2;
12013
12426
  }
12014
12427
  });
@@ -12037,7 +12450,7 @@ function createProgram(io = defaultIO) {
12037
12450
  }
12038
12451
  io.out(formatSummary(summary));
12039
12452
  } catch (error) {
12040
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12453
+ io.err(formatCliError(error));
12041
12454
  process.exitCode = 2;
12042
12455
  }
12043
12456
  });
@@ -12057,7 +12470,7 @@ function createProgram(io = defaultIO) {
12057
12470
  io.out(json);
12058
12471
  }
12059
12472
  } catch (error) {
12060
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12473
+ io.err(formatCliError(error));
12061
12474
  process.exitCode = 2;
12062
12475
  }
12063
12476
  });
@@ -12076,7 +12489,7 @@ function createProgram(io = defaultIO) {
12076
12489
  }
12077
12490
  if (findings.some((f) => meetsThreshold(f.severity, "high"))) process.exitCode = 1;
12078
12491
  } catch (error) {
12079
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12492
+ io.err(formatCliError(error));
12080
12493
  process.exitCode = 2;
12081
12494
  }
12082
12495
  });
@@ -12111,7 +12524,7 @@ function createProgram(io = defaultIO) {
12111
12524
  const gate = failOn ?? result.config.failOn;
12112
12525
  if (fresh.some((f) => meetsThreshold(f.severity, gate))) process.exitCode = 1;
12113
12526
  } catch (error) {
12114
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12527
+ io.err(formatCliError(error));
12115
12528
  process.exitCode = 2;
12116
12529
  }
12117
12530
  });
@@ -12124,7 +12537,7 @@ function createProgram(io = defaultIO) {
12124
12537
  const model = buildThreatModel2(resolve(path), { experimental: true });
12125
12538
  io.out(JSON.stringify({ routes: model.routes.length, stride: model.stride, mermaid: model.mermaid }));
12126
12539
  } catch (error) {
12127
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12540
+ io.err(formatCliError(error));
12128
12541
  process.exitCode = 2;
12129
12542
  }
12130
12543
  });
@@ -12154,7 +12567,7 @@ function createProgram(io = defaultIO) {
12154
12567
  const started = await startDashboard2({ dir: resolve(path), experimental: true });
12155
12568
  io.out(`dashboard listening on http://127.0.0.1:${started.port} (Host must be localhost or 127.0.0.1)`);
12156
12569
  } catch (error) {
12157
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12570
+ io.err(formatCliError(error));
12158
12571
  process.exitCode = 2;
12159
12572
  }
12160
12573
  });
@@ -12167,7 +12580,7 @@ function createProgram(io = defaultIO) {
12167
12580
  const findings = scanIac2(resolve(path), { experimental: true });
12168
12581
  io.out(JSON.stringify({ findings }));
12169
12582
  } catch (error) {
12170
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12583
+ io.err(formatCliError(error));
12171
12584
  process.exitCode = 2;
12172
12585
  }
12173
12586
  });
@@ -12196,7 +12609,7 @@ function createProgram(io = defaultIO) {
12196
12609
  const req = JSON.parse(text);
12197
12610
  io.out(JSON.stringify(await handleMcpRequest2(session, req)));
12198
12611
  } catch (error) {
12199
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12612
+ io.err(formatCliError(error));
12200
12613
  process.exitCode = 2;
12201
12614
  }
12202
12615
  });
@@ -12210,7 +12623,7 @@ function createProgram(io = defaultIO) {
12210
12623
  const result = await confirmTarget2(url, { experimental: true, routes: [] });
12211
12624
  io.out(JSON.stringify({ ok: true, confirmations: result.confirmations }));
12212
12625
  } catch (error) {
12213
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12626
+ io.err(formatCliError(error));
12214
12627
  process.exitCode = 2;
12215
12628
  }
12216
12629
  });
@@ -12233,7 +12646,7 @@ function createProgram(io = defaultIO) {
12233
12646
  }
12234
12647
  io.out(`static checks passed for ${pkg}; next step would be npm install --ignore-scripts`);
12235
12648
  } catch (error) {
12236
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12649
+ io.err(formatCliError(error));
12237
12650
  process.exitCode = 2;
12238
12651
  }
12239
12652
  });
@@ -12253,7 +12666,7 @@ function createProgram(io = defaultIO) {
12253
12666
  });
12254
12667
  io.out(JSON.stringify({ prs: result.prs, stoppedOnTestFailure: result.stoppedOnTestFailure, merged: result.merged }));
12255
12668
  } catch (error) {
12256
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12669
+ io.err(formatCliError(error));
12257
12670
  process.exitCode = 2;
12258
12671
  }
12259
12672
  });
@@ -12303,7 +12716,7 @@ function createProgram(io = defaultIO) {
12303
12716
  }
12304
12717
  if (result.findings.some((f) => meetsThreshold(f.severity, "high"))) process.exitCode = 1;
12305
12718
  } catch (error) {
12306
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12719
+ io.err(formatCliError(error));
12307
12720
  process.exitCode = 2;
12308
12721
  }
12309
12722
  });
@@ -12317,7 +12730,7 @@ function createProgram(io = defaultIO) {
12317
12730
  io.out(`wrote honeytokens (.env.backup). decoys: /admin.php /.git/config /wp-login.php`);
12318
12731
  io.out(`canary prefix ${canaries.awsKey.slice(0, 4)}****`);
12319
12732
  } catch (error) {
12320
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12733
+ io.err(formatCliError(error));
12321
12734
  process.exitCode = 2;
12322
12735
  }
12323
12736
  });
@@ -12353,7 +12766,15 @@ function createProgram(io = defaultIO) {
12353
12766
  io.out(json);
12354
12767
  }
12355
12768
  } catch (error) {
12356
- io.err(`fullshield: ${error instanceof Error ? error.message : String(error)}`);
12769
+ io.err(formatCliError(error));
12770
+ process.exitCode = 2;
12771
+ }
12772
+ });
12773
+ program.command("doctor").description("Report Node, lockfile, cache, flags, config, and whether OSV is offline").argument("[path]", "project directory", ".").option("--offline", "do not describe OSV as allowed to use the network").option("--config <file>", "explicit config file").action(async (path, opts) => {
12774
+ try {
12775
+ io.out(await runDoctor(resolve(path), opts));
12776
+ } catch (error) {
12777
+ io.err(formatCliError(error));
12357
12778
  process.exitCode = 2;
12358
12779
  }
12359
12780
  });