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 +48 -1
- package/dist/index.js +383 -40
- package/package.json +1 -1
- package/rules/ss-convex-mutation-no-auth.yaml +1 -1
- package/rules/ss-convex-public-function-no-auth.yaml +20 -0
- package/rules/ss-secrets-private-key-file.yaml +4 -1
- package/rules/ss-vercel-config-access-risk.yaml +19 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
144
|
-
import { dirname as dirname2, join as
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
1065
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1496
|
-
|
|
1497
|
-
|
|
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(
|
|
1836
|
+
return writeSection(join8(target, kind === "codex" ? "AGENTS.md" : "CLAUDE.md"), "# SEAMSHIELD", body);
|
|
1501
1837
|
}
|
|
1502
1838
|
function readTriageConfig(target) {
|
|
1503
|
-
const path =
|
|
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 =
|
|
1510
|
-
|
|
1511
|
-
|
|
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 =
|
|
1548
|
-
|
|
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
|
-
|
|
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(
|
|
1621
|
-
const abs =
|
|
1622
|
-
|
|
1623
|
-
|
|
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 =
|
|
1973
|
+
const logPath = join8(process.cwd(), ".seamshield", "guard.log");
|
|
1638
1974
|
try {
|
|
1639
|
-
|
|
1640
|
-
|
|
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 =
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
@@ -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.
|