seamshield 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -36
- package/dist/index.js +652 -47
- package/package.json +13 -12
- package/rules/ss-auth-api-route-no-auth.yaml +3 -1
- package/rules/ss-client-next-public-secret.yaml +3 -0
- package/rules/ss-convex-mutation-no-auth.yaml +1 -1
- package/rules/ss-deploy-public-env-secret.yaml +29 -0
- package/rules/ss-deps-postinstall-script.yaml +22 -0
- package/rules/ss-server-route-no-auth.yaml +24 -0
package/README.md
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
# SeamShield
|
|
2
2
|
|
|
3
|
-
Local security scanner for AI-built JavaScript and TypeScript apps.
|
|
3
|
+
Local access-lane security scanner for AI-built JavaScript and TypeScript apps.
|
|
4
4
|
|
|
5
|
-
SeamShield
|
|
5
|
+
SeamShield maps who or what can reach sensitive assets before you ship:
|
|
6
|
+
`Actor -> Lane -> Asset -> Permission -> Condition -> Risk`.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
8
9
|
|
|
9
10
|
```bash
|
|
10
|
-
npx seamshield
|
|
11
|
+
npx seamshield ship .
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
Or install globally:
|
|
14
15
|
|
|
15
16
|
```bash
|
|
16
17
|
npm install -g seamshield
|
|
17
|
-
seamshield
|
|
18
|
+
seamshield ship .
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
Requires Node.js 20 or newer.
|
|
@@ -22,14 +23,38 @@ Requires Node.js 20 or newer.
|
|
|
22
23
|
## Commands
|
|
23
24
|
|
|
24
25
|
```bash
|
|
25
|
-
seamshield
|
|
26
|
-
seamshield
|
|
26
|
+
seamshield ship .
|
|
27
|
+
seamshield access . --format table
|
|
28
|
+
seamshield access . --format json
|
|
29
|
+
seamshield scan . --offline
|
|
30
|
+
seamshield fix-plan . --agent codex
|
|
31
|
+
seamshield triage . --rule ss/auth/client-only-guard
|
|
27
32
|
seamshield agent-context . --claude
|
|
28
33
|
seamshield agent-context . --cursor
|
|
29
34
|
seamshield guard install .
|
|
30
|
-
seamshield
|
|
35
|
+
seamshield learn
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Ship Verdict
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
seamshield ship .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Runs locally and prints `SAFE TO SHIP` only when there are no block or high
|
|
45
|
+
access-lane risks. Use this before deploys.
|
|
46
|
+
|
|
47
|
+
## Access Map
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
seamshield access . --format json
|
|
31
51
|
```
|
|
32
52
|
|
|
53
|
+
Outputs normalized access lanes while preserving provider-specific evidence.
|
|
54
|
+
Supported surfaces include Next.js/API routes, Supabase, Firebase/Firestore,
|
|
55
|
+
Convex, Vercel/Coolify/self-hosted deploy config, generic Node servers, AI
|
|
56
|
+
agent config, and package supply-chain risks.
|
|
57
|
+
|
|
33
58
|
## Scan
|
|
34
59
|
|
|
35
60
|
```bash
|
|
@@ -42,40 +67,49 @@ seamshield scan . --offline
|
|
|
42
67
|
|
|
43
68
|
Exit codes:
|
|
44
69
|
|
|
45
|
-
- `0` - no findings at or above the selected
|
|
46
|
-
- `1` - findings at or above the threshold.
|
|
70
|
+
- `0` - no findings at or above the selected threshold.
|
|
71
|
+
- `1` - findings at or above the selected threshold.
|
|
47
72
|
- `2` - CLI usage or scanner failure.
|
|
48
73
|
|
|
49
74
|
`--offline` disables npm registry and OSV checks. Static rules still run.
|
|
50
75
|
|
|
51
|
-
##
|
|
76
|
+
## Triage
|
|
52
77
|
|
|
53
78
|
```bash
|
|
54
|
-
seamshield
|
|
79
|
+
seamshield triage . --rule ss/auth/client-only-guard
|
|
55
80
|
```
|
|
56
81
|
|
|
57
|
-
|
|
82
|
+
Persists current false-positive decisions into `.seamshield/config.yaml` as
|
|
83
|
+
exact rule/file/line suppressions. Block findings are not triaged unless
|
|
84
|
+
`--include-block` is provided.
|
|
58
85
|
|
|
59
|
-
##
|
|
86
|
+
## Fix Plans
|
|
60
87
|
|
|
61
88
|
```bash
|
|
62
|
-
seamshield
|
|
63
|
-
seamshield
|
|
89
|
+
seamshield fix-plan . --agent claude
|
|
90
|
+
seamshield fix-plan . --agent cursor
|
|
91
|
+
seamshield fix-plan . --agent codex
|
|
92
|
+
seamshield fix-plan . --agent generic
|
|
64
93
|
```
|
|
65
94
|
|
|
66
|
-
|
|
95
|
+
Writes `.seamshield/fix-plan.json` and a Markdown plan under
|
|
96
|
+
`.seamshield/fix-plans/` with redacted findings and provider-aware prompts.
|
|
67
97
|
|
|
68
|
-
##
|
|
98
|
+
## Agent Guard
|
|
69
99
|
|
|
70
100
|
```bash
|
|
101
|
+
seamshield agent-context . --claude
|
|
102
|
+
seamshield agent-context . --cursor
|
|
71
103
|
seamshield guard install .
|
|
72
104
|
```
|
|
73
105
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
77
110
|
|
|
78
|
-
Guard behavior is fail-open: if the hook errors, it allows the tool call and
|
|
111
|
+
Guard behavior is fail-open: if the hook errors, it allows the tool call and
|
|
112
|
+
appends diagnostics to `.seamshield/guard.log`.
|
|
79
113
|
|
|
80
114
|
## Configuration
|
|
81
115
|
|
|
@@ -84,6 +118,11 @@ Create `.seamshield/config.yaml`:
|
|
|
84
118
|
```yaml
|
|
85
119
|
ignore:
|
|
86
120
|
- vendored/**
|
|
121
|
+
suppress:
|
|
122
|
+
- rule: ss/auth/client-only-guard
|
|
123
|
+
file: app/dashboard/page.tsx
|
|
124
|
+
line: 42
|
|
125
|
+
reason: server-side route enforces auth
|
|
87
126
|
rules:
|
|
88
127
|
disable:
|
|
89
128
|
- ss/auth/client-only-guard
|
|
@@ -100,19 +139,9 @@ const fixtureKey = "sk_live_test_fixture_only";
|
|
|
100
139
|
|
|
101
140
|
SeamShield runs locally. Static scanning does not transmit source code.
|
|
102
141
|
|
|
103
|
-
Network dependency checks send package names and versions to the npm registry
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
## Public Rule Coverage
|
|
108
|
-
|
|
109
|
-
- Secrets and client exposure
|
|
110
|
-
- Next.js auth footguns
|
|
111
|
-
- Supabase, Convex, and Firebase platform mistakes
|
|
112
|
-
- Dependency lockfile, pinning, hallucinated-package, and OSV vulnerability checks
|
|
113
|
-
- Agent config secrets and overbroad permissions
|
|
114
|
-
|
|
115
|
-
## Links
|
|
142
|
+
Network dependency checks send package names and versions to the npm registry
|
|
143
|
+
and OSV. Use `--offline` or avoid `--online` on `ship` and `access` to keep the
|
|
144
|
+
run fully local.
|
|
116
145
|
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
Secret evidence is redacted before findings, JSON, SARIF, and fix plans are
|
|
147
|
+
emitted.
|
package/dist/index.js
CHANGED
|
@@ -100,13 +100,16 @@ 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, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync } from "fs";
|
|
103
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
104
104
|
import { tmpdir } from "os";
|
|
105
105
|
import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
|
|
106
106
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
107
107
|
import { Command } from "commander";
|
|
108
|
+
import { parse as parse3, stringify } from "yaml";
|
|
108
109
|
|
|
109
110
|
// ../core/dist/index.js
|
|
111
|
+
var import_picocolors = __toESM(require_picocolors(), 1);
|
|
112
|
+
var import_picocolors2 = __toESM(require_picocolors(), 1);
|
|
110
113
|
import { readFileSync } from "fs";
|
|
111
114
|
import { join as join2 } from "path";
|
|
112
115
|
import { parse } from "yaml";
|
|
@@ -115,7 +118,8 @@ import { randomUUID } from "crypto";
|
|
|
115
118
|
import { basename, extname } from "path";
|
|
116
119
|
import { spawnSync } from "child_process";
|
|
117
120
|
import { basename as basename2 } from "path";
|
|
118
|
-
|
|
121
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
122
|
+
import { join as join22 } from "path";
|
|
119
123
|
import { createHash } from "crypto";
|
|
120
124
|
import { readFileSync as readFileSync2, readdirSync } from "fs";
|
|
121
125
|
import { join as join3 } from "path";
|
|
@@ -145,9 +149,17 @@ import { join as join6, relative as relative2 } from "path";
|
|
|
145
149
|
import { z as z3 } from "zod";
|
|
146
150
|
var ConfigSchema = z.object({
|
|
147
151
|
ignore: z.array(z.string()).optional(),
|
|
152
|
+
suppress: z.array(
|
|
153
|
+
z.object({
|
|
154
|
+
rule: z.string().min(1),
|
|
155
|
+
file: z.string().min(1),
|
|
156
|
+
line: z.number().int().positive().optional(),
|
|
157
|
+
reason: z.string().optional()
|
|
158
|
+
})
|
|
159
|
+
).optional(),
|
|
148
160
|
rules: z.object({ disable: z.array(z.string()).optional() }).optional()
|
|
149
161
|
});
|
|
150
|
-
var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set() };
|
|
162
|
+
var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set(), suppressions: [] };
|
|
151
163
|
function loadConfig(root) {
|
|
152
164
|
let text;
|
|
153
165
|
try {
|
|
@@ -160,15 +172,450 @@ function loadConfig(root) {
|
|
|
160
172
|
ignorePrefixes: (parsed.ignore ?? []).map(
|
|
161
173
|
(p) => p.replace(/\/\*{1,2}$/, "").replace(/\/$/, "")
|
|
162
174
|
),
|
|
163
|
-
disabledRules: new Set(parsed.rules?.disable ?? [])
|
|
175
|
+
disabledRules: new Set(parsed.rules?.disable ?? []),
|
|
176
|
+
suppressions: parsed.suppress ?? []
|
|
164
177
|
};
|
|
165
178
|
}
|
|
166
179
|
function isIgnored(rel, prefixes) {
|
|
167
180
|
return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
|
|
168
181
|
}
|
|
182
|
+
function isSuppressedByConfig(ruleId, rel, line, suppressions) {
|
|
183
|
+
return suppressions.some(
|
|
184
|
+
(s) => s.rule === ruleId && s.file === rel && (s.line === void 0 || s.line === line)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
var SEVERITY_RANK = {
|
|
188
|
+
block: 0,
|
|
189
|
+
high: 1,
|
|
190
|
+
warn: 2,
|
|
191
|
+
info: 3
|
|
192
|
+
};
|
|
193
|
+
var ROUTE_ASSET_RE = /^(?:app|pages)\/(.+?)(?:\/route)?\.[tj]sx?$/;
|
|
194
|
+
function envAsset(finding) {
|
|
195
|
+
const evidence = finding.spans[0]?.evidence ?? "";
|
|
196
|
+
const upper = evidence.match(/[A-Z][A-Z0-9_]{3,}/)?.[0];
|
|
197
|
+
if (upper) return `env:${upper}`;
|
|
198
|
+
if (finding.finding.rule_id.includes("supabase")) return "env:SUPABASE_SERVICE_ROLE_KEY";
|
|
199
|
+
if (finding.finding.rule_id.includes("next-public")) return "env:NEXT_PUBLIC_*SECRET*";
|
|
200
|
+
return "env:server_secret";
|
|
201
|
+
}
|
|
202
|
+
function routeAsset(finding) {
|
|
203
|
+
const normalized = finding.finding.file.split("\\").join("/");
|
|
204
|
+
const route = ROUTE_ASSET_RE.exec(normalized)?.[1];
|
|
205
|
+
if (route) return `/${route.replace(/\/page$|\/route$/, "")}`;
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
function packageAsset(finding) {
|
|
209
|
+
const evidence = finding.spans[0]?.evidence ?? "";
|
|
210
|
+
const quoted = evidence.match(/"([^"]+)"/)?.[1];
|
|
211
|
+
return quoted && quoted.trim().length > 1 ? `package:${quoted}` : "package.json";
|
|
212
|
+
}
|
|
213
|
+
function databaseAsset(finding) {
|
|
214
|
+
if (finding.finding.file.includes("firestore")) return "firestore.rules";
|
|
215
|
+
if (finding.finding.file.includes("convex/")) return `convex:${routeAsset(finding)}`;
|
|
216
|
+
if (finding.finding.file.includes("supabase/")) return "supabase:database";
|
|
217
|
+
return finding.finding.file;
|
|
218
|
+
}
|
|
219
|
+
var RULE_TEMPLATES = {
|
|
220
|
+
"ss/client/supabase-service-role-in-client": {
|
|
221
|
+
actor: "frontend_bundle",
|
|
222
|
+
lane: "env_variable",
|
|
223
|
+
asset: envAsset,
|
|
224
|
+
permission: "read",
|
|
225
|
+
condition: "client_exposed",
|
|
226
|
+
risk: "client_to_server_secret",
|
|
227
|
+
provider: "supabase"
|
|
228
|
+
},
|
|
229
|
+
"ss/secrets/supabase-service-role-key": {
|
|
230
|
+
actor: "server_runtime",
|
|
231
|
+
lane: "env_variable",
|
|
232
|
+
asset: envAsset,
|
|
233
|
+
permission: "read",
|
|
234
|
+
condition: "hardcoded_or_committed",
|
|
235
|
+
risk: "server_secret_exposure",
|
|
236
|
+
provider: "supabase"
|
|
237
|
+
},
|
|
238
|
+
"ss/supabase/rls-disabled": {
|
|
239
|
+
actor: "public_user",
|
|
240
|
+
lane: "database",
|
|
241
|
+
asset: databaseAsset,
|
|
242
|
+
permission: "read",
|
|
243
|
+
condition: "rls_disabled",
|
|
244
|
+
risk: "public_database_access",
|
|
245
|
+
provider: "supabase"
|
|
246
|
+
},
|
|
247
|
+
"ss/supabase/permissive-policy": {
|
|
248
|
+
actor: "public_user",
|
|
249
|
+
lane: "database",
|
|
250
|
+
asset: databaseAsset,
|
|
251
|
+
permission: "write",
|
|
252
|
+
condition: "permissive_policy",
|
|
253
|
+
risk: "public_database_access",
|
|
254
|
+
provider: "supabase"
|
|
255
|
+
},
|
|
256
|
+
"ss/firebase/open-rules": {
|
|
257
|
+
actor: "public_user",
|
|
258
|
+
lane: "database",
|
|
259
|
+
asset: databaseAsset,
|
|
260
|
+
permission: "write",
|
|
261
|
+
condition: "open_rules",
|
|
262
|
+
risk: "public_database_access",
|
|
263
|
+
provider: "firebase"
|
|
264
|
+
},
|
|
265
|
+
"ss/client/firebase-admin-in-client": {
|
|
266
|
+
actor: "frontend_bundle",
|
|
267
|
+
lane: "env_variable",
|
|
268
|
+
asset: () => "firebase:admin_sdk",
|
|
269
|
+
permission: "admin",
|
|
270
|
+
condition: "client_exposed",
|
|
271
|
+
risk: "client_to_admin",
|
|
272
|
+
provider: "firebase"
|
|
273
|
+
},
|
|
274
|
+
"ss/convex/mutation-no-auth": {
|
|
275
|
+
actor: "public_user",
|
|
276
|
+
lane: "server_action",
|
|
277
|
+
asset: databaseAsset,
|
|
278
|
+
permission: "execute",
|
|
279
|
+
condition: "no_auth_check",
|
|
280
|
+
risk: "anonymous_write",
|
|
281
|
+
provider: "convex"
|
|
282
|
+
},
|
|
283
|
+
"ss/convex/internal-not-internal": {
|
|
284
|
+
actor: "public_user",
|
|
285
|
+
lane: "server_action",
|
|
286
|
+
asset: databaseAsset,
|
|
287
|
+
permission: "execute",
|
|
288
|
+
condition: "internal_function_public",
|
|
289
|
+
risk: "untrusted_admin_surface",
|
|
290
|
+
provider: "convex"
|
|
291
|
+
},
|
|
292
|
+
"ss/auth/api-route-no-auth": {
|
|
293
|
+
actor: "public_user",
|
|
294
|
+
lane: "http_route",
|
|
295
|
+
asset: routeAsset,
|
|
296
|
+
permission: "execute",
|
|
297
|
+
condition: "no_auth_or_signature",
|
|
298
|
+
risk: "anonymous_execute",
|
|
299
|
+
provider: "web"
|
|
300
|
+
},
|
|
301
|
+
"ss/auth/admin-route-unprotected": {
|
|
302
|
+
actor: "public_user",
|
|
303
|
+
lane: "http_route",
|
|
304
|
+
asset: routeAsset,
|
|
305
|
+
permission: "admin",
|
|
306
|
+
condition: "no_server_auth",
|
|
307
|
+
risk: "untrusted_admin_surface",
|
|
308
|
+
provider: "web"
|
|
309
|
+
},
|
|
310
|
+
"ss/auth/client-only-guard": {
|
|
311
|
+
actor: "authenticated_user",
|
|
312
|
+
lane: "http_route",
|
|
313
|
+
asset: routeAsset,
|
|
314
|
+
permission: "admin",
|
|
315
|
+
condition: "client_only_role_check",
|
|
316
|
+
risk: "client_to_admin",
|
|
317
|
+
provider: "web"
|
|
318
|
+
},
|
|
319
|
+
"ss/auth/cors-wildcard-with-credentials": {
|
|
320
|
+
actor: "public_user",
|
|
321
|
+
lane: "http_route",
|
|
322
|
+
asset: routeAsset,
|
|
323
|
+
permission: "execute",
|
|
324
|
+
condition: "wildcard_origin_with_credentials",
|
|
325
|
+
risk: "cors_credential_theft",
|
|
326
|
+
provider: "web"
|
|
327
|
+
},
|
|
328
|
+
"ss/server/route-no-auth": {
|
|
329
|
+
actor: "public_user",
|
|
330
|
+
lane: "self_hosted_server",
|
|
331
|
+
asset: routeAsset,
|
|
332
|
+
permission: "execute",
|
|
333
|
+
condition: "no_auth_middleware",
|
|
334
|
+
risk: "anonymous_execute",
|
|
335
|
+
provider: "self-hosted"
|
|
336
|
+
},
|
|
337
|
+
"ss/deploy/public-env-secret": {
|
|
338
|
+
actor: "deploy_platform",
|
|
339
|
+
lane: "deploy_config",
|
|
340
|
+
asset: envAsset,
|
|
341
|
+
permission: "read",
|
|
342
|
+
condition: "public_or_build_exposed",
|
|
343
|
+
risk: "deploy_secret_exposure",
|
|
344
|
+
provider: "deploy"
|
|
345
|
+
},
|
|
346
|
+
"ss/client/server-secret-env-in-client": {
|
|
347
|
+
actor: "frontend_bundle",
|
|
348
|
+
lane: "env_variable",
|
|
349
|
+
asset: envAsset,
|
|
350
|
+
permission: "read",
|
|
351
|
+
condition: "client_exposed",
|
|
352
|
+
risk: "client_to_server_secret",
|
|
353
|
+
provider: "web"
|
|
354
|
+
},
|
|
355
|
+
"ss/client/next-public-secret": {
|
|
356
|
+
actor: "frontend_bundle",
|
|
357
|
+
lane: "env_variable",
|
|
358
|
+
asset: envAsset,
|
|
359
|
+
permission: "read",
|
|
360
|
+
condition: "next_public_exposed",
|
|
361
|
+
risk: "client_to_server_secret",
|
|
362
|
+
provider: "nextjs"
|
|
363
|
+
},
|
|
364
|
+
"ss/secrets/env-file-committed": {
|
|
365
|
+
actor: "ai_agent",
|
|
366
|
+
lane: "filesystem",
|
|
367
|
+
asset: () => ".env",
|
|
368
|
+
permission: "modify",
|
|
369
|
+
condition: "committed_or_staged",
|
|
370
|
+
risk: "agent_to_secret",
|
|
371
|
+
provider: "agent"
|
|
372
|
+
},
|
|
373
|
+
"ss/agent/secrets-in-agent-files": {
|
|
374
|
+
actor: "ai_agent",
|
|
375
|
+
lane: "agent_tooling",
|
|
376
|
+
asset: routeAsset,
|
|
377
|
+
permission: "read",
|
|
378
|
+
condition: "agent_instruction_exposes_secret",
|
|
379
|
+
risk: "agent_to_secret",
|
|
380
|
+
provider: "agent"
|
|
381
|
+
},
|
|
382
|
+
"ss/agent/mcp-inline-credentials": {
|
|
383
|
+
actor: "ai_agent",
|
|
384
|
+
lane: "agent_tooling",
|
|
385
|
+
asset: routeAsset,
|
|
386
|
+
permission: "read",
|
|
387
|
+
condition: "inline_mcp_credentials",
|
|
388
|
+
risk: "agent_to_secret",
|
|
389
|
+
provider: "agent"
|
|
390
|
+
},
|
|
391
|
+
"ss/agent/overbroad-permissions": {
|
|
392
|
+
actor: "ai_agent",
|
|
393
|
+
lane: "agent_tooling",
|
|
394
|
+
asset: routeAsset,
|
|
395
|
+
permission: "execute",
|
|
396
|
+
condition: "overbroad_tool_permission",
|
|
397
|
+
risk: "agent_to_secret",
|
|
398
|
+
provider: "agent"
|
|
399
|
+
},
|
|
400
|
+
"ss/deps/postinstall-script": {
|
|
401
|
+
actor: "dependency",
|
|
402
|
+
lane: "package_install",
|
|
403
|
+
asset: packageAsset,
|
|
404
|
+
permission: "execute",
|
|
405
|
+
condition: "install_lifecycle_script",
|
|
406
|
+
risk: "dependency_to_shell",
|
|
407
|
+
provider: "npm"
|
|
408
|
+
},
|
|
409
|
+
"ss/deps/unpinned-spec": {
|
|
410
|
+
actor: "dependency",
|
|
411
|
+
lane: "package_install",
|
|
412
|
+
asset: packageAsset,
|
|
413
|
+
permission: "install",
|
|
414
|
+
condition: "latest_or_star",
|
|
415
|
+
risk: "unreproducible_dependency",
|
|
416
|
+
provider: "npm"
|
|
417
|
+
},
|
|
418
|
+
"ss/deps/known-vuln": {
|
|
419
|
+
actor: "dependency",
|
|
420
|
+
lane: "package_install",
|
|
421
|
+
asset: packageAsset,
|
|
422
|
+
permission: "execute",
|
|
423
|
+
condition: "known_vulnerability",
|
|
424
|
+
risk: "known_vulnerable_dependency",
|
|
425
|
+
provider: "npm"
|
|
426
|
+
},
|
|
427
|
+
"ss/deps/hallucinated-package": {
|
|
428
|
+
actor: "dependency",
|
|
429
|
+
lane: "package_install",
|
|
430
|
+
asset: packageAsset,
|
|
431
|
+
permission: "install",
|
|
432
|
+
condition: "package_not_found",
|
|
433
|
+
risk: "unknown_package",
|
|
434
|
+
provider: "npm"
|
|
435
|
+
},
|
|
436
|
+
"ss/deps/no-lockfile": {
|
|
437
|
+
actor: "dependency",
|
|
438
|
+
lane: "package_install",
|
|
439
|
+
asset: () => "lockfile",
|
|
440
|
+
permission: "install",
|
|
441
|
+
condition: "missing_lockfile",
|
|
442
|
+
risk: "unreproducible_dependency",
|
|
443
|
+
provider: "npm"
|
|
444
|
+
},
|
|
445
|
+
"ss/secrets/hardcoded-provider-key": {
|
|
446
|
+
actor: "server_runtime",
|
|
447
|
+
lane: "env_variable",
|
|
448
|
+
asset: envAsset,
|
|
449
|
+
permission: "read",
|
|
450
|
+
condition: "hardcoded",
|
|
451
|
+
risk: "server_secret_exposure",
|
|
452
|
+
provider: "provider"
|
|
453
|
+
},
|
|
454
|
+
"ss/secrets/generic-credential-assignment": {
|
|
455
|
+
actor: "server_runtime",
|
|
456
|
+
lane: "env_variable",
|
|
457
|
+
asset: envAsset,
|
|
458
|
+
permission: "read",
|
|
459
|
+
condition: "credential_assignment",
|
|
460
|
+
risk: "server_secret_exposure",
|
|
461
|
+
provider: "provider"
|
|
462
|
+
},
|
|
463
|
+
"ss/secrets/private-key-file": {
|
|
464
|
+
actor: "server_runtime",
|
|
465
|
+
lane: "filesystem",
|
|
466
|
+
asset: routeAsset,
|
|
467
|
+
permission: "read",
|
|
468
|
+
condition: "private_key_file",
|
|
469
|
+
risk: "server_secret_exposure",
|
|
470
|
+
provider: "provider"
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
function fallbackTemplate(finding) {
|
|
474
|
+
if (finding.finding.file.includes("coolify") || finding.finding.file.includes("docker")) {
|
|
475
|
+
return {
|
|
476
|
+
actor: "deploy_platform",
|
|
477
|
+
lane: "deploy_config",
|
|
478
|
+
asset: routeAsset,
|
|
479
|
+
permission: "execute",
|
|
480
|
+
condition: "deploy_config_change",
|
|
481
|
+
risk: "deploy_secret_exposure",
|
|
482
|
+
provider: "deploy"
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
actor: "server_runtime",
|
|
487
|
+
lane: "filesystem",
|
|
488
|
+
asset: routeAsset,
|
|
489
|
+
permission: "read",
|
|
490
|
+
condition: "scanner_finding",
|
|
491
|
+
risk: "server_secret_exposure",
|
|
492
|
+
provider: "generic"
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function toLane(finding) {
|
|
496
|
+
const template = RULE_TEMPLATES[finding.finding.rule_id] ?? fallbackTemplate(finding);
|
|
497
|
+
return {
|
|
498
|
+
actor: template.actor,
|
|
499
|
+
lane: template.lane,
|
|
500
|
+
asset: template.asset(finding),
|
|
501
|
+
permission: template.permission,
|
|
502
|
+
condition: template.condition,
|
|
503
|
+
risk: template.risk,
|
|
504
|
+
severity: finding.finding.severity,
|
|
505
|
+
provider: template.provider,
|
|
506
|
+
source: {
|
|
507
|
+
rule_id: finding.finding.rule_id,
|
|
508
|
+
title: finding.finding.title,
|
|
509
|
+
file: finding.finding.file,
|
|
510
|
+
line: finding.finding.line,
|
|
511
|
+
evidence: finding.spans[0]?.evidence
|
|
512
|
+
},
|
|
513
|
+
fix: finding.finding.fix
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function countBy(items, pick) {
|
|
517
|
+
const counts = {};
|
|
518
|
+
for (const item of items) counts[pick(item)] = (counts[pick(item)] ?? 0) + 1;
|
|
519
|
+
return counts;
|
|
520
|
+
}
|
|
521
|
+
function buildAccessMap(result) {
|
|
522
|
+
const lanes = result.findings.map(toLane).sort(
|
|
523
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.actor.localeCompare(b.actor) || a.asset.localeCompare(b.asset) || a.source.rule_id.localeCompare(b.source.rule_id)
|
|
524
|
+
);
|
|
525
|
+
return {
|
|
526
|
+
schema: "seamshield.access-map/v1",
|
|
527
|
+
target: result.target,
|
|
528
|
+
policy_bundle_digest: result.policyBundleDigest,
|
|
529
|
+
summary: {
|
|
530
|
+
lanes_total: lanes.length,
|
|
531
|
+
by_actor: countBy(lanes, (lane) => lane.actor),
|
|
532
|
+
by_risk: countBy(lanes, (lane) => lane.risk),
|
|
533
|
+
by_severity: countBy(lanes, (lane) => lane.severity)
|
|
534
|
+
},
|
|
535
|
+
lanes
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function buildShipVerdict(result) {
|
|
539
|
+
const access = buildAccessMap(result);
|
|
540
|
+
const critical = access.lanes.filter((lane) => lane.severity === "block" || lane.severity === "high");
|
|
541
|
+
const warnings = access.lanes.filter((lane) => lane.severity === "warn" || lane.severity === "info");
|
|
542
|
+
return {
|
|
543
|
+
schema: "seamshield.ship/v1",
|
|
544
|
+
target: result.target,
|
|
545
|
+
verdict: critical.length > 0 ? "UNSAFE TO SHIP" : "SAFE TO SHIP",
|
|
546
|
+
exitCode: critical.length > 0 ? 1 : 0,
|
|
547
|
+
access,
|
|
548
|
+
critical,
|
|
549
|
+
warnings
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function renderAccessJson(access) {
|
|
553
|
+
return JSON.stringify(access, null, 2);
|
|
554
|
+
}
|
|
555
|
+
var SEVERITY_COLOR = {
|
|
556
|
+
block: (s) => import_picocolors.default.red(s),
|
|
557
|
+
high: (s) => import_picocolors.default.magenta(s),
|
|
558
|
+
warn: (s) => import_picocolors.default.yellow(s),
|
|
559
|
+
info: (s) => import_picocolors.default.dim(s)
|
|
560
|
+
};
|
|
561
|
+
function renderLane(lane) {
|
|
562
|
+
return [
|
|
563
|
+
` -> ${lane.lane} -> ${lane.asset}`,
|
|
564
|
+
` permission: ${lane.permission}`,
|
|
565
|
+
` condition: ${lane.condition}`,
|
|
566
|
+
` risk: ${SEVERITY_COLOR[lane.severity](lane.risk)} (${lane.source.rule_id})`
|
|
567
|
+
];
|
|
568
|
+
}
|
|
569
|
+
function renderAccessTable(access) {
|
|
570
|
+
const lines = [import_picocolors.default.bold("Access Map"), ""];
|
|
571
|
+
if (access.lanes.length === 0) {
|
|
572
|
+
lines.push(import_picocolors.default.green("No dangerous access lanes found."));
|
|
573
|
+
return lines.join("\n");
|
|
574
|
+
}
|
|
575
|
+
const actors = [...new Set(access.lanes.map((lane) => lane.actor))];
|
|
576
|
+
for (const actor of actors) {
|
|
577
|
+
lines.push(import_picocolors.default.bold(actor));
|
|
578
|
+
for (const lane of access.lanes.filter((candidate) => candidate.actor === actor)) {
|
|
579
|
+
lines.push(...renderLane(lane));
|
|
580
|
+
}
|
|
581
|
+
lines.push("");
|
|
582
|
+
}
|
|
583
|
+
lines.push(import_picocolors.default.dim(`policy bundle ${access.policy_bundle_digest.slice(0, 12)}`));
|
|
584
|
+
return lines.join("\n");
|
|
585
|
+
}
|
|
586
|
+
function renderShipTable(verdict) {
|
|
587
|
+
const lines = [import_picocolors.default.bold("SeamShield Ship Check"), "", `Verdict: ${verdict.verdict}`, ""];
|
|
588
|
+
if (verdict.critical.length > 0) {
|
|
589
|
+
lines.push(import_picocolors.default.red("Critical"));
|
|
590
|
+
verdict.critical.forEach((lane, i) => {
|
|
591
|
+
lines.push(
|
|
592
|
+
`${i + 1}. ${lane.actor} can ${lane.permission} ${lane.asset} (${lane.risk})`,
|
|
593
|
+
` ${lane.source.file}:${lane.source.line} ${lane.source.rule_id}`
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
lines.push("");
|
|
597
|
+
}
|
|
598
|
+
if (verdict.warnings.length > 0) {
|
|
599
|
+
lines.push(import_picocolors.default.yellow("Warnings"));
|
|
600
|
+
verdict.warnings.forEach((lane, i) => {
|
|
601
|
+
lines.push(
|
|
602
|
+
`${i + 1}. ${lane.actor} can ${lane.permission} ${lane.asset} (${lane.risk})`,
|
|
603
|
+
` ${lane.source.file}:${lane.source.line} ${lane.source.rule_id}`
|
|
604
|
+
);
|
|
605
|
+
});
|
|
606
|
+
lines.push("");
|
|
607
|
+
}
|
|
608
|
+
if (verdict.critical.length === 0 && verdict.warnings.length === 0) {
|
|
609
|
+
lines.push(import_picocolors.default.green("No dangerous access lanes found."), "");
|
|
610
|
+
}
|
|
611
|
+
lines.push("Next:", "npx seamshield access", "npx seamshield fix-plan");
|
|
612
|
+
return lines.join("\n");
|
|
613
|
+
}
|
|
169
614
|
function matchBasenamePattern(name, pattern) {
|
|
170
|
-
if (pattern.
|
|
171
|
-
|
|
615
|
+
if (pattern.includes("*")) {
|
|
616
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
617
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
618
|
+
}
|
|
172
619
|
return name === pattern;
|
|
173
620
|
}
|
|
174
621
|
function fileMatchesRule(file, check) {
|
|
@@ -303,7 +750,38 @@ function promptFor(finding) {
|
|
|
303
750
|
`Fix: ${finding.finding.fix.agent_prompt}`
|
|
304
751
|
].join("\n");
|
|
305
752
|
}
|
|
306
|
-
function
|
|
753
|
+
function rulesForAgent(agent) {
|
|
754
|
+
const base = [
|
|
755
|
+
"Do not print, rename, commit, or expose secret values.",
|
|
756
|
+
"Do not move server credentials into public/client env names.",
|
|
757
|
+
"Do not weaken authentication, authorization, database rules, storage rules, or CORS.",
|
|
758
|
+
"Preserve current user-facing behavior unless the risky access lane requires a safer boundary.",
|
|
759
|
+
"Re-run `npx seamshield ship --offline` after changes."
|
|
760
|
+
];
|
|
761
|
+
if (agent === "codex") return [...base, "Use focused edits and run the repo's typecheck/tests before claiming done."];
|
|
762
|
+
if (agent === "claude") return [...base, "Respect existing CLAUDE.md project instructions and tool hooks."];
|
|
763
|
+
if (agent === "cursor") return [...base, "Respect existing Cursor rules and keep generated code inside the intended files."];
|
|
764
|
+
return base;
|
|
765
|
+
}
|
|
766
|
+
function lanePrompt(lane) {
|
|
767
|
+
return [
|
|
768
|
+
`## Issue`,
|
|
769
|
+
`${lane.actor} can ${lane.permission} ${lane.asset} through ${lane.lane}.`,
|
|
770
|
+
"",
|
|
771
|
+
`Risk: ${lane.risk}`,
|
|
772
|
+
`Condition: ${lane.condition}`,
|
|
773
|
+
`Source: ${lane.source.file}:${lane.source.line} (${lane.source.rule_id})`,
|
|
774
|
+
"",
|
|
775
|
+
`## Goal`,
|
|
776
|
+
lane.fix.summary,
|
|
777
|
+
"",
|
|
778
|
+
`## Steps`,
|
|
779
|
+
lane.fix.agent_prompt
|
|
780
|
+
].join("\n");
|
|
781
|
+
}
|
|
782
|
+
function buildFixPlan(result, options = {}) {
|
|
783
|
+
const agent = options.agent ?? "generic";
|
|
784
|
+
const access = buildAccessMap(result);
|
|
307
785
|
const items = result.findings.map((finding) => ({
|
|
308
786
|
rule_id: finding.finding.rule_id,
|
|
309
787
|
severity: finding.finding.severity,
|
|
@@ -317,19 +795,31 @@ function buildFixPlan(result) {
|
|
|
317
795
|
return {
|
|
318
796
|
schema: "seamshield.fix-plan/v1",
|
|
319
797
|
target: result.target,
|
|
798
|
+
agent,
|
|
320
799
|
policy_bundle_digest: result.policyBundleDigest,
|
|
321
|
-
summary: { findings_total: result.findings.length },
|
|
800
|
+
summary: { findings_total: result.findings.length, access_lanes_total: access.lanes.length },
|
|
322
801
|
items,
|
|
802
|
+
access_lanes: access.lanes,
|
|
323
803
|
agent_markdown: [
|
|
324
804
|
"# SeamShield Fix Plan",
|
|
325
805
|
"",
|
|
326
|
-
|
|
806
|
+
...rulesForAgent(agent).map((rule) => `- ${rule}`),
|
|
327
807
|
"",
|
|
328
|
-
...
|
|
808
|
+
...access.lanes.map(lanePrompt),
|
|
329
809
|
""
|
|
330
810
|
].join("\n")
|
|
331
811
|
};
|
|
332
812
|
}
|
|
813
|
+
function dateStamp(now = /* @__PURE__ */ new Date()) {
|
|
814
|
+
return now.toISOString().slice(0, 10);
|
|
815
|
+
}
|
|
816
|
+
function writeMarkdownFixPlan(result, options = {}) {
|
|
817
|
+
const dir = join22(result.target, ".seamshield", "fix-plans");
|
|
818
|
+
mkdirSync(dir, { recursive: true });
|
|
819
|
+
const path = join22(dir, `${dateStamp(options.now)}-critical-access-risks.md`);
|
|
820
|
+
writeFileSync(path, buildFixPlan(result, { agent: options.agent }).agent_markdown);
|
|
821
|
+
return path;
|
|
822
|
+
}
|
|
333
823
|
var PatternSchema = z2.object({
|
|
334
824
|
name: z2.string().min(1),
|
|
335
825
|
regex: z2.string().min(1)
|
|
@@ -473,42 +963,42 @@ function renderSarif(result) {
|
|
|
473
963
|
2
|
|
474
964
|
);
|
|
475
965
|
}
|
|
476
|
-
var
|
|
477
|
-
block: (s) =>
|
|
478
|
-
high: (s) =>
|
|
479
|
-
warn: (s) =>
|
|
480
|
-
info: (s) =>
|
|
966
|
+
var SEVERITY_COLOR2 = {
|
|
967
|
+
block: (s) => import_picocolors2.default.red(s),
|
|
968
|
+
high: (s) => import_picocolors2.default.magenta(s),
|
|
969
|
+
warn: (s) => import_picocolors2.default.yellow(s),
|
|
970
|
+
info: (s) => import_picocolors2.default.dim(s)
|
|
481
971
|
};
|
|
482
972
|
function renderTable(result) {
|
|
483
973
|
const lines = [];
|
|
484
974
|
lines.push(
|
|
485
|
-
`${
|
|
975
|
+
`${import_picocolors2.default.bold(`SeamShield v${result.engineVersion}`)}${import_picocolors2.default.dim(
|
|
486
976
|
` \u2014 ${result.filesScanned} files, ${result.rulesLoaded} rules`
|
|
487
977
|
)}`
|
|
488
978
|
);
|
|
489
979
|
lines.push("");
|
|
490
980
|
if (result.findings.length === 0) {
|
|
491
|
-
lines.push(
|
|
981
|
+
lines.push(import_picocolors2.default.green("\u2713 No findings."));
|
|
492
982
|
} else {
|
|
493
983
|
for (const f of result.findings) {
|
|
494
|
-
const sev =
|
|
984
|
+
const sev = SEVERITY_COLOR2[f.finding.severity](
|
|
495
985
|
f.finding.severity.toUpperCase().padEnd(5)
|
|
496
986
|
);
|
|
497
|
-
lines.push(`${sev} ${f.finding.rule_id} ${
|
|
987
|
+
lines.push(`${sev} ${f.finding.rule_id} ${import_picocolors2.default.bold(`${f.finding.file}:${f.finding.line}`)}`);
|
|
498
988
|
lines.push(` ${f.finding.title}`);
|
|
499
989
|
const evidence = f.spans[0]?.evidence;
|
|
500
|
-
if (evidence) lines.push(
|
|
501
|
-
lines.push(` ${
|
|
990
|
+
if (evidence) lines.push(import_picocolors2.default.dim(` evidence: ${evidence}`));
|
|
991
|
+
lines.push(` ${import_picocolors2.default.cyan("fix:")} ${f.finding.fix.summary}`);
|
|
502
992
|
lines.push("");
|
|
503
993
|
}
|
|
504
994
|
const counts = /* @__PURE__ */ new Map();
|
|
505
995
|
for (const f of result.findings) {
|
|
506
996
|
counts.set(f.finding.severity, (counts.get(f.finding.severity) ?? 0) + 1);
|
|
507
997
|
}
|
|
508
|
-
const parts = [...counts.entries()].map(([sev, n]) =>
|
|
509
|
-
lines.push(`${
|
|
998
|
+
const parts = [...counts.entries()].map(([sev, n]) => SEVERITY_COLOR2[sev](`${n} ${sev}`));
|
|
999
|
+
lines.push(`${import_picocolors2.default.bold(`${result.findings.length} findings`)} (${parts.join(", ")})`);
|
|
510
1000
|
}
|
|
511
|
-
lines.push(
|
|
1001
|
+
lines.push(import_picocolors2.default.dim(`policy bundle ${result.policyBundleDigest.slice(0, 12)}`));
|
|
512
1002
|
return lines.join("\n");
|
|
513
1003
|
}
|
|
514
1004
|
var DEP_FIELDS = [
|
|
@@ -692,12 +1182,6 @@ function checkNoLockfile(rule, ctx) {
|
|
|
692
1182
|
}
|
|
693
1183
|
return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
|
|
694
1184
|
}
|
|
695
|
-
var SEVERITY_RANK = {
|
|
696
|
-
block: 0,
|
|
697
|
-
high: 1,
|
|
698
|
-
warn: 2,
|
|
699
|
-
info: 3
|
|
700
|
-
};
|
|
701
1185
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
702
1186
|
"node_modules",
|
|
703
1187
|
".git",
|
|
@@ -706,6 +1190,8 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
706
1190
|
"build",
|
|
707
1191
|
"out",
|
|
708
1192
|
"coverage",
|
|
1193
|
+
"target",
|
|
1194
|
+
".gradle",
|
|
709
1195
|
".turbo",
|
|
710
1196
|
".vercel",
|
|
711
1197
|
".cache",
|
|
@@ -786,7 +1272,14 @@ function scan(target, options = {}) {
|
|
|
786
1272
|
throw new Error(`${rule.id}: unknown builtin check "${rule.check.builtin}"`);
|
|
787
1273
|
}
|
|
788
1274
|
}
|
|
789
|
-
findings = findings.filter((f) => !isIgnored(f.finding.file, config.ignorePrefixes))
|
|
1275
|
+
findings = findings.filter((f) => !isIgnored(f.finding.file, config.ignorePrefixes)).filter(
|
|
1276
|
+
(f) => !isSuppressedByConfig(
|
|
1277
|
+
f.finding.rule_id,
|
|
1278
|
+
f.finding.file,
|
|
1279
|
+
f.finding.line,
|
|
1280
|
+
config.suppressions
|
|
1281
|
+
)
|
|
1282
|
+
).map((f) => refineFinding(f, cache, files));
|
|
790
1283
|
findings.sort(
|
|
791
1284
|
(a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
|
|
792
1285
|
);
|
|
@@ -834,7 +1327,12 @@ async function scanAsync(target, options = {}) {
|
|
|
834
1327
|
}
|
|
835
1328
|
}
|
|
836
1329
|
result.findings = [...result.findings, ...dependencyFindings].filter(
|
|
837
|
-
(f) => !isIgnored(f.finding.file, config.ignorePrefixes)
|
|
1330
|
+
(f) => !isIgnored(f.finding.file, config.ignorePrefixes) && !isSuppressedByConfig(
|
|
1331
|
+
f.finding.rule_id,
|
|
1332
|
+
f.finding.file,
|
|
1333
|
+
f.finding.line,
|
|
1334
|
+
config.suppressions
|
|
1335
|
+
)
|
|
838
1336
|
);
|
|
839
1337
|
result.findings.sort(
|
|
840
1338
|
(a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
|
|
@@ -843,6 +1341,22 @@ async function scanAsync(target, options = {}) {
|
|
|
843
1341
|
result.networkSkipped = false;
|
|
844
1342
|
return result;
|
|
845
1343
|
}
|
|
1344
|
+
function refineFinding(finding, cache, files) {
|
|
1345
|
+
if (finding.finding.rule_id !== "ss/auth/client-only-guard") return finding;
|
|
1346
|
+
const file = files.find((candidate) => candidate.rel === finding.finding.file);
|
|
1347
|
+
if (!file) return finding;
|
|
1348
|
+
const content = cache.read(file.abs);
|
|
1349
|
+
if (!content) return finding;
|
|
1350
|
+
const hasServerDataBoundary = /\b(?:fetch|axios|useQuery|useMutation|useAction|api\.|convex|server action|route\.ts|\/api\/)\b/i.test(
|
|
1351
|
+
content
|
|
1352
|
+
);
|
|
1353
|
+
if (hasServerDataBoundary) return finding;
|
|
1354
|
+
return {
|
|
1355
|
+
...finding,
|
|
1356
|
+
finding: { ...finding.finding, severity: "info" },
|
|
1357
|
+
decision: "scan"
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
846
1360
|
function computeExitCode(findings, failOn) {
|
|
847
1361
|
if (failOn === "never") return 0;
|
|
848
1362
|
const threshold = SEVERITY_RANK[failOn];
|
|
@@ -896,13 +1410,21 @@ var pkg = JSON.parse(
|
|
|
896
1410
|
readFileSync6(new URL("../package.json", import.meta.url), "utf8")
|
|
897
1411
|
);
|
|
898
1412
|
var FORMATS = ["table", "json", "sarif"];
|
|
1413
|
+
var ACCESS_FORMATS = ["table", "json"];
|
|
899
1414
|
var FAIL_ON = ["block", "high", "warn", "never"];
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1415
|
+
var FIX_AGENTS = ["claude", "cursor", "codex", "generic"];
|
|
1416
|
+
function assertChoice(value, allowed, label) {
|
|
1417
|
+
if (value && !allowed.includes(value)) {
|
|
1418
|
+
console.error(`seamshield: unknown --${label} "${value}" (expected: ${allowed.join(", ")})`);
|
|
903
1419
|
process.exitCode = 2;
|
|
904
1420
|
return false;
|
|
905
1421
|
}
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
function assertOptions(opts) {
|
|
1425
|
+
if (!assertChoice(opts.format, FORMATS, "format")) {
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
906
1428
|
if (opts.failOn && !FAIL_ON.includes(opts.failOn)) {
|
|
907
1429
|
console.error(`seamshield: unknown --fail-on "${opts.failOn}" (expected: ${FAIL_ON.join(", ")})`);
|
|
908
1430
|
process.exitCode = 2;
|
|
@@ -934,6 +1456,20 @@ async function runScan(path, opts) {
|
|
|
934
1456
|
process.exitCode = 2;
|
|
935
1457
|
}
|
|
936
1458
|
}
|
|
1459
|
+
async function readScanForCommand(path, offline = true) {
|
|
1460
|
+
if (!existsSync2(path)) {
|
|
1461
|
+
console.error(`seamshield: path not found: ${path}`);
|
|
1462
|
+
process.exitCode = 2;
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
return await scanAsync(path, { failOn: "never", network: offline ? "off" : "on" });
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
console.error(`seamshield: scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1469
|
+
process.exitCode = 2;
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
937
1473
|
function writeAgentContext(target, kind) {
|
|
938
1474
|
const body = [
|
|
939
1475
|
"# SEAMSHIELD",
|
|
@@ -948,24 +1484,65 @@ function writeAgentContext(target, kind) {
|
|
|
948
1484
|
].join("\n");
|
|
949
1485
|
if (kind === "cursor") {
|
|
950
1486
|
const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
|
|
951
|
-
|
|
952
|
-
|
|
1487
|
+
mkdirSync3(dirname3(out2), { recursive: true });
|
|
1488
|
+
writeFileSync3(out2, body);
|
|
953
1489
|
return out2;
|
|
954
1490
|
}
|
|
955
1491
|
const out = join7(target, "CLAUDE.md");
|
|
956
1492
|
const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
|
|
957
1493
|
const marker = "# SEAMSHIELD";
|
|
958
1494
|
const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
|
|
959
|
-
|
|
1495
|
+
writeFileSync3(out, next.endsWith("\n") ? next : `${next}
|
|
960
1496
|
`);
|
|
961
1497
|
return out;
|
|
962
1498
|
}
|
|
1499
|
+
function readTriageConfig(target) {
|
|
1500
|
+
const path = join7(target, ".seamshield", "config.yaml");
|
|
1501
|
+
if (!existsSync2(path)) return {};
|
|
1502
|
+
const parsed = parse3(readFileSync6(path, "utf8"));
|
|
1503
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1504
|
+
}
|
|
1505
|
+
function writeTriageConfig(target, config) {
|
|
1506
|
+
const out = join7(target, ".seamshield", "config.yaml");
|
|
1507
|
+
mkdirSync3(dirname3(out), { recursive: true });
|
|
1508
|
+
writeFileSync3(out, stringify(config));
|
|
1509
|
+
return out;
|
|
1510
|
+
}
|
|
1511
|
+
async function writeTriageSuppressions(path, opts) {
|
|
1512
|
+
const target = resolve2(path);
|
|
1513
|
+
const result = await readScanForCommand(target, !opts.online);
|
|
1514
|
+
if (!result) return;
|
|
1515
|
+
const config = readTriageConfig(target);
|
|
1516
|
+
const suppress = config.suppress ?? [];
|
|
1517
|
+
const existing = new Set(suppress.map((s) => `${s.rule}\0${s.file}\0${s.line ?? ""}`));
|
|
1518
|
+
const candidates = result.findings.filter((finding) => {
|
|
1519
|
+
if (opts.rule && finding.finding.rule_id !== opts.rule) return false;
|
|
1520
|
+
if (!opts.includeBlock && finding.finding.severity === "block") return false;
|
|
1521
|
+
return true;
|
|
1522
|
+
});
|
|
1523
|
+
for (const finding of candidates) {
|
|
1524
|
+
const entry = {
|
|
1525
|
+
rule: finding.finding.rule_id,
|
|
1526
|
+
file: finding.finding.file,
|
|
1527
|
+
line: finding.finding.line,
|
|
1528
|
+
reason: opts.reason ?? "triaged false positive"
|
|
1529
|
+
};
|
|
1530
|
+
const key = `${entry.rule}\0${entry.file}\0${entry.line}`;
|
|
1531
|
+
if (existing.has(key)) continue;
|
|
1532
|
+
suppress.push(entry);
|
|
1533
|
+
existing.add(key);
|
|
1534
|
+
}
|
|
1535
|
+
config.suppress = suppress;
|
|
1536
|
+
const out = writeTriageConfig(target, config);
|
|
1537
|
+
console.log(out);
|
|
1538
|
+
console.log(`Suppressed ${candidates.length} current finding(s).`);
|
|
1539
|
+
}
|
|
963
1540
|
function currentBin() {
|
|
964
1541
|
return fileURLToPath2(import.meta.url);
|
|
965
1542
|
}
|
|
966
1543
|
function installGuard(target) {
|
|
967
1544
|
const settingsPath = join7(target, ".claude", "settings.json");
|
|
968
|
-
|
|
1545
|
+
mkdirSync3(dirname3(settingsPath), { recursive: true });
|
|
969
1546
|
const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
|
|
970
1547
|
const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
971
1548
|
const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
|
|
@@ -976,7 +1553,7 @@ function installGuard(target) {
|
|
|
976
1553
|
}
|
|
977
1554
|
];
|
|
978
1555
|
settings.hooks = hooks;
|
|
979
|
-
|
|
1556
|
+
writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
980
1557
|
`);
|
|
981
1558
|
return settingsPath;
|
|
982
1559
|
}
|
|
@@ -1039,8 +1616,8 @@ function guardCheck() {
|
|
|
1039
1616
|
}
|
|
1040
1617
|
const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
|
|
1041
1618
|
const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
|
|
1042
|
-
|
|
1043
|
-
|
|
1619
|
+
mkdirSync3(dirname3(abs), { recursive: true });
|
|
1620
|
+
writeFileSync3(abs, proposed.content);
|
|
1044
1621
|
const result = scan(tempRoot, { network: "off" });
|
|
1045
1622
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
1046
1623
|
const block = result.findings.find((f) => f.finding.severity === "block");
|
|
@@ -1056,8 +1633,8 @@ function guardCheck() {
|
|
|
1056
1633
|
} catch (error) {
|
|
1057
1634
|
const logPath = join7(process.cwd(), ".seamshield", "guard.log");
|
|
1058
1635
|
try {
|
|
1059
|
-
|
|
1060
|
-
|
|
1636
|
+
mkdirSync3(dirname3(logPath), { recursive: true });
|
|
1637
|
+
writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
|
|
1061
1638
|
`, { flag: "a" });
|
|
1062
1639
|
} catch {
|
|
1063
1640
|
}
|
|
@@ -1069,7 +1646,23 @@ program.name("seamshield").description("Security scanner for AI-generated apps:
|
|
|
1069
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) => {
|
|
1070
1647
|
return runScan(path, opts);
|
|
1071
1648
|
});
|
|
1072
|
-
program.command("
|
|
1649
|
+
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
|
+
const result = await readScanForCommand(path, !opts.online);
|
|
1651
|
+
if (!result) return;
|
|
1652
|
+
const verdict = buildShipVerdict(result);
|
|
1653
|
+
console.log(renderShipTable(verdict));
|
|
1654
|
+
process.exitCode = verdict.exitCode;
|
|
1655
|
+
});
|
|
1656
|
+
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
|
+
if (!assertChoice(opts.format, ACCESS_FORMATS, "format")) return;
|
|
1658
|
+
const result = await readScanForCommand(path, !opts.online);
|
|
1659
|
+
if (!result) return;
|
|
1660
|
+
const access = buildAccessMap(result);
|
|
1661
|
+
console.log(opts.format === "json" ? renderAccessJson(access) : renderAccessTable(access));
|
|
1662
|
+
process.exitCode = 0;
|
|
1663
|
+
});
|
|
1664
|
+
program.command("fix-plan").description("Write agent-ready fix prompts for dangerous access lanes").argument("[path]", "directory to scan", ".").option("--offline", "skip npm registry and OSV network checks").option("--agent <agent>", "target agent: claude | cursor | codex | generic", "generic").action(async (path, opts) => {
|
|
1665
|
+
if (!assertChoice(opts.agent, FIX_AGENTS, "agent")) return;
|
|
1073
1666
|
if (!existsSync2(path)) {
|
|
1074
1667
|
console.error(`seamshield: path not found: ${path}`);
|
|
1075
1668
|
process.exitCode = 2;
|
|
@@ -1077,12 +1670,24 @@ program.command("fix-plan").description("Write .seamshield/fix-plan.json with ag
|
|
|
1077
1670
|
}
|
|
1078
1671
|
const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
|
|
1079
1672
|
const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
|
|
1080
|
-
|
|
1081
|
-
|
|
1673
|
+
mkdirSync3(dirname3(out), { recursive: true });
|
|
1674
|
+
writeFileSync3(out, `${JSON.stringify(buildFixPlan(result, { agent: opts.agent }), null, 2)}
|
|
1082
1675
|
`);
|
|
1676
|
+
const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
|
|
1083
1677
|
console.log(out);
|
|
1678
|
+
console.log(markdownOut);
|
|
1084
1679
|
process.exitCode = result.exitCode;
|
|
1085
1680
|
});
|
|
1681
|
+
program.command("learn").description("Update local controls from vulnerability intelligence without uploading source").option("--source <path-or-url>", "future rule/control bundle source").action((opts) => {
|
|
1682
|
+
console.log("SeamShield Learn");
|
|
1683
|
+
console.log("Status: local rule/control updates are not wired yet.");
|
|
1684
|
+
console.log("Privacy: no source code was read or uploaded.");
|
|
1685
|
+
if (opts.source) console.log(`Requested source: ${opts.source}`);
|
|
1686
|
+
process.exitCode = 0;
|
|
1687
|
+
});
|
|
1688
|
+
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
|
+
(path, opts) => writeTriageSuppressions(path, opts)
|
|
1690
|
+
);
|
|
1086
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) => {
|
|
1087
1692
|
const target = resolve2(path);
|
|
1088
1693
|
const kind = opts.cursor ? "cursor" : "claude";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seamshield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,16 +24,18 @@
|
|
|
24
24
|
"nextjs",
|
|
25
25
|
"supabase",
|
|
26
26
|
"firebase",
|
|
27
|
+
"convex",
|
|
28
|
+
"vercel",
|
|
29
|
+
"coolify",
|
|
27
30
|
"claude-code",
|
|
28
31
|
"cursor",
|
|
29
32
|
"osv",
|
|
30
33
|
"sarif"
|
|
31
34
|
],
|
|
32
35
|
"bin": {
|
|
33
|
-
"seamshield": "dist/index.js"
|
|
36
|
+
"seamshield": "./dist/index.js"
|
|
34
37
|
},
|
|
35
38
|
"files": [
|
|
36
|
-
"README.md",
|
|
37
39
|
"dist",
|
|
38
40
|
"rules",
|
|
39
41
|
"schemas"
|
|
@@ -41,22 +43,21 @@
|
|
|
41
43
|
"engines": {
|
|
42
44
|
"node": ">=20"
|
|
43
45
|
},
|
|
44
|
-
"scripts": {
|
|
45
|
-
"build": "tsup src/index.ts --format esm --clean && tsc -p tsconfig.build.json && rm -rf rules schemas && cp -R ../rules/rules ../rules/schemas .",
|
|
46
|
-
"prepack": "pnpm run build",
|
|
47
|
-
"test": "vitest run"
|
|
48
|
-
},
|
|
49
46
|
"dependencies": {
|
|
50
47
|
"commander": "^14.0.0",
|
|
51
48
|
"yaml": "^2.5.0",
|
|
52
49
|
"zod": "^4.0.0"
|
|
53
50
|
},
|
|
54
51
|
"devDependencies": {
|
|
55
|
-
"@seamshield/core": "workspace:*",
|
|
56
|
-
"@seamshield/rules": "workspace:*",
|
|
57
52
|
"@types/node": "^22.0.0",
|
|
58
53
|
"tsup": "^8.3.0",
|
|
59
54
|
"typescript": "^5.7.0",
|
|
60
|
-
"vitest": "^3.0.0"
|
|
55
|
+
"vitest": "^3.0.0",
|
|
56
|
+
"@seamshield/core": "0.1.0",
|
|
57
|
+
"@seamshield/rules": "0.1.0"
|
|
58
|
+
},
|
|
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 .",
|
|
61
|
+
"test": "vitest run"
|
|
61
62
|
}
|
|
62
|
-
}
|
|
63
|
+
}
|
|
@@ -11,9 +11,11 @@ check:
|
|
|
11
11
|
include:
|
|
12
12
|
path_contains: ["/api/"]
|
|
13
13
|
extensions: [".ts", ".js", ".tsx", ".jsx"]
|
|
14
|
+
exclude:
|
|
15
|
+
basenames: ["_*.ts", "_*.js", "_*.tsx", "_*.jsx"]
|
|
14
16
|
patterns:
|
|
15
17
|
- name: auth-or-signature-markers
|
|
16
|
-
regex: "auth|Auth|session|Session|currentUser|getUser|clerk|Clerk|jwt|JWT|cookie|Cookie|bearer|Bearer|signature|webhook|svix|x-api-key|apiKey|API_KEY|verif|rate[Ll]imit|public"
|
|
18
|
+
regex: "auth|Auth|session|Session|currentUser|getUser|clerk|Clerk|jwt|JWT|cookie|Cookie|bearer|Bearer|signature|webhook|svix|x-api-key|apiKey|API_KEY|verif|rate[Ll]imit|public|410|Gone"
|
|
17
19
|
fix:
|
|
18
20
|
summary: Authenticate the caller, or mark the route as intentionally public.
|
|
19
21
|
agent_prompt: >
|
|
@@ -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|internalMutation"
|
|
19
|
+
regex: "ctx\\.auth|getAuthUserId|getUserIdentity|requireAuth|requireInternalToken|getCaller|requireRole|requireAdmin|internalMutation|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,29 @@
|
|
|
1
|
+
id: ss/deploy/public-env-secret
|
|
2
|
+
severity: high
|
|
3
|
+
title: Deploy config exposes a secret as a public env var
|
|
4
|
+
description: >
|
|
5
|
+
A deploy or self-hosting config exposes a secret-looking value through a
|
|
6
|
+
public/client env namespace. Public env values can be embedded into builds,
|
|
7
|
+
logs, previews, or frontend bundles.
|
|
8
|
+
framework_ref: AI_AGENT_DROP_IN.md#default-security-stance
|
|
9
|
+
check:
|
|
10
|
+
type: regex
|
|
11
|
+
include:
|
|
12
|
+
basenames:
|
|
13
|
+
- "coolify.yaml"
|
|
14
|
+
- "coolify.yml"
|
|
15
|
+
- "docker-compose.yml"
|
|
16
|
+
- "docker-compose.yaml"
|
|
17
|
+
- "vercel.json"
|
|
18
|
+
- ".env"
|
|
19
|
+
- ".env.production"
|
|
20
|
+
patterns:
|
|
21
|
+
- name: public-secret-env
|
|
22
|
+
regex: "(?:NEXT_PUBLIC_|VITE_|PUBLIC_)[A-Z0-9_]*(?:SECRET|PRIVATE|PASSWORD|SERVICE_ROLE|API_KEY|TOKEN)[A-Z0-9_]*"
|
|
23
|
+
fix:
|
|
24
|
+
summary: Move the secret to a server-only environment variable.
|
|
25
|
+
agent_prompt: >
|
|
26
|
+
Remove this value from the public/client env namespace. Put the
|
|
27
|
+
credential in a server-only environment variable, update code to read it
|
|
28
|
+
only from server-side handlers or workers, and rotate the exposed value
|
|
29
|
+
if it may have reached a build artifact or log.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
id: ss/deps/postinstall-script
|
|
2
|
+
severity: warn
|
|
3
|
+
title: Dependency install lifecycle script can execute shell commands
|
|
4
|
+
description: >
|
|
5
|
+
npm lifecycle scripts such as postinstall run during dependency
|
|
6
|
+
installation. In AI-built apps this creates a supply-chain lane from a
|
|
7
|
+
dependency or pasted package.json change into local shell execution.
|
|
8
|
+
framework_ref: AI_AGENT_DROP_IN.md#supply-chain-stance
|
|
9
|
+
check:
|
|
10
|
+
type: regex
|
|
11
|
+
include:
|
|
12
|
+
basenames: ["package.json"]
|
|
13
|
+
patterns:
|
|
14
|
+
- name: install-lifecycle-script
|
|
15
|
+
regex: "\"(?:preinstall|install|postinstall)\"\\s*:"
|
|
16
|
+
fix:
|
|
17
|
+
summary: Remove install lifecycle scripts unless they are reviewed and required.
|
|
18
|
+
agent_prompt: >
|
|
19
|
+
Review why this install lifecycle script is needed. If it is not
|
|
20
|
+
required, remove it. If it is required, pin the dependency or script
|
|
21
|
+
owner, document the command, and make sure it cannot download or execute
|
|
22
|
+
unreviewed remote code.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
id: ss/server/route-no-auth
|
|
2
|
+
severity: warn
|
|
3
|
+
title: Self-hosted server route with no recognizable auth middleware
|
|
4
|
+
description: >
|
|
5
|
+
An Express, Fastify, or Hono route handler contains no recognizable auth,
|
|
6
|
+
signature, token, or public-route marker. Review whether this self-hosted
|
|
7
|
+
server endpoint should be callable by anyone.
|
|
8
|
+
framework_ref: AI_AGENT_DROP_IN.md#default-security-stance
|
|
9
|
+
check:
|
|
10
|
+
type: absence
|
|
11
|
+
include:
|
|
12
|
+
extensions: [".ts", ".js", ".tsx", ".jsx", ".mjs"]
|
|
13
|
+
path_contains: ["server", "api", "routes"]
|
|
14
|
+
file_contains: "\\b(?:app|router|server)\\.(?:get|post|put|patch|delete|route)\\s*\\("
|
|
15
|
+
patterns:
|
|
16
|
+
- name: server-auth-markers
|
|
17
|
+
regex: "auth|Auth|session|Session|jwt|JWT|bearer|Bearer|cookie|Cookie|signature|webhook|x-api-key|apiKey|API_KEY|requireAuth|verify|public|health"
|
|
18
|
+
fix:
|
|
19
|
+
summary: Add auth middleware or mark the route as intentionally public.
|
|
20
|
+
agent_prompt: >
|
|
21
|
+
Decide whether this server route should be public. If not, add auth,
|
|
22
|
+
API-key, session, or webhook-signature verification before the handler
|
|
23
|
+
does work. If it is intentionally public, add a short public-route
|
|
24
|
+
comment and a seamshield-ignore for this rule.
|