seamshield 0.2.0 → 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,7 +33,9 @@ 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
38
+ seamshield agent-context . --codex
31
39
  seamshield triage . --rule ss/auth/client-only-guard
32
40
  seamshield agent-context . --claude
33
41
  seamshield agent-context . --cursor
@@ -42,7 +50,11 @@ seamshield ship .
42
50
  ```
43
51
 
44
52
  Runs locally and prints `SAFE TO SHIP` only when there are no block or high
45
- 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.
46
58
 
47
59
  ## Access Map
48
60
 
@@ -55,6 +67,11 @@ Supported surfaces include Next.js/API routes, Supabase, Firebase/Firestore,
55
67
  Convex, Vercel/Coolify/self-hosted deploy config, generic Node servers, AI
56
68
  agent config, and package supply-chain risks.
57
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
+
58
75
  ## Scan
59
76
 
60
77
  ```bash
@@ -63,6 +80,7 @@ seamshield scan . --format json
63
80
  seamshield scan . --format sarif
64
81
  seamshield scan . --fail-on high
65
82
  seamshield scan . --offline
83
+ seamshield scan . --no-investigation
66
84
  ```
67
85
 
68
86
  Exit codes:
@@ -73,6 +91,20 @@ Exit codes:
73
91
 
74
92
  `--offline` disables npm registry and OSV checks. Static rules still run.
75
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
+
76
108
  ## Triage
77
109
 
78
110
  ```bash
@@ -98,15 +130,19 @@ Writes `.seamshield/fix-plan.json` and a Markdown plan under
98
130
  ## Agent Guard
99
131
 
100
132
  ```bash
133
+ seamshield agent-context . --codex
101
134
  seamshield agent-context . --claude
102
135
  seamshield agent-context . --cursor
103
136
  seamshield guard install .
104
137
  ```
105
138
 
106
- `agent-context` writes agent instructions. `guard install` adds a Claude Code
107
- `PreToolUse` hook that blocks high-confidence risky edits such as committed
108
- dotenv files, exposed server secrets, public database/storage writes, unsafe
109
- `.env` edits, dangerous shell installs, and obvious privileged route exposure.
139
+ `agent-context --codex` writes `AGENTS.md`. `--claude` writes `CLAUDE.md`.
140
+ `--cursor` writes `.cursor/rules/seamshield.mdc`.
141
+
142
+ `guard install` adds a Claude Code `PreToolUse` hook that blocks
143
+ high-confidence risky edits such as committed dotenv files, exposed server
144
+ secrets, public database/storage writes, unsafe `.env` edits, dangerous shell
145
+ installs, and obvious privileged route exposure.
110
146
 
111
147
  Guard behavior is fail-open: if the hook errors, it allows the tool call and
112
148
  appends diagnostics to `.seamshield/guard.log`.
@@ -145,3 +181,19 @@ run fully local.
145
181
 
146
182
  Secret evidence is redacted before findings, JSON, SARIF, and fix plans are
147
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 {
@@ -1413,6 +1742,7 @@ var FORMATS = ["table", "json", "sarif"];
1413
1742
  var ACCESS_FORMATS = ["table", "json"];
1414
1743
  var FAIL_ON = ["block", "high", "warn", "never"];
1415
1744
  var FIX_AGENTS = ["claude", "cursor", "codex", "generic"];
1745
+ var CONTEXT_AGENTS = ["claude", "cursor", "codex"];
1416
1746
  function assertChoice(value, allowed, label) {
1417
1747
  if (value && !allowed.includes(value)) {
1418
1748
  console.error(`seamshield: unknown --${label} "${value}" (expected: ${allowed.join(", ")})`);
@@ -1437,6 +1767,11 @@ function render(format, result) {
1437
1767
  if (format === "sarif") return renderSarif(result);
1438
1768
  return renderTable(result);
1439
1769
  }
1770
+ function maybeWriteInvestigation(result, enabled) {
1771
+ if (enabled === false) return;
1772
+ const out = writeInvestigationMarkdown(result);
1773
+ console.error(`Investigation written: ${out}`);
1774
+ }
1440
1775
  async function runScan(path, opts) {
1441
1776
  if (!assertOptions(opts)) return;
1442
1777
  if (!existsSync2(path)) {
@@ -1449,6 +1784,7 @@ async function runScan(path, opts) {
1449
1784
  failOn: opts.failOn,
1450
1785
  network: opts.offline ? "off" : "on"
1451
1786
  });
1787
+ maybeWriteInvestigation(result, opts.investigation);
1452
1788
  console.log(render(opts.format, result));
1453
1789
  process.exitCode = result.exitCode;
1454
1790
  } catch (error) {
@@ -1470,42 +1806,45 @@ async function readScanForCommand(path, offline = true) {
1470
1806
  return null;
1471
1807
  }
1472
1808
  }
1809
+ function writeSection(path, marker, body) {
1810
+ mkdirSync4(dirname3(path), { recursive: true });
1811
+ const existing = existsSync2(path) ? readFileSync6(path, "utf8") : "";
1812
+ const next = existing.includes(marker) ? existing.replace(new RegExp(`${marker}[\\s\\S]*?(?=\\n# |\\n?$)`), body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
1813
+ writeFileSync4(path, next.endsWith("\n") ? next : `${next}
1814
+ `);
1815
+ return path;
1816
+ }
1473
1817
  function writeAgentContext(target, kind) {
1474
1818
  const body = [
1475
1819
  "# SEAMSHIELD",
1476
1820
  "",
1477
- "- Run `npx seamshield scan --offline` before committing AI-generated changes.",
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.",
1478
1823
  "- Never hardcode provider keys, service-role keys, private keys, or dotenv contents.",
1479
1824
  "- Do not expose server secrets through `NEXT_PUBLIC_*` or client components.",
1480
1825
  "- Do not rely on client-only auth for private data; enforce auth server-side.",
1481
- "- Keep Supabase RLS enabled and Firebase rules closed by default.",
1482
- "- If SeamShield reports findings, apply the generated `npx seamshield fix-plan` prompts.",
1826
+ "- Keep Supabase RLS enabled, Firebase rules closed by default, and Convex privileged mutations authenticated or internal.",
1827
+ `- If SeamShield reports findings, inspect \`npx seamshield access .\` and apply \`npx seamshield fix-plan . --agent ${kind === "codex" ? "codex" : kind}\`.`,
1483
1828
  ""
1484
1829
  ].join("\n");
1485
1830
  if (kind === "cursor") {
1486
- const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
1487
- mkdirSync3(dirname3(out2), { recursive: true });
1488
- writeFileSync3(out2, body);
1489
- return out2;
1831
+ const out = join8(target, ".cursor", "rules", "seamshield.mdc");
1832
+ mkdirSync4(dirname3(out), { recursive: true });
1833
+ writeFileSync4(out, body);
1834
+ return out;
1490
1835
  }
1491
- const out = join7(target, "CLAUDE.md");
1492
- const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
1493
- const marker = "# SEAMSHIELD";
1494
- const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
1495
- writeFileSync3(out, next.endsWith("\n") ? next : `${next}
1496
- `);
1497
- return out;
1836
+ return writeSection(join8(target, kind === "codex" ? "AGENTS.md" : "CLAUDE.md"), "# SEAMSHIELD", body);
1498
1837
  }
1499
1838
  function readTriageConfig(target) {
1500
- const path = join7(target, ".seamshield", "config.yaml");
1839
+ const path = join8(target, ".seamshield", "config.yaml");
1501
1840
  if (!existsSync2(path)) return {};
1502
1841
  const parsed = parse3(readFileSync6(path, "utf8"));
1503
1842
  return parsed && typeof parsed === "object" ? parsed : {};
1504
1843
  }
1505
1844
  function writeTriageConfig(target, config) {
1506
- const out = join7(target, ".seamshield", "config.yaml");
1507
- mkdirSync3(dirname3(out), { recursive: true });
1508
- writeFileSync3(out, stringify(config));
1845
+ const out = join8(target, ".seamshield", "config.yaml");
1846
+ mkdirSync4(dirname3(out), { recursive: true });
1847
+ writeFileSync4(out, stringify(config));
1509
1848
  return out;
1510
1849
  }
1511
1850
  async function writeTriageSuppressions(path, opts) {
@@ -1541,8 +1880,8 @@ function currentBin() {
1541
1880
  return fileURLToPath2(import.meta.url);
1542
1881
  }
1543
1882
  function installGuard(target) {
1544
- const settingsPath = join7(target, ".claude", "settings.json");
1545
- mkdirSync3(dirname3(settingsPath), { recursive: true });
1883
+ const settingsPath = join8(target, ".claude", "settings.json");
1884
+ mkdirSync4(dirname3(settingsPath), { recursive: true });
1546
1885
  const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
1547
1886
  const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
1548
1887
  const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
@@ -1553,7 +1892,7 @@ function installGuard(target) {
1553
1892
  }
1554
1893
  ];
1555
1894
  settings.hooks = hooks;
1556
- writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
1895
+ writeFileSync4(settingsPath, `${JSON.stringify(settings, null, 2)}
1557
1896
  `);
1558
1897
  return settingsPath;
1559
1898
  }
@@ -1614,10 +1953,10 @@ function guardCheck() {
1614
1953
  console.log(JSON.stringify(hookAllow()));
1615
1954
  return;
1616
1955
  }
1617
- const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
1618
- const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
1619
- mkdirSync3(dirname3(abs), { recursive: true });
1620
- 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);
1621
1960
  const result = scan(tempRoot, { network: "off" });
1622
1961
  rmSync(tempRoot, { recursive: true, force: true });
1623
1962
  const block = result.findings.find((f) => f.finding.severity === "block");
@@ -1631,10 +1970,10 @@ function guardCheck() {
1631
1970
  }
1632
1971
  console.log(JSON.stringify(hookAllow()));
1633
1972
  } catch (error) {
1634
- const logPath = join7(process.cwd(), ".seamshield", "guard.log");
1973
+ const logPath = join8(process.cwd(), ".seamshield", "guard.log");
1635
1974
  try {
1636
- mkdirSync3(dirname3(logPath), { recursive: true });
1637
- writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1975
+ mkdirSync4(dirname3(logPath), { recursive: true });
1976
+ writeFileSync4(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1638
1977
  `, { flag: "a" });
1639
1978
  } catch {
1640
1979
  }
@@ -1643,16 +1982,23 @@ function guardCheck() {
1643
1982
  }
1644
1983
  var program = new Command();
1645
1984
  program.name("seamshield").description("Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship.").version(pkg.version);
1646
- 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) => {
1647
1986
  return runScan(path, opts);
1648
1987
  });
1649
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) => {
1650
1989
  const result = await readScanForCommand(path, !opts.online);
1651
1990
  if (!result) return;
1991
+ maybeWriteInvestigation(result, true);
1652
1992
  const verdict = buildShipVerdict(result);
1653
1993
  console.log(renderShipTable(verdict));
1654
1994
  process.exitCode = verdict.exitCode;
1655
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
+ });
1656
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) => {
1657
2003
  if (!assertChoice(opts.format, ACCESS_FORMATS, "format")) return;
1658
2004
  const result = await readScanForCommand(path, !opts.online);
@@ -1669,9 +2015,9 @@ program.command("fix-plan").description("Write agent-ready fix prompts for dange
1669
2015
  return;
1670
2016
  }
1671
2017
  const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
1672
- const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
1673
- mkdirSync3(dirname3(out), { recursive: true });
1674
- 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)}
1675
2021
  `);
1676
2022
  const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
1677
2023
  console.log(out);
@@ -1688,9 +2034,19 @@ program.command("learn").description("Update local controls from vulnerability i
1688
2034
  program.command("triage").description("Persist current false-positive decisions into .seamshield/config.yaml").argument("[path]", "directory to scan", ".").option("--rule <rule-id>", "only suppress current findings from one rule").option("--reason <text>", "suppression reason", "triaged false positive").option("--include-block", "also suppress block findings", false).option("--online", "include network-backed dependency intelligence").action(
1689
2035
  (path, opts) => writeTriageSuppressions(path, opts)
1690
2036
  );
1691
- program.command("agent-context").description("Write SeamShield agent instructions into CLAUDE.md or Cursor rules").argument("[path]", "project directory", ".").option("--claude", "write CLAUDE.md", false).option("--cursor", "write .cursor/rules/seamshield.mdc", false).action((path, opts) => {
2037
+ program.command("agent-context").description("Write SeamShield agent instructions into AGENTS.md, CLAUDE.md, or Cursor rules").argument("[path]", "project directory", ".").option("--claude", "write CLAUDE.md", false).option("--cursor", "write .cursor/rules/seamshield.mdc", false).option("--codex", "write AGENTS.md", false).action((path, opts) => {
2038
+ const selected = [
2039
+ opts.claude ? "claude" : null,
2040
+ opts.cursor ? "cursor" : null,
2041
+ opts.codex ? "codex" : null
2042
+ ].filter(Boolean);
2043
+ if (selected.length > 1) {
2044
+ console.error(`seamshield: choose one agent context (${CONTEXT_AGENTS.join(", ")})`);
2045
+ process.exitCode = 2;
2046
+ return;
2047
+ }
1692
2048
  const target = resolve2(path);
1693
- const kind = opts.cursor ? "cursor" : "claude";
2049
+ const kind = selected[0] ?? "codex";
1694
2050
  console.log(writeAgentContext(target, kind));
1695
2051
  });
1696
2052
  var guard = program.command("guard").description("Claude Code guard utilities");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seamshield",
3
- "version": "0.2.0",
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",
@@ -57,7 +57,7 @@
57
57
  "@seamshield/rules": "0.1.0"
58
58
  },
59
59
  "scripts": {
60
- "build": "tsup src/index.ts --format esm --clean && tsc -p tsconfig.build.json && rm -rf rules schemas && cp -R ../rules/rules ../rules/schemas .",
60
+ "build": "tsup src/index.ts --format esm --clean && tsc -p tsconfig.build.json && rm -rf rules schemas && ditto ../rules/rules rules && ditto ../rules/schemas schemas",
61
61
  "test": "vitest run"
62
62
  }
63
63
  }
@@ -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.