seamshield 0.1.3 → 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 +654 -61
- 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 -6
- 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
|
@@ -99,14 +99,17 @@ var require_picocolors = __commonJS({
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
// src/index.ts
|
|
102
|
-
import { spawnSync as
|
|
103
|
-
import { existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync } from "fs";
|
|
102
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
103
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
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";
|
|
@@ -141,14 +145,21 @@ import { dirname as dirname2, join as join4 } from "path";
|
|
|
141
145
|
import { existsSync } from "fs";
|
|
142
146
|
import { dirname as dirname22, join as join5 } from "path";
|
|
143
147
|
import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
|
|
144
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
145
148
|
import { join as join6, relative as relative2 } from "path";
|
|
146
149
|
import { z as z3 } from "zod";
|
|
147
150
|
var ConfigSchema = z.object({
|
|
148
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(),
|
|
149
160
|
rules: z.object({ disable: z.array(z.string()).optional() }).optional()
|
|
150
161
|
});
|
|
151
|
-
var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set() };
|
|
162
|
+
var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set(), suppressions: [] };
|
|
152
163
|
function loadConfig(root) {
|
|
153
164
|
let text;
|
|
154
165
|
try {
|
|
@@ -161,15 +172,450 @@ function loadConfig(root) {
|
|
|
161
172
|
ignorePrefixes: (parsed.ignore ?? []).map(
|
|
162
173
|
(p) => p.replace(/\/\*{1,2}$/, "").replace(/\/$/, "")
|
|
163
174
|
),
|
|
164
|
-
disabledRules: new Set(parsed.rules?.disable ?? [])
|
|
175
|
+
disabledRules: new Set(parsed.rules?.disable ?? []),
|
|
176
|
+
suppressions: parsed.suppress ?? []
|
|
165
177
|
};
|
|
166
178
|
}
|
|
167
179
|
function isIgnored(rel, prefixes) {
|
|
168
180
|
return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
|
|
169
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
|
+
}
|
|
170
614
|
function matchBasenamePattern(name, pattern) {
|
|
171
|
-
if (pattern.
|
|
172
|
-
|
|
615
|
+
if (pattern.includes("*")) {
|
|
616
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
617
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
618
|
+
}
|
|
173
619
|
return name === pattern;
|
|
174
620
|
}
|
|
175
621
|
function fileMatchesRule(file, check) {
|
|
@@ -304,7 +750,38 @@ function promptFor(finding) {
|
|
|
304
750
|
`Fix: ${finding.finding.fix.agent_prompt}`
|
|
305
751
|
].join("\n");
|
|
306
752
|
}
|
|
307
|
-
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);
|
|
308
785
|
const items = result.findings.map((finding) => ({
|
|
309
786
|
rule_id: finding.finding.rule_id,
|
|
310
787
|
severity: finding.finding.severity,
|
|
@@ -318,19 +795,31 @@ function buildFixPlan(result) {
|
|
|
318
795
|
return {
|
|
319
796
|
schema: "seamshield.fix-plan/v1",
|
|
320
797
|
target: result.target,
|
|
798
|
+
agent,
|
|
321
799
|
policy_bundle_digest: result.policyBundleDigest,
|
|
322
|
-
summary: { findings_total: result.findings.length },
|
|
800
|
+
summary: { findings_total: result.findings.length, access_lanes_total: access.lanes.length },
|
|
323
801
|
items,
|
|
802
|
+
access_lanes: access.lanes,
|
|
324
803
|
agent_markdown: [
|
|
325
804
|
"# SeamShield Fix Plan",
|
|
326
805
|
"",
|
|
327
|
-
|
|
806
|
+
...rulesForAgent(agent).map((rule) => `- ${rule}`),
|
|
328
807
|
"",
|
|
329
|
-
...
|
|
808
|
+
...access.lanes.map(lanePrompt),
|
|
330
809
|
""
|
|
331
810
|
].join("\n")
|
|
332
811
|
};
|
|
333
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
|
+
}
|
|
334
823
|
var PatternSchema = z2.object({
|
|
335
824
|
name: z2.string().min(1),
|
|
336
825
|
regex: z2.string().min(1)
|
|
@@ -474,42 +963,42 @@ function renderSarif(result) {
|
|
|
474
963
|
2
|
|
475
964
|
);
|
|
476
965
|
}
|
|
477
|
-
var
|
|
478
|
-
block: (s) =>
|
|
479
|
-
high: (s) =>
|
|
480
|
-
warn: (s) =>
|
|
481
|
-
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)
|
|
482
971
|
};
|
|
483
972
|
function renderTable(result) {
|
|
484
973
|
const lines = [];
|
|
485
974
|
lines.push(
|
|
486
|
-
`${
|
|
975
|
+
`${import_picocolors2.default.bold(`SeamShield v${result.engineVersion}`)}${import_picocolors2.default.dim(
|
|
487
976
|
` \u2014 ${result.filesScanned} files, ${result.rulesLoaded} rules`
|
|
488
977
|
)}`
|
|
489
978
|
);
|
|
490
979
|
lines.push("");
|
|
491
980
|
if (result.findings.length === 0) {
|
|
492
|
-
lines.push(
|
|
981
|
+
lines.push(import_picocolors2.default.green("\u2713 No findings."));
|
|
493
982
|
} else {
|
|
494
983
|
for (const f of result.findings) {
|
|
495
|
-
const sev =
|
|
984
|
+
const sev = SEVERITY_COLOR2[f.finding.severity](
|
|
496
985
|
f.finding.severity.toUpperCase().padEnd(5)
|
|
497
986
|
);
|
|
498
|
-
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}`)}`);
|
|
499
988
|
lines.push(` ${f.finding.title}`);
|
|
500
989
|
const evidence = f.spans[0]?.evidence;
|
|
501
|
-
if (evidence) lines.push(
|
|
502
|
-
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}`);
|
|
503
992
|
lines.push("");
|
|
504
993
|
}
|
|
505
994
|
const counts = /* @__PURE__ */ new Map();
|
|
506
995
|
for (const f of result.findings) {
|
|
507
996
|
counts.set(f.finding.severity, (counts.get(f.finding.severity) ?? 0) + 1);
|
|
508
997
|
}
|
|
509
|
-
const parts = [...counts.entries()].map(([sev, n]) =>
|
|
510
|
-
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(", ")})`);
|
|
511
1000
|
}
|
|
512
|
-
lines.push(
|
|
1001
|
+
lines.push(import_picocolors2.default.dim(`policy bundle ${result.policyBundleDigest.slice(0, 12)}`));
|
|
513
1002
|
return lines.join("\n");
|
|
514
1003
|
}
|
|
515
1004
|
var DEP_FIELDS = [
|
|
@@ -693,12 +1182,6 @@ function checkNoLockfile(rule, ctx) {
|
|
|
693
1182
|
}
|
|
694
1183
|
return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
|
|
695
1184
|
}
|
|
696
|
-
var SEVERITY_RANK = {
|
|
697
|
-
block: 0,
|
|
698
|
-
high: 1,
|
|
699
|
-
warn: 2,
|
|
700
|
-
info: 3
|
|
701
|
-
};
|
|
702
1185
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
703
1186
|
"node_modules",
|
|
704
1187
|
".git",
|
|
@@ -707,6 +1190,8 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
707
1190
|
"build",
|
|
708
1191
|
"out",
|
|
709
1192
|
"coverage",
|
|
1193
|
+
"target",
|
|
1194
|
+
".gradle",
|
|
710
1195
|
".turbo",
|
|
711
1196
|
".vercel",
|
|
712
1197
|
".cache",
|
|
@@ -714,8 +1199,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
714
1199
|
]);
|
|
715
1200
|
var MAX_FILE_BYTES = 1e6;
|
|
716
1201
|
function walk(root) {
|
|
717
|
-
const gitFiles = gitTrackedAndUnignoredFiles(root);
|
|
718
|
-
if (gitFiles) return gitFiles;
|
|
719
1202
|
const files = [];
|
|
720
1203
|
const visit = (dir) => {
|
|
721
1204
|
let entries;
|
|
@@ -746,15 +1229,6 @@ function walk(root) {
|
|
|
746
1229
|
visit(root);
|
|
747
1230
|
return files;
|
|
748
1231
|
}
|
|
749
|
-
function gitTrackedAndUnignoredFiles(root) {
|
|
750
|
-
const result = spawnSync2(
|
|
751
|
-
"git",
|
|
752
|
-
["-C", root, "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
753
|
-
{ encoding: "utf8", timeout: 1500 }
|
|
754
|
-
);
|
|
755
|
-
if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
756
|
-
return result.stdout.split("\n").filter(Boolean).filter((rel) => !rel.split("/").some((part) => SKIP_DIRS.has(part))).filter((rel) => rel !== ".claude/worktrees" && !rel.includes("/.claude/worktrees/")).filter((rel) => rel !== ".worktrees" && !rel.startsWith(".worktrees/")).map((rel) => ({ abs: join6(root, rel), rel }));
|
|
757
|
-
}
|
|
758
1232
|
var FileCache = class {
|
|
759
1233
|
cache = /* @__PURE__ */ new Map();
|
|
760
1234
|
read(abs) {
|
|
@@ -798,7 +1272,14 @@ function scan(target, options = {}) {
|
|
|
798
1272
|
throw new Error(`${rule.id}: unknown builtin check "${rule.check.builtin}"`);
|
|
799
1273
|
}
|
|
800
1274
|
}
|
|
801
|
-
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));
|
|
802
1283
|
findings.sort(
|
|
803
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)
|
|
804
1285
|
);
|
|
@@ -846,7 +1327,12 @@ async function scanAsync(target, options = {}) {
|
|
|
846
1327
|
}
|
|
847
1328
|
}
|
|
848
1329
|
result.findings = [...result.findings, ...dependencyFindings].filter(
|
|
849
|
-
(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
|
+
)
|
|
850
1336
|
);
|
|
851
1337
|
result.findings.sort(
|
|
852
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)
|
|
@@ -855,6 +1341,22 @@ async function scanAsync(target, options = {}) {
|
|
|
855
1341
|
result.networkSkipped = false;
|
|
856
1342
|
return result;
|
|
857
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
|
+
}
|
|
858
1360
|
function computeExitCode(findings, failOn) {
|
|
859
1361
|
if (failOn === "never") return 0;
|
|
860
1362
|
const threshold = SEVERITY_RANK[failOn];
|
|
@@ -908,13 +1410,21 @@ var pkg = JSON.parse(
|
|
|
908
1410
|
readFileSync6(new URL("../package.json", import.meta.url), "utf8")
|
|
909
1411
|
);
|
|
910
1412
|
var FORMATS = ["table", "json", "sarif"];
|
|
1413
|
+
var ACCESS_FORMATS = ["table", "json"];
|
|
911
1414
|
var FAIL_ON = ["block", "high", "warn", "never"];
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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(", ")})`);
|
|
915
1419
|
process.exitCode = 2;
|
|
916
1420
|
return false;
|
|
917
1421
|
}
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
function assertOptions(opts) {
|
|
1425
|
+
if (!assertChoice(opts.format, FORMATS, "format")) {
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
918
1428
|
if (opts.failOn && !FAIL_ON.includes(opts.failOn)) {
|
|
919
1429
|
console.error(`seamshield: unknown --fail-on "${opts.failOn}" (expected: ${FAIL_ON.join(", ")})`);
|
|
920
1430
|
process.exitCode = 2;
|
|
@@ -946,6 +1456,20 @@ async function runScan(path, opts) {
|
|
|
946
1456
|
process.exitCode = 2;
|
|
947
1457
|
}
|
|
948
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
|
+
}
|
|
949
1473
|
function writeAgentContext(target, kind) {
|
|
950
1474
|
const body = [
|
|
951
1475
|
"# SEAMSHIELD",
|
|
@@ -960,24 +1484,65 @@ function writeAgentContext(target, kind) {
|
|
|
960
1484
|
].join("\n");
|
|
961
1485
|
if (kind === "cursor") {
|
|
962
1486
|
const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
|
|
963
|
-
|
|
964
|
-
|
|
1487
|
+
mkdirSync3(dirname3(out2), { recursive: true });
|
|
1488
|
+
writeFileSync3(out2, body);
|
|
965
1489
|
return out2;
|
|
966
1490
|
}
|
|
967
1491
|
const out = join7(target, "CLAUDE.md");
|
|
968
1492
|
const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
|
|
969
1493
|
const marker = "# SEAMSHIELD";
|
|
970
1494
|
const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
|
|
971
|
-
|
|
1495
|
+
writeFileSync3(out, next.endsWith("\n") ? next : `${next}
|
|
972
1496
|
`);
|
|
973
1497
|
return out;
|
|
974
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
|
+
}
|
|
975
1540
|
function currentBin() {
|
|
976
1541
|
return fileURLToPath2(import.meta.url);
|
|
977
1542
|
}
|
|
978
1543
|
function installGuard(target) {
|
|
979
1544
|
const settingsPath = join7(target, ".claude", "settings.json");
|
|
980
|
-
|
|
1545
|
+
mkdirSync3(dirname3(settingsPath), { recursive: true });
|
|
981
1546
|
const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
|
|
982
1547
|
const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
983
1548
|
const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
|
|
@@ -988,7 +1553,7 @@ function installGuard(target) {
|
|
|
988
1553
|
}
|
|
989
1554
|
];
|
|
990
1555
|
settings.hooks = hooks;
|
|
991
|
-
|
|
1556
|
+
writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
992
1557
|
`);
|
|
993
1558
|
return settingsPath;
|
|
994
1559
|
}
|
|
@@ -1025,7 +1590,7 @@ function bashDecision(command) {
|
|
|
1025
1590
|
const name = command.match(/npm\s+(?:i|install|add)\s+(@?[\w.-]+\/?[\w.-]*)/)?.[1];
|
|
1026
1591
|
if (name) {
|
|
1027
1592
|
const encoded = name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
1028
|
-
const res =
|
|
1593
|
+
const res = spawnSync2("curl", ["-fsSI", `https://registry.npmjs.org/${encoded}`], {
|
|
1029
1594
|
encoding: "utf8",
|
|
1030
1595
|
timeout: 750
|
|
1031
1596
|
});
|
|
@@ -1051,8 +1616,8 @@ function guardCheck() {
|
|
|
1051
1616
|
}
|
|
1052
1617
|
const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
|
|
1053
1618
|
const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
|
|
1054
|
-
|
|
1055
|
-
|
|
1619
|
+
mkdirSync3(dirname3(abs), { recursive: true });
|
|
1620
|
+
writeFileSync3(abs, proposed.content);
|
|
1056
1621
|
const result = scan(tempRoot, { network: "off" });
|
|
1057
1622
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
1058
1623
|
const block = result.findings.find((f) => f.finding.severity === "block");
|
|
@@ -1068,8 +1633,8 @@ function guardCheck() {
|
|
|
1068
1633
|
} catch (error) {
|
|
1069
1634
|
const logPath = join7(process.cwd(), ".seamshield", "guard.log");
|
|
1070
1635
|
try {
|
|
1071
|
-
|
|
1072
|
-
|
|
1636
|
+
mkdirSync3(dirname3(logPath), { recursive: true });
|
|
1637
|
+
writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
|
|
1073
1638
|
`, { flag: "a" });
|
|
1074
1639
|
} catch {
|
|
1075
1640
|
}
|
|
@@ -1081,7 +1646,23 @@ program.name("seamshield").description("Security scanner for AI-generated apps:
|
|
|
1081
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) => {
|
|
1082
1647
|
return runScan(path, opts);
|
|
1083
1648
|
});
|
|
1084
|
-
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;
|
|
1085
1666
|
if (!existsSync2(path)) {
|
|
1086
1667
|
console.error(`seamshield: path not found: ${path}`);
|
|
1087
1668
|
process.exitCode = 2;
|
|
@@ -1089,12 +1670,24 @@ program.command("fix-plan").description("Write .seamshield/fix-plan.json with ag
|
|
|
1089
1670
|
}
|
|
1090
1671
|
const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
|
|
1091
1672
|
const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
|
|
1092
|
-
|
|
1093
|
-
|
|
1673
|
+
mkdirSync3(dirname3(out), { recursive: true });
|
|
1674
|
+
writeFileSync3(out, `${JSON.stringify(buildFixPlan(result, { agent: opts.agent }), null, 2)}
|
|
1094
1675
|
`);
|
|
1676
|
+
const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
|
|
1095
1677
|
console.log(out);
|
|
1678
|
+
console.log(markdownOut);
|
|
1096
1679
|
process.exitCode = result.exitCode;
|
|
1097
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
|
+
);
|
|
1098
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) => {
|
|
1099
1692
|
const target = resolve2(path);
|
|
1100
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: >
|
|
@@ -20,14 +20,11 @@ check:
|
|
|
20
20
|
basenames:
|
|
21
21
|
- ".env*"
|
|
22
22
|
exclude:
|
|
23
|
-
|
|
24
|
-
- "
|
|
25
|
-
- "test"
|
|
26
|
-
- "tests"
|
|
27
|
-
- "__tests__"
|
|
23
|
+
basenames:
|
|
24
|
+
- "seamshield-release-gate.mjs"
|
|
28
25
|
patterns:
|
|
29
26
|
- name: next-public-secret-name
|
|
30
|
-
regex: "
|
|
27
|
+
regex: "NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|ADMIN)[A-Z0-9_]*"
|
|
31
28
|
fix:
|
|
32
29
|
summary: Rename the variable to drop the NEXT_PUBLIC_ prefix and read it only on the server.
|
|
33
30
|
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.
|