seamshield 0.2.1 → 0.2.2

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.

Potentially problematic release.


This version of seamshield might be problematic. Click here for more details.

package/README.md CHANGED
@@ -5,6 +5,12 @@ Local access-lane security scanner for AI-built JavaScript and TypeScript apps.
5
5
  SeamShield maps who or what can reach sensitive assets before you ship:
6
6
  `Actor -> Lane -> Asset -> Permission -> Condition -> Risk`.
7
7
 
8
+ The CLI is the open-source local scanner/control engine. Run it locally,
9
+ inspect it, block network access, and verify source code does not leave your
10
+ machine. Commercial SeamShield layers such as premium rulepacks, dashboards,
11
+ signed rule distribution, suppression approvals, audit trails, and managed
12
+ policy workflows are outside this npm package.
13
+
8
14
  ## Install
9
15
 
10
16
  ```bash
@@ -27,6 +33,7 @@ seamshield ship .
27
33
  seamshield access . --format table
28
34
  seamshield access . --format json
29
35
  seamshield scan . --offline
36
+ seamshield investigate .
30
37
  seamshield fix-plan . --agent codex
31
38
  seamshield agent-context . --codex
32
39
  seamshield triage . --rule ss/auth/client-only-guard
@@ -43,7 +50,11 @@ seamshield ship .
43
50
  ```
44
51
 
45
52
  Runs locally and prints `SAFE TO SHIP` only when there are no block or high
46
- access-lane risks. Use this before deploys.
53
+ unsafe-to-ship access-lane risks found by the controls that ran. Use this
54
+ before deploys; it does not replace a security review.
55
+
56
+ By default, `ship` also writes a Markdown investigation under
57
+ `.seamshield/investigations/` so new repos have a durable review artifact.
47
58
 
48
59
  ## Access Map
49
60
 
@@ -56,6 +67,11 @@ Supported surfaces include Next.js/API routes, Supabase, Firebase/Firestore,
56
67
  Convex, Vercel/Coolify/self-hosted deploy config, generic Node servers, AI
57
68
  agent config, and package supply-chain risks.
58
69
 
70
+ Convex coverage includes public function checks for sensitive queries,
71
+ mutations, and actions without recognized auth/internal guards. Vercel coverage
72
+ includes `vercel.json` public env secrets, wildcard credentialed CORS, and
73
+ public privileged route/cron surfaces.
74
+
59
75
  ## Scan
60
76
 
61
77
  ```bash
@@ -64,6 +80,7 @@ seamshield scan . --format json
64
80
  seamshield scan . --format sarif
65
81
  seamshield scan . --fail-on high
66
82
  seamshield scan . --offline
83
+ seamshield scan . --no-investigation
67
84
  ```
68
85
 
69
86
  Exit codes:
@@ -74,6 +91,20 @@ Exit codes:
74
91
 
75
92
  `--offline` disables npm registry and OSV checks. Static rules still run.
76
93
 
94
+ By default, `scan` writes `.seamshield/investigations/<date>-access-lanes.md`
95
+ and prints the path to stderr so JSON/SARIF stdout remains parseable. Use
96
+ `--no-investigation` for CI jobs that should not write files.
97
+
98
+ ## Investigations
99
+
100
+ ```bash
101
+ seamshield investigate .
102
+ ```
103
+
104
+ Writes a Markdown investigation summarizing the ship verdict, severity/provider
105
+ breakdowns, normalized access lanes, and suggested next commands. This is meant
106
+ to make findings understandable in a fresh repo without uploading source code.
107
+
77
108
  ## Triage
78
109
 
79
110
  ```bash
@@ -150,3 +181,19 @@ run fully local.
150
181
 
151
182
  Secret evidence is redacted before findings, JSON, SARIF, and fix plans are
152
183
  emitted.
184
+
185
+ `learn` is currently a no-upload stub. SeamShield does not auto-update or run
186
+ untrusted community rules.
187
+
188
+ ## Release Trust
189
+
190
+ Install from the official npm package:
191
+
192
+ ```bash
193
+ npx seamshield ship .
194
+ npm install -g seamshield
195
+ ```
196
+
197
+ For release audits, use npm tarball integrity/checksum metadata and prefer the
198
+ documented package entrypoint over forks or republished packages. Signed
199
+ rulepack distribution is reserved for a future commercial/control-plane layer.
package/dist/index.js CHANGED
@@ -100,9 +100,9 @@ var require_picocolors = __commonJS({
100
100
 
101
101
  // src/index.ts
102
102
  import { spawnSync as spawnSync2 } from "child_process";
103
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
103
+ import { existsSync as existsSync2, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
104
104
  import { tmpdir } from "os";
105
- import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
105
+ import { dirname as dirname3, join as join8, resolve as resolve2 } from "path";
106
106
  import { fileURLToPath as fileURLToPath2 } from "url";
107
107
  import { Command } from "commander";
108
108
  import { parse as parse3, stringify } from "yaml";
@@ -120,9 +120,11 @@ import { spawnSync } from "child_process";
120
120
  import { basename as basename2 } from "path";
121
121
  import { mkdirSync, writeFileSync } from "fs";
122
122
  import { join as join22 } from "path";
123
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
124
+ import { join as join3 } from "path";
123
125
  import { createHash } from "crypto";
124
126
  import { readFileSync as readFileSync2, readdirSync } from "fs";
125
- import { join as join3 } from "path";
127
+ import { join as join4 } from "path";
126
128
  import { parse as parse2 } from "yaml";
127
129
  import { z as z2 } from "zod";
128
130
  import { readFileSync as readFileSync5 } from "fs";
@@ -140,12 +142,13 @@ var findingSchemaPath = join(
140
142
  );
141
143
 
142
144
  // ../core/dist/index.js
143
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
144
- import { dirname as dirname2, join as join4 } from "path";
145
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
146
+ import { dirname as dirname2, join as join5 } from "path";
145
147
  import { existsSync } from "fs";
146
- import { dirname as dirname22, join as join5 } from "path";
148
+ import { dirname as dirname22, join as join6 } from "path";
149
+ import { basename as basename3 } from "path";
147
150
  import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
148
- import { join as join6, relative as relative2 } from "path";
151
+ import { join as join7, relative as relative2 } from "path";
149
152
  import { z as z3 } from "zod";
150
153
  var ConfigSchema = z.object({
151
154
  ignore: z.array(z.string()).optional(),
@@ -289,6 +292,15 @@ var RULE_TEMPLATES = {
289
292
  risk: "untrusted_admin_surface",
290
293
  provider: "convex"
291
294
  },
295
+ "ss/convex/public-function-no-auth": {
296
+ actor: "public_user",
297
+ lane: "server_action",
298
+ asset: databaseAsset,
299
+ permission: "execute",
300
+ condition: "sensitive_public_function_no_auth",
301
+ risk: "anonymous_write",
302
+ provider: "convex"
303
+ },
292
304
  "ss/auth/api-route-no-auth": {
293
305
  actor: "public_user",
294
306
  lane: "http_route",
@@ -343,6 +355,15 @@ var RULE_TEMPLATES = {
343
355
  risk: "deploy_secret_exposure",
344
356
  provider: "deploy"
345
357
  },
358
+ "ss/vercel/config-access-risk": {
359
+ actor: "deploy_platform",
360
+ lane: "deploy_config",
361
+ asset: routeAsset,
362
+ permission: "execute",
363
+ condition: "vercel_public_deploy_surface",
364
+ risk: "deploy_secret_exposure",
365
+ provider: "vercel"
366
+ },
346
367
  "ss/client/server-secret-env-in-client": {
347
368
  actor: "frontend_bundle",
348
369
  lane: "env_variable",
@@ -720,6 +741,55 @@ function runAbsenceRule(rule, files, cache, ctx) {
720
741
  }
721
742
  return findings;
722
743
  }
744
+ var PUBLIC_FUNCTION_RE = /\bexport\s+const\s+(?<name>[A-Za-z_$][\w$]*)\s*=\s*(?<kind>query|mutation|action)\s*\(/g;
745
+ var INTERNAL_FUNCTION_RE = /\binternal(?:Query|Mutation|Action)\s*\(/;
746
+ var AUTH_MARKER_RE = /\b(?:ctx\.auth|getAuthUserId|getUserIdentity|requireAuth|requireInternalToken|getCaller|requireRole|requireAdmin)\b/;
747
+ var PUBLIC_INTENT_RE = /\b(?:seamshield-public|publicIntent|publicMutation|publicAction|allowAnonymous|rateLimit|rateLimiter|RATE_LIMITED|waitlist_join|emailHash|hashKey|normalizeEmail|isValidEmail)\b/;
748
+ var SENSITIVE_ACTION_RE = /\b(?:ctx\.db\.(?:insert|patch|replace|delete)|process\.env|admin|tenant|team|org|role|invite|token|secret|apiKey|webhook|billing|subscription|delete|recompute|backfill|sync|migrate)\b/i;
749
+ function lineForOffset(content, offset) {
750
+ return content.slice(0, offset).split(/\r?\n/).length;
751
+ }
752
+ function statementWindow(content, start) {
753
+ const end = content.indexOf("\nexport const", start + 1);
754
+ const next = end === -1 ? content.length : end;
755
+ return content.slice(start, next);
756
+ }
757
+ function isConvexSource(file) {
758
+ const rel = file.rel.split("\\").join("/");
759
+ return rel.startsWith("convex/") && !rel.includes("/_generated/") && (rel.endsWith(".ts") || rel.endsWith(".js"));
760
+ }
761
+ function checkConvexPublicFunctions(rule, files, cache, ctx) {
762
+ const findings = [];
763
+ for (const file of files) {
764
+ if (!isConvexSource(file)) continue;
765
+ const content = cache.read(file.abs);
766
+ if (content === null) continue;
767
+ if (INTERNAL_FUNCTION_RE.test(content)) continue;
768
+ PUBLIC_FUNCTION_RE.lastIndex = 0;
769
+ for (const match of content.matchAll(PUBLIC_FUNCTION_RE)) {
770
+ const kind = match.groups?.kind;
771
+ const name = match.groups?.name ?? "function";
772
+ const offset = match.index ?? 0;
773
+ const window = statementWindow(content, offset);
774
+ if (AUTH_MARKER_RE.test(window) || PUBLIC_INTENT_RE.test(window)) continue;
775
+ if (kind === "query" && !SENSITIVE_ACTION_RE.test(window)) continue;
776
+ if (!SENSITIVE_ACTION_RE.test(window)) continue;
777
+ const line = lineForOffset(content, offset);
778
+ const lines = content.split(/\r?\n/);
779
+ if (isSuppressed(lines, line - 1, rule.id)) continue;
780
+ findings.push(
781
+ buildFinding(
782
+ rule,
783
+ file.rel,
784
+ line,
785
+ `${kind ?? "function"} ${name} has sensitive server capability and no recognized auth marker`,
786
+ ctx
787
+ )
788
+ );
789
+ }
790
+ }
791
+ return findings;
792
+ }
723
793
  var ALLOWED = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
724
794
  var ENV_FILE_RE = /^\.env(\..+)?$/;
725
795
  function checkEnvFileCommitted(rule, ctx) {
@@ -820,10 +890,114 @@ function writeMarkdownFixPlan(result, options = {}) {
820
890
  writeFileSync(path, buildFixPlan(result, { agent: options.agent }).agent_markdown);
821
891
  return path;
822
892
  }
893
+ var SEVERITIES = ["block", "high", "warn", "info"];
894
+ function dateStamp2(now = /* @__PURE__ */ new Date()) {
895
+ return now.toISOString().slice(0, 10);
896
+ }
897
+ function countBy2(items, pick) {
898
+ const counts = {};
899
+ for (const item of items) counts[pick(item)] = (counts[pick(item)] ?? 0) + 1;
900
+ return counts;
901
+ }
902
+ function table(counts) {
903
+ const entries = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
904
+ if (entries.length === 0) return ["_None._"];
905
+ return ["| Item | Count |", "| --- | ---: |", ...entries.map(([key, value]) => `| \`${key}\` | ${value} |`)];
906
+ }
907
+ function laneLine(lane) {
908
+ return [
909
+ `- \`${lane.severity}\` \`${lane.source.rule_id}\``,
910
+ ` ${lane.actor} -> ${lane.lane} -> ${lane.asset} -> ${lane.permission}`,
911
+ ` (${lane.condition}, ${lane.risk})`,
912
+ ` at \`${lane.source.file}:${lane.source.line}\``
913
+ ].join(" ");
914
+ }
915
+ function sectionFor(title, lanes, limit) {
916
+ if (lanes.length === 0) return [`## ${title}`, "", "_None._", ""];
917
+ const shown = lanes.slice(0, limit);
918
+ const remaining = lanes.length - shown.length;
919
+ return [
920
+ `## ${title}`,
921
+ "",
922
+ ...shown.map(laneLine),
923
+ ...remaining > 0 ? [`- ... ${remaining} more not shown in this summary.`] : [],
924
+ ""
925
+ ];
926
+ }
927
+ function renderInvestigationMarkdown(result, options = {}) {
928
+ const access = buildAccessMap(result);
929
+ const verdict = buildShipVerdict(result);
930
+ const lanes = access.lanes;
931
+ const limit = options.itemLimit ?? 80;
932
+ const bySeverity = countBy2(lanes, (lane) => lane.severity);
933
+ const byProvider = countBy2(lanes, (lane) => lane.provider);
934
+ const byRisk = countBy2(lanes, (lane) => lane.risk);
935
+ const critical = lanes.filter((lane) => lane.severity === "block" || lane.severity === "high");
936
+ const warnings = lanes.filter((lane) => lane.severity === "warn");
937
+ const info = lanes.filter((lane) => lane.severity === "info");
938
+ return [
939
+ "# SeamShield Investigation",
940
+ "",
941
+ `Date: ${dateStamp2(options.now)}`,
942
+ `Target: \`${result.target}\``,
943
+ `Verdict: **${verdict.verdict}**`,
944
+ `Policy bundle: \`${result.policyBundleDigest}\``,
945
+ "",
946
+ "## What This Means",
947
+ "",
948
+ verdict.verdict === "SAFE TO SHIP" ? "No block or high unsafe-to-ship access lanes were found by the controls that ran. Warnings still deserve triage." : "One or more block/high access lanes were found. Treat these as release blockers until fixed or explicitly triaged.",
949
+ "",
950
+ "SeamShield reports access-lane risk. It does not claim that the whole app has no vulnerabilities.",
951
+ "",
952
+ "## Summary",
953
+ "",
954
+ `- Files scanned: ${result.filesScanned}`,
955
+ `- Rules loaded: ${result.rulesLoaded}`,
956
+ `- Findings: ${result.findings.length}`,
957
+ `- Access lanes: ${lanes.length}`,
958
+ "",
959
+ "### By Severity",
960
+ "",
961
+ ...table(Object.fromEntries(SEVERITIES.map((severity) => [severity, bySeverity[severity] ?? 0]))),
962
+ "",
963
+ "### By Provider",
964
+ "",
965
+ ...table(byProvider),
966
+ "",
967
+ "### By Risk",
968
+ "",
969
+ ...table(byRisk),
970
+ "",
971
+ ...sectionFor("Critical Access Lanes", critical, limit),
972
+ ...sectionFor("Warnings To Triage", warnings, limit),
973
+ ...sectionFor("Informational Findings", info, Math.min(limit, 30)),
974
+ "## Suggested Next Steps",
975
+ "",
976
+ "- Run `seamshield access --format table` to inspect normalized lanes.",
977
+ "- Run `seamshield fix-plan --agent codex` to generate provider-aware remediation prompts.",
978
+ "- Use `seamshield triage --rule <rule-id>` only after validating a false positive or accepted risk.",
979
+ ""
980
+ ].join("\n");
981
+ }
982
+ function writeInvestigationMarkdown(result, options = {}) {
983
+ const dir = join3(result.target, ".seamshield", "investigations");
984
+ mkdirSync2(dir, { recursive: true });
985
+ const path = join3(dir, `${dateStamp2(options.now)}-access-lanes.md`);
986
+ writeFileSync2(path, renderInvestigationMarkdown(result, options));
987
+ return path;
988
+ }
823
989
  var PatternSchema = z2.object({
824
990
  name: z2.string().min(1),
825
991
  regex: z2.string().min(1)
826
992
  });
993
+ var BuiltinSchema = z2.enum([
994
+ "env-file-committed",
995
+ "no-lockfile",
996
+ "hallucinated-package",
997
+ "known-vuln",
998
+ "convex-public-function-no-auth",
999
+ "vercel-config"
1000
+ ]);
827
1001
  var RuleSchema = z2.object({
828
1002
  id: z2.string().regex(/^ss\/[a-z-]+\/[a-z0-9-]+$/),
829
1003
  severity: z2.enum(["block", "high", "warn", "info"]),
@@ -832,7 +1006,7 @@ var RuleSchema = z2.object({
832
1006
  framework_ref: z2.string().min(1),
833
1007
  check: z2.object({
834
1008
  type: z2.enum(["regex", "absence", "builtin"]),
835
- builtin: z2.string().optional(),
1009
+ builtin: BuiltinSchema.optional(),
836
1010
  include: z2.object({
837
1011
  extensions: z2.array(z2.string()).optional(),
838
1012
  basenames: z2.array(z2.string()).optional(),
@@ -862,7 +1036,7 @@ function loadRules(rulesDir2) {
862
1036
  const hash = createHash("sha256");
863
1037
  const contents = /* @__PURE__ */ new Map();
864
1038
  for (const name of yamlFiles) {
865
- const text = readFileSync2(join3(rulesDir2, name), "utf8");
1039
+ const text = readFileSync2(join4(rulesDir2, name), "utf8");
866
1040
  contents.set(name, text);
867
1041
  hash.update(name);
868
1042
  hash.update("\n");
@@ -1047,7 +1221,7 @@ function collectDependencies(ctx, files) {
1047
1221
  }
1048
1222
  function cachePath(ctx, kind, key) {
1049
1223
  const safe = encodeURIComponent(key).replace(/[!'()*]/g, "_");
1050
- return join4(ctx.root, ".seamshield", "cache", kind, `${safe}.json`);
1224
+ return join5(ctx.root, ".seamshield", "cache", kind, `${safe}.json`);
1051
1225
  }
1052
1226
  function readCache(ctx, kind, key) {
1053
1227
  try {
@@ -1061,8 +1235,8 @@ function readCache(ctx, kind, key) {
1061
1235
  }
1062
1236
  function writeCache(ctx, kind, key, value) {
1063
1237
  const path = cachePath(ctx, kind, key);
1064
- mkdirSync2(dirname2(path), { recursive: true });
1065
- writeFileSync2(path, JSON.stringify({ ok: true, value, time: Date.now() }));
1238
+ mkdirSync3(dirname2(path), { recursive: true });
1239
+ writeFileSync3(path, JSON.stringify({ ok: true, value, time: Date.now() }));
1066
1240
  }
1067
1241
  async function fetchJson(url, init, timeoutMs, fetchImpl) {
1068
1242
  const controller = new AbortController();
@@ -1172,16 +1346,119 @@ async function checkKnownVulnerabilities(rule, ctx, files, options = {}) {
1172
1346
  }
1173
1347
  var LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
1174
1348
  function checkNoLockfile(rule, ctx) {
1175
- if (!existsSync(join5(ctx.root, "package.json"))) return [];
1349
+ if (!existsSync(join6(ctx.root, "package.json"))) return [];
1176
1350
  let dir = ctx.root;
1177
1351
  for (let depth = 0; depth < 8; depth++) {
1178
- if (LOCKFILES.some((name) => existsSync(join5(dir, name)))) return [];
1352
+ if (LOCKFILES.some((name) => existsSync(join6(dir, name)))) return [];
1179
1353
  const parent = dirname22(dir);
1180
1354
  if (parent === dir) break;
1181
1355
  dir = parent;
1182
1356
  }
1183
1357
  return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
1184
1358
  }
1359
+ var PUBLIC_SECRET_ENV_RE = /^(?:NEXT_PUBLIC_|VITE_|PUBLIC_).*(?:SECRET|PRIVATE|PASSWORD|SERVICE_ROLE|API_KEY|TOKEN)/i;
1360
+ var ADMIN_ROUTE_RE = /\/(?:api\/)?(?:admin|internal|debug|ops|backoffice)(?:\/|$)/i;
1361
+ function walkJson(value, visit, path = []) {
1362
+ visit(path, value);
1363
+ if (Array.isArray(value)) {
1364
+ value.forEach((item, index) => walkJson(item, visit, [...path, String(index)]));
1365
+ return;
1366
+ }
1367
+ if (value && typeof value === "object") {
1368
+ for (const [key, item] of Object.entries(value)) walkJson(item, visit, [...path, key]);
1369
+ }
1370
+ }
1371
+ function valueAtPath(root, path) {
1372
+ let current = root;
1373
+ for (const segment of path) {
1374
+ if (Array.isArray(current)) {
1375
+ current = current[Number(segment)];
1376
+ continue;
1377
+ }
1378
+ if (!current || typeof current !== "object") return void 0;
1379
+ current = current[segment];
1380
+ }
1381
+ return current;
1382
+ }
1383
+ function hasVercelAuthSignal(root) {
1384
+ let found = false;
1385
+ walkJson(root, (path, value) => {
1386
+ if (found || typeof value !== "string") return;
1387
+ const key = path[path.length - 1] ?? "";
1388
+ if (/authorization|x-vercel-protection-bypass|middleware|auth|token/i.test(`${key}:${value}`)) {
1389
+ found = true;
1390
+ }
1391
+ });
1392
+ return found;
1393
+ }
1394
+ function lineForNeedle(content, needle) {
1395
+ const index = content.indexOf(needle);
1396
+ if (index === -1) return 1;
1397
+ return content.slice(0, index).split(/\r?\n/).length;
1398
+ }
1399
+ function addFinding(findings, rule, file, line, evidence, ctx) {
1400
+ findings.push(buildFinding(rule, file, line, evidence.slice(0, 120), ctx));
1401
+ }
1402
+ function checkVercelConfig(rule, files, cache, ctx) {
1403
+ const findings = [];
1404
+ for (const file of files) {
1405
+ if (basename3(file.rel) !== "vercel.json") continue;
1406
+ const content = cache.read(file.abs);
1407
+ if (content === null) continue;
1408
+ let parsed;
1409
+ try {
1410
+ parsed = JSON.parse(content);
1411
+ } catch {
1412
+ continue;
1413
+ }
1414
+ const hasAuthSignal = hasVercelAuthSignal(parsed);
1415
+ walkJson(parsed, (path, value) => {
1416
+ const key = path[path.length - 1] ?? "";
1417
+ if (typeof value !== "string") return;
1418
+ if (PUBLIC_SECRET_ENV_RE.test(key)) {
1419
+ addFinding(findings, rule, file.rel, lineForNeedle(content, key), `public env ${key}`, ctx);
1420
+ }
1421
+ const joinedPath = path.join(".");
1422
+ if (/^(routes|rewrites|redirects)\.\d+\.(source|src|destination|dest)$/.test(joinedPath) && ADMIN_ROUTE_RE.test(value) && !hasAuthSignal) {
1423
+ addFinding(
1424
+ findings,
1425
+ rule,
1426
+ file.rel,
1427
+ lineForNeedle(content, value),
1428
+ `public ${joinedPath} exposes ${value}`,
1429
+ ctx
1430
+ );
1431
+ }
1432
+ if (/^crons\.\d+\.path$/.test(joinedPath) && ADMIN_ROUTE_RE.test(value) && !hasAuthSignal) {
1433
+ addFinding(
1434
+ findings,
1435
+ rule,
1436
+ file.rel,
1437
+ lineForNeedle(content, value),
1438
+ `cron path targets privileged route ${value}`,
1439
+ ctx
1440
+ );
1441
+ }
1442
+ });
1443
+ const headers = valueAtPath(parsed, ["headers"]);
1444
+ if (Array.isArray(headers)) {
1445
+ for (const header of headers) {
1446
+ const stringified = JSON.stringify(header);
1447
+ if (/access-control-allow-origin/i.test(stringified) && /"\*"/.test(stringified) && /access-control-allow-credentials/i.test(stringified) && /true/i.test(stringified)) {
1448
+ addFinding(
1449
+ findings,
1450
+ rule,
1451
+ file.rel,
1452
+ lineForNeedle(content, "Access-Control-Allow-Origin"),
1453
+ "wildcard CORS origin with credentials in vercel.json",
1454
+ ctx
1455
+ );
1456
+ }
1457
+ }
1458
+ }
1459
+ }
1460
+ return findings;
1461
+ }
1185
1462
  var SKIP_DIRS = /* @__PURE__ */ new Set([
1186
1463
  "node_modules",
1187
1464
  ".git",
@@ -1198,8 +1475,53 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
1198
1475
  ".pnpm-store"
1199
1476
  ]);
1200
1477
  var MAX_FILE_BYTES = 1e6;
1478
+ function escapeRegex(value) {
1479
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1480
+ }
1481
+ function globToRegex(pattern) {
1482
+ let source = "";
1483
+ for (const char of pattern) {
1484
+ source += char === "*" ? "[^/]*" : escapeRegex(char);
1485
+ }
1486
+ return new RegExp(`^${source}$`);
1487
+ }
1488
+ function loadGitignore(root) {
1489
+ let text;
1490
+ try {
1491
+ text = readFileSync4(join7(root, ".gitignore"), "utf8");
1492
+ } catch {
1493
+ return [];
1494
+ }
1495
+ return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((line) => {
1496
+ const negated = line.startsWith("!");
1497
+ return { pattern: (negated ? line.slice(1) : line).replace(/^\/+/, ""), negated };
1498
+ }).filter((rule) => rule.pattern.length > 0);
1499
+ }
1500
+ function globMatches(value, pattern) {
1501
+ const normalized = value.split("\\").join("/");
1502
+ const clean = pattern.replace(/\/$/, "");
1503
+ if (clean.includes("*")) {
1504
+ const re = globToRegex(clean);
1505
+ if (re.test(normalized)) return true;
1506
+ const base = normalized.split("/").pop() ?? normalized;
1507
+ return !clean.includes("/") && re.test(base);
1508
+ }
1509
+ if (clean.includes("/")) return normalized === clean || normalized.startsWith(`${clean}/`);
1510
+ const segments = normalized.split("/");
1511
+ return segments.includes(clean);
1512
+ }
1513
+ function isGitIgnored(rel, rules) {
1514
+ let ignored = false;
1515
+ const normalized = rel.split("\\").join("/");
1516
+ for (const rule of rules) {
1517
+ if (!globMatches(normalized, rule.pattern)) continue;
1518
+ ignored = !rule.negated;
1519
+ }
1520
+ return ignored;
1521
+ }
1201
1522
  function walk(root) {
1202
1523
  const files = [];
1524
+ const gitignore = loadGitignore(root);
1203
1525
  const visit = (dir) => {
1204
1526
  let entries;
1205
1527
  try {
@@ -1209,12 +1531,13 @@ function walk(root) {
1209
1531
  }
1210
1532
  for (const entry of entries) {
1211
1533
  if (entry.isSymbolicLink()) continue;
1212
- const abs = join6(dir, entry.name);
1534
+ const abs = join7(dir, entry.name);
1213
1535
  if (entry.isDirectory()) {
1214
1536
  const rel = relative2(root, abs).split("\\").join("/");
1215
1537
  if (SKIP_DIRS.has(entry.name)) continue;
1216
1538
  if (entry.name === ".worktrees") continue;
1217
1539
  if (rel === ".claude/worktrees" || rel.endsWith("/.claude/worktrees")) continue;
1540
+ if (isGitIgnored(rel, gitignore)) continue;
1218
1541
  visit(abs);
1219
1542
  } else if (entry.isFile()) {
1220
1543
  try {
@@ -1222,7 +1545,9 @@ function walk(root) {
1222
1545
  } catch {
1223
1546
  continue;
1224
1547
  }
1225
- files.push({ abs, rel: relative2(root, abs) });
1548
+ const rel = relative2(root, abs).split("\\").join("/");
1549
+ if (isGitIgnored(rel, gitignore)) continue;
1550
+ files.push({ abs, rel });
1226
1551
  }
1227
1552
  }
1228
1553
  };
@@ -1266,6 +1591,10 @@ function scan(target, options = {}) {
1266
1591
  findings.push(...checkEnvFileCommitted(rule, ctx));
1267
1592
  } else if (rule.check.builtin === "no-lockfile") {
1268
1593
  findings.push(...checkNoLockfile(rule, ctx));
1594
+ } else if (rule.check.builtin === "convex-public-function-no-auth") {
1595
+ findings.push(...checkConvexPublicFunctions(rule, files, cache, ctx));
1596
+ } else if (rule.check.builtin === "vercel-config") {
1597
+ findings.push(...checkVercelConfig(rule, files, cache, ctx));
1269
1598
  } else if (rule.check.builtin === "hallucinated-package" || rule.check.builtin === "known-vuln") {
1270
1599
  continue;
1271
1600
  } else {
@@ -1438,6 +1767,11 @@ function render(format, result) {
1438
1767
  if (format === "sarif") return renderSarif(result);
1439
1768
  return renderTable(result);
1440
1769
  }
1770
+ function maybeWriteInvestigation(result, enabled) {
1771
+ if (enabled === false) return;
1772
+ const out = writeInvestigationMarkdown(result);
1773
+ console.error(`Investigation written: ${out}`);
1774
+ }
1441
1775
  async function runScan(path, opts) {
1442
1776
  if (!assertOptions(opts)) return;
1443
1777
  if (!existsSync2(path)) {
@@ -1450,6 +1784,7 @@ async function runScan(path, opts) {
1450
1784
  failOn: opts.failOn,
1451
1785
  network: opts.offline ? "off" : "on"
1452
1786
  });
1787
+ maybeWriteInvestigation(result, opts.investigation);
1453
1788
  console.log(render(opts.format, result));
1454
1789
  process.exitCode = result.exitCode;
1455
1790
  } catch (error) {
@@ -1472,10 +1807,10 @@ async function readScanForCommand(path, offline = true) {
1472
1807
  }
1473
1808
  }
1474
1809
  function writeSection(path, marker, body) {
1475
- mkdirSync3(dirname3(path), { recursive: true });
1810
+ mkdirSync4(dirname3(path), { recursive: true });
1476
1811
  const existing = existsSync2(path) ? readFileSync6(path, "utf8") : "";
1477
1812
  const next = existing.includes(marker) ? existing.replace(new RegExp(`${marker}[\\s\\S]*?(?=\\n# |\\n?$)`), body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
1478
- writeFileSync3(path, next.endsWith("\n") ? next : `${next}
1813
+ writeFileSync4(path, next.endsWith("\n") ? next : `${next}
1479
1814
  `);
1480
1815
  return path;
1481
1816
  }
@@ -1484,6 +1819,7 @@ function writeAgentContext(target, kind) {
1484
1819
  "# SEAMSHIELD",
1485
1820
  "",
1486
1821
  "- Run `npx seamshield ship .` before deploys and `npx seamshield scan --offline` before committing AI-generated changes.",
1822
+ "- Review `.seamshield/investigations/` after scans; it explains findings and open access lanes in Markdown.",
1487
1823
  "- Never hardcode provider keys, service-role keys, private keys, or dotenv contents.",
1488
1824
  "- Do not expose server secrets through `NEXT_PUBLIC_*` or client components.",
1489
1825
  "- Do not rely on client-only auth for private data; enforce auth server-side.",
@@ -1492,23 +1828,23 @@ function writeAgentContext(target, kind) {
1492
1828
  ""
1493
1829
  ].join("\n");
1494
1830
  if (kind === "cursor") {
1495
- const out = join7(target, ".cursor", "rules", "seamshield.mdc");
1496
- mkdirSync3(dirname3(out), { recursive: true });
1497
- writeFileSync3(out, body);
1831
+ const out = join8(target, ".cursor", "rules", "seamshield.mdc");
1832
+ mkdirSync4(dirname3(out), { recursive: true });
1833
+ writeFileSync4(out, body);
1498
1834
  return out;
1499
1835
  }
1500
- return writeSection(join7(target, kind === "codex" ? "AGENTS.md" : "CLAUDE.md"), "# SEAMSHIELD", body);
1836
+ return writeSection(join8(target, kind === "codex" ? "AGENTS.md" : "CLAUDE.md"), "# SEAMSHIELD", body);
1501
1837
  }
1502
1838
  function readTriageConfig(target) {
1503
- const path = join7(target, ".seamshield", "config.yaml");
1839
+ const path = join8(target, ".seamshield", "config.yaml");
1504
1840
  if (!existsSync2(path)) return {};
1505
1841
  const parsed = parse3(readFileSync6(path, "utf8"));
1506
1842
  return parsed && typeof parsed === "object" ? parsed : {};
1507
1843
  }
1508
1844
  function writeTriageConfig(target, config) {
1509
- const out = join7(target, ".seamshield", "config.yaml");
1510
- mkdirSync3(dirname3(out), { recursive: true });
1511
- writeFileSync3(out, stringify(config));
1845
+ const out = join8(target, ".seamshield", "config.yaml");
1846
+ mkdirSync4(dirname3(out), { recursive: true });
1847
+ writeFileSync4(out, stringify(config));
1512
1848
  return out;
1513
1849
  }
1514
1850
  async function writeTriageSuppressions(path, opts) {
@@ -1544,8 +1880,8 @@ function currentBin() {
1544
1880
  return fileURLToPath2(import.meta.url);
1545
1881
  }
1546
1882
  function installGuard(target) {
1547
- const settingsPath = join7(target, ".claude", "settings.json");
1548
- mkdirSync3(dirname3(settingsPath), { recursive: true });
1883
+ const settingsPath = join8(target, ".claude", "settings.json");
1884
+ mkdirSync4(dirname3(settingsPath), { recursive: true });
1549
1885
  const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
1550
1886
  const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
1551
1887
  const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
@@ -1556,7 +1892,7 @@ function installGuard(target) {
1556
1892
  }
1557
1893
  ];
1558
1894
  settings.hooks = hooks;
1559
- writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
1895
+ writeFileSync4(settingsPath, `${JSON.stringify(settings, null, 2)}
1560
1896
  `);
1561
1897
  return settingsPath;
1562
1898
  }
@@ -1617,10 +1953,10 @@ function guardCheck() {
1617
1953
  console.log(JSON.stringify(hookAllow()));
1618
1954
  return;
1619
1955
  }
1620
- const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
1621
- const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
1622
- mkdirSync3(dirname3(abs), { recursive: true });
1623
- writeFileSync3(abs, proposed.content);
1956
+ const tempRoot = mkdtempSync(join8(tmpdir(), "seamshield-guard-"));
1957
+ const abs = join8(tempRoot, proposed.rel.replace(/^\/+/, ""));
1958
+ mkdirSync4(dirname3(abs), { recursive: true });
1959
+ writeFileSync4(abs, proposed.content);
1624
1960
  const result = scan(tempRoot, { network: "off" });
1625
1961
  rmSync(tempRoot, { recursive: true, force: true });
1626
1962
  const block = result.findings.find((f) => f.finding.severity === "block");
@@ -1634,10 +1970,10 @@ function guardCheck() {
1634
1970
  }
1635
1971
  console.log(JSON.stringify(hookAllow()));
1636
1972
  } catch (error) {
1637
- const logPath = join7(process.cwd(), ".seamshield", "guard.log");
1973
+ const logPath = join8(process.cwd(), ".seamshield", "guard.log");
1638
1974
  try {
1639
- mkdirSync3(dirname3(logPath), { recursive: true });
1640
- writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1975
+ mkdirSync4(dirname3(logPath), { recursive: true });
1976
+ writeFileSync4(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1641
1977
  `, { flag: "a" });
1642
1978
  } catch {
1643
1979
  }
@@ -1646,16 +1982,23 @@ function guardCheck() {
1646
1982
  }
1647
1983
  var program = new Command();
1648
1984
  program.name("seamshield").description("Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship.").version(pkg.version);
1649
- program.command("scan").description("Scan a project directory and report findings").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json | sarif", "table").option("--fail-on <severity>", "exit 1 at or above: block | high | warn | never", "block").option("--offline", "skip npm registry and OSV network checks").action((path, opts) => {
1985
+ program.command("scan").description("Scan a project directory and report findings").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json | sarif", "table").option("--fail-on <severity>", "exit 1 at or above: block | high | warn | never", "block").option("--offline", "skip npm registry and OSV network checks").option("--no-investigation", "do not write .seamshield/investigations/*.md").action((path, opts) => {
1650
1986
  return runScan(path, opts);
1651
1987
  });
1652
1988
  program.command("ship").description("Give a deploy verdict from dangerous access lanes").argument("[path]", "directory to scan", ".").option("--online", "include network-backed dependency intelligence").action(async (path, opts) => {
1653
1989
  const result = await readScanForCommand(path, !opts.online);
1654
1990
  if (!result) return;
1991
+ maybeWriteInvestigation(result, true);
1655
1992
  const verdict = buildShipVerdict(result);
1656
1993
  console.log(renderShipTable(verdict));
1657
1994
  process.exitCode = verdict.exitCode;
1658
1995
  });
1996
+ program.command("investigate").description("Write a Markdown investigation for current access-lane findings").argument("[path]", "directory to scan", ".").option("--online", "include network-backed dependency intelligence").action(async (path, opts) => {
1997
+ const result = await readScanForCommand(path, !opts.online);
1998
+ if (!result) return;
1999
+ console.log(writeInvestigationMarkdown(result));
2000
+ process.exitCode = 0;
2001
+ });
1659
2002
  program.command("access").description("Show the normalized Actor -> Lane -> Asset -> Permission -> Condition -> Risk access map").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json", "table").option("--online", "include network-backed dependency intelligence").action(async (path, opts) => {
1660
2003
  if (!assertChoice(opts.format, ACCESS_FORMATS, "format")) return;
1661
2004
  const result = await readScanForCommand(path, !opts.online);
@@ -1672,9 +2015,9 @@ program.command("fix-plan").description("Write agent-ready fix prompts for dange
1672
2015
  return;
1673
2016
  }
1674
2017
  const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
1675
- const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
1676
- mkdirSync3(dirname3(out), { recursive: true });
1677
- writeFileSync3(out, `${JSON.stringify(buildFixPlan(result, { agent: opts.agent }), null, 2)}
2018
+ const out = join8(resolve2(path), ".seamshield", "fix-plan.json");
2019
+ mkdirSync4(dirname3(out), { recursive: true });
2020
+ writeFileSync4(out, `${JSON.stringify(buildFixPlan(result, { agent: opts.agent }), null, 2)}
1678
2021
  `);
1679
2022
  const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
1680
2023
  console.log(out);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seamshield",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,7 @@ check:
16
16
  file_contains: "(?<![A-Za-z])mutation\\s*\\("
17
17
  patterns:
18
18
  - name: convex-auth-markers
19
- regex: "ctx\\.auth|getAuthUserId|getUserIdentity|requireAuth|requireInternalToken|getCaller|requireRole|requireAdmin|internalMutation|rateLimit|rateLimiter|RATE_LIMITED|emailHash|hashKey|normalizeEmail|isValidEmail|waitlist_join"
19
+ regex: "ctx\\.auth|getAuthUserId|getUserIdentity|requireAuth|requireInternalToken|getCaller|requireRole|requireAdmin|internalMutation|seamshield-public|publicIntent|publicMutation|allowAnonymous|rateLimit|rateLimiter|RATE_LIMITED|emailHash|hashKey|normalizeEmail|isValidEmail|waitlist_join"
20
20
  fix:
21
21
  summary: Verify the caller with ctx.auth at the top of the mutation, or make it internal.
22
22
  agent_prompt: >
@@ -0,0 +1,20 @@
1
+ id: ss/convex/public-function-no-auth
2
+ severity: warn
3
+ title: Public Convex function with sensitive capability and no auth check
4
+ description: >
5
+ A public Convex query, mutation, or action touches sensitive server capability
6
+ such as database writes, privileged tenant/team paths, secrets, or admin-like
7
+ operations without a recognized auth or internal-only guard.
8
+ framework_ref: OPEN_CORE.md#oss-scanner-boundary
9
+ check:
10
+ type: builtin
11
+ builtin: convex-public-function-no-auth
12
+ fix:
13
+ summary: Add a server-side auth/role guard, mark the function internal, or explicitly document safe public intent.
14
+ agent_prompt: >
15
+ Review this Convex function as a public entrypoint. If it changes data,
16
+ accesses tenant/team/admin state, or reads privileged runtime capability,
17
+ add ctx.auth/getAuthUserId plus the required role or tenant check near the
18
+ top. If it is server-only, convert it to internalQuery/internalMutation/
19
+ internalAction. If it is intentionally public and low risk, add an explicit
20
+ public-intent marker and rate limit instead of weakening auth checks.
@@ -8,8 +8,11 @@ description: >
8
8
  framework_ref: AI_AGENT_DROP_IN.md#default-security-stance
9
9
  check:
10
10
  type: regex
11
+ include:
12
+ extensions: [".pem", ".key", ".crt", ".cer", ".p12", ".pfx", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".yaml", ".yml"]
13
+ basenames: ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "*.pem", "*.key"]
11
14
  exclude:
12
- basenames: ["*.pub"]
15
+ basenames: ["*.pub", ".env", ".env.*", "*.env", "*.env.*"]
13
16
  patterns:
14
17
  - name: pem-private-key
15
18
  regex: "-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----"
@@ -0,0 +1,19 @@
1
+ id: ss/vercel/config-access-risk
2
+ severity: high
3
+ title: Vercel config opens a risky access lane
4
+ description: >
5
+ vercel.json exposes a public/client secret, wildcard credentialed CORS, or a
6
+ privileged route through rewrites, routes, redirects, or cron paths without an
7
+ obvious auth/protection signal.
8
+ framework_ref: OPEN_CORE.md#oss-scanner-boundary
9
+ check:
10
+ type: builtin
11
+ builtin: vercel-config
12
+ fix:
13
+ summary: Move secrets to server-only env, restrict public headers, and protect privileged Vercel routes.
14
+ agent_prompt: >
15
+ Inspect vercel.json and close the risky access lane. Do not place secrets in
16
+ NEXT_PUBLIC_, VITE_, or PUBLIC_ variables. Avoid wildcard credentialed CORS.
17
+ For admin/internal/debug/ops routes, require middleware, authorization
18
+ headers, or Vercel protection before the route can be reached from the
19
+ public internet.