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 +57 -5
- package/dist/index.js +406 -50
- package/package.json +2 -2
- 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,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
|
|
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
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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 {
|
|
@@ -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
|
|
1482
|
-
|
|
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
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1507
|
-
|
|
1508
|
-
|
|
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 =
|
|
1545
|
-
|
|
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
|
-
|
|
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(
|
|
1618
|
-
const abs =
|
|
1619
|
-
|
|
1620
|
-
|
|
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 =
|
|
1973
|
+
const logPath = join8(process.cwd(), ".seamshield", "guard.log");
|
|
1635
1974
|
try {
|
|
1636
|
-
|
|
1637
|
-
|
|
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 =
|
|
1673
|
-
|
|
1674
|
-
|
|
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 =
|
|
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.
|
|
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 &&
|
|
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.
|