@vibefoundry/vibe-shield 0.0.0-stage → 1.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -3
  3. package/SECURITY.md +110 -0
  4. package/SKILL.md +124 -0
  5. package/dist/bin/vibe-shield.d.ts +59 -0
  6. package/dist/bin/vibe-shield.js +545 -0
  7. package/dist/src/index.d.ts +25 -0
  8. package/dist/src/index.js +108 -0
  9. package/dist/src/init/scaffold.d.ts +120 -0
  10. package/dist/src/init/scaffold.js +556 -0
  11. package/dist/src/mcp/server.d.ts +127 -0
  12. package/dist/src/mcp/server.js +479 -0
  13. package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
  14. package/dist/src/mcp-guard/skill-auditor.js +1129 -0
  15. package/dist/src/reporter/console-reporter.d.ts +58 -0
  16. package/dist/src/reporter/console-reporter.js +254 -0
  17. package/dist/src/reporter/formatters.d.ts +38 -0
  18. package/dist/src/reporter/formatters.js +262 -0
  19. package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
  20. package/dist/src/reporter/patch-prompt-writer.js +238 -0
  21. package/dist/src/scanner/context.d.ts +51 -0
  22. package/dist/src/scanner/context.js +568 -0
  23. package/dist/src/scanner/engine.d.ts +130 -0
  24. package/dist/src/scanner/engine.js +726 -0
  25. package/dist/src/scanner/gitignore.d.ts +49 -0
  26. package/dist/src/scanner/gitignore.js +195 -0
  27. package/dist/src/scanner/lexicon.d.ts +56 -0
  28. package/dist/src/scanner/lexicon.js +357 -0
  29. package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
  30. package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
  31. package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
  32. package/dist/src/scanner/rules/client-exposure.js +410 -0
  33. package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
  34. package/dist/src/scanner/rules/database-exposure.js +626 -0
  35. package/dist/src/scanner/rules/helpers.d.ts +31 -0
  36. package/dist/src/scanner/rules/helpers.js +85 -0
  37. package/dist/src/scanner/rules/index.d.ts +30 -0
  38. package/dist/src/scanner/rules/index.js +95 -0
  39. package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
  40. package/dist/src/scanner/rules/secret-entropy.js +338 -0
  41. package/dist/src/scanner/rules/shared.d.ts +100 -0
  42. package/dist/src/scanner/rules/shared.js +161 -0
  43. package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
  44. package/dist/src/scanner/rules/unguarded-routes.js +526 -0
  45. package/dist/src/scanner/text.d.ts +90 -0
  46. package/dist/src/scanner/text.js +472 -0
  47. package/dist/src/scanner/types.d.ts +340 -0
  48. package/dist/src/scanner/types.js +195 -0
  49. package/dist/src/secrets/patterns.d.ts +63 -0
  50. package/dist/src/secrets/patterns.js +392 -0
  51. package/package.json +97 -4
@@ -0,0 +1,526 @@
1
+ "use strict";
2
+ /**
3
+ * vibe-shield :: unguarded-routes rules
4
+ * -------------------------------------
5
+ * The second great AI failure mode: an endpoint that does exactly what it was
6
+ * asked to do, with no check on *who* is asking. An agent asked to "add a
7
+ * delete endpoint" happily writes `export async function DELETE(req)` with no
8
+ * session lookup at all, and a route that deletes by `req.body.id` is a
9
+ * one-request account takeover.
10
+ *
11
+ * Nine rules cover the space: App Router handlers, Pages Router handlers,
12
+ * Express/Fastify/Koa/Hono routes, object-level authorisation (IDOR), Server
13
+ * Actions, identity taken from an unverified source, admin surfaces without a
14
+ * role check, auth that is fetched but never enforced, and cookie-session CSRF.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.UNGUARDED_ROUTE_RULES = void 0;
18
+ const lexicon_1 = require("../lexicon");
19
+ const text_1 = require("../text");
20
+ const helpers_1 = require("./helpers");
21
+ const shared_1 = require("./shared");
22
+ const JS_ROUTE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs'];
23
+ /** Next.js App Router verb exports. */
24
+ const APP_ROUTER_VERB_REGEX = /export\s+(?:async\s+)?(?:function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b|const\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*[:=])/g;
25
+ const MUTATING_VERBS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
26
+ /** Extract `{ verb, offset, body }` for every HTTP handler in an App Router file. */
27
+ function appRouterHandlers(ctx) {
28
+ const handlers = [];
29
+ for (const match of ctx.code.matchAll(APP_ROUTER_VERB_REGEX)) {
30
+ const verb = (match[1] ?? match[2] ?? '').toUpperCase();
31
+ const open = ctx.code.indexOf('{', match.index);
32
+ const body = open === -1 ? '' : (0, text_1.balancedBlock)(ctx.code, open, '{', '}');
33
+ handlers.push({ verb, offset: match.index, body });
34
+ }
35
+ return handlers;
36
+ }
37
+ /** Express/Fastify/Koa/Hono route registrations, with the handler body. */
38
+ function serverFrameworkHandlers(ctx) {
39
+ const out = [];
40
+ const regex = /\b(?:app|router|server|fastify|api|routes?|hono|route)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*(['"`])([^'"`]{0,200})\2/g;
41
+ for (const match of ctx.code.matchAll(regex)) {
42
+ const verb = (match[1] ?? '').toLowerCase();
43
+ if (!['post', 'put', 'patch', 'delete', 'all'].includes(verb))
44
+ continue;
45
+ const open = ctx.code.indexOf('(', match.index);
46
+ // The whole argument list of the registration, not just the path.
47
+ const body = (0, text_1.balancedBlock)(ctx.code, open, '(', ')');
48
+ out.push({ verb, offset: match.index, body, path: match[3] ?? '' });
49
+ }
50
+ return out;
51
+ }
52
+ const ADMIN_PATH_REGEX = /\b(?:admin|superuser|internal|backoffice|manage|staff|root|ops)(?:\b|\/|_|-)/i;
53
+ const DESTRUCTIVE_CODE_REGEX = /\b(?:\.delete\(|\.deleteMany\(|\.destroy\(|DELETE\s+FROM|DROP\s+TABLE|TRUNCATE|deleteUser|removeUser|refund|payout|transferFunds|impersonate|setRole|updateRole|grantAdmin|disableMfa|resetPassword)\b/i;
54
+ /** VSB-AUTH-001 - App Router mutation handler with no auth check. */
55
+ const appRouterMutationNoAuth = (0, helpers_1.defineRule)({
56
+ id: 'VSB-AUTH-001',
57
+ slug: 'app-router-mutation-without-auth',
58
+ title: 'Next.js App Router mutation handler has no authentication check',
59
+ category: 'unguarded-routes',
60
+ severity: 'critical',
61
+ confidence: 'high',
62
+ cwe: shared_1.CWES.missingAuth,
63
+ description: 'A `POST`/`PUT`/`PATCH`/`DELETE` handler under `app/**/route.ts` runs on the public internet. Without a session lookup (`auth()`, `getServerSession`, `currentUser`) plus an authorisation check, anyone who guesses the URL can mutate your database.',
64
+ insecureExample: 'export async function DELETE(req: Request) {\n const { id } = await req.json();\n await db.users.delete({ where: { id } });\n}',
65
+ secureExample: 'export async function DELETE(req: Request) {\n const session = await auth();\n if (!session?.user) return new Response("Unauthorized", { status: 401 });\n ...\n}',
66
+ remediation: [
67
+ 'Call your auth helper (`auth()`, `getServerSession`, `currentUser`, Clerk `auth()`) at the top of the handler and return 401 when there is no session.',
68
+ 'Authorise the action, not just the request: verify the caller owns (or has an explicit role for) the resource identified in the body or URL.',
69
+ 'Validate the request body with a schema (zod/valibot) before it reaches the database.',
70
+ 'If the endpoint really must be public, gate it with a shared secret header comparison or a signed token and document why.',
71
+ ],
72
+ references: [
73
+ 'https://nextjs.org/docs/app/building-your-application/routing/route-handlers',
74
+ 'https://cwe.mitre.org/data/definitions/306.html',
75
+ 'https://nextjs.org/docs/app/building-your-application/authentication',
76
+ ],
77
+ extensions: JS_ROUTE_EXTENSIONS,
78
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
79
+ appliesTo: (ctx) => ctx.flags.isAppRouterRoute,
80
+ detect(ctx) {
81
+ if (ctx.project.hasGlobalAuthMiddleware && ctx.project.authMiddlewareMatchers.length === 0)
82
+ return [];
83
+ const hits = [];
84
+ for (const handler of appRouterHandlers(ctx)) {
85
+ if (!MUTATING_VERBS.has(handler.verb))
86
+ continue;
87
+ if (lexicon_1.AUTH_VERIFICATION_REGEX.test(handler.body))
88
+ continue;
89
+ if (lexicon_1.ROUTE_AUTH_MIDDLEWARE_REGEX.test(handler.body))
90
+ continue;
91
+ // A webhook endpoint is authenticated by its signature, not a session.
92
+ if (/\b(?:webhook|stripe|svix|github|clerk|lemonsqueezy|paddle)\b/i.test(ctx.relativePath) && /constructEvent|verifyWebhook|verifySignature|webhooks?\.|timingSafeEqual/i.test(handler.body))
93
+ continue;
94
+ hits.push({
95
+ offset: handler.offset,
96
+ matched: `export async function ${handler.verb}`,
97
+ message: `\`${handler.verb}\` handler has no session check — the endpoint is open to anonymous callers.`,
98
+ data: { verb: handler.verb },
99
+ });
100
+ }
101
+ return hits;
102
+ },
103
+ });
104
+ /** VSB-AUTH-002 - Pages Router API mutation without auth. */
105
+ const pagesApiMutationNoAuth = (0, helpers_1.defineRule)({
106
+ id: 'VSB-AUTH-002',
107
+ slug: 'pages-api-mutation-without-auth',
108
+ title: 'Next.js Pages API mutation handler has no authentication check',
109
+ category: 'unguarded-routes',
110
+ severity: 'critical',
111
+ confidence: 'high',
112
+ cwe: shared_1.CWES.missingAuth,
113
+ description: 'A `pages/api/**` handler that branches on `req.method === "POST"` and mutates state without calling `getSession`/`getServerSession`/`req.auth` is publicly writable.',
114
+ insecureExample: 'export default async function handler(req, res) {\n if (req.method === "POST") await db.update(...);\n}',
115
+ secureExample: 'export default async function handler(req, res) {\n const session = await getServerSession(req, res, authOptions);\n if (!session) return res.status(401).json({ error: "Unauthorized" });\n ...\n}',
116
+ remediation: [
117
+ 'Call `getServerSession(req, res, authOptions)` (or your provider equivalent) before any mutation and return 401 when absent.',
118
+ 'Reject methods you do not implement explicitly instead of falling through to a default branch.',
119
+ 'Authorise the concrete record: compare the session user id against the row owner, do not trust `req.body.userId`.',
120
+ ],
121
+ references: [
122
+ 'https://nextjs.org/docs/pages/building-your-application/routing/api-routes',
123
+ 'https://cwe.mitre.org/data/definitions/306.html',
124
+ ],
125
+ extensions: JS_ROUTE_EXTENSIONS,
126
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
127
+ appliesTo: (ctx) => ctx.flags.isPagesApiRoute,
128
+ detect(ctx) {
129
+ if (!/req\.method|request\.method|method\s*===|method\s*==/.test(ctx.code))
130
+ return [];
131
+ if (!lexicon_1.MUTATION_REGEX.test(ctx.code))
132
+ return [];
133
+ if (lexicon_1.AUTH_VERIFICATION_REGEX.test(ctx.code))
134
+ return [];
135
+ if (ctx.project.hasGlobalAuthMiddleware && ctx.project.authMiddlewareMatchers.length === 0)
136
+ return [];
137
+ const offset = ctx.code.search(/req\.method|request\.method/);
138
+ return [
139
+ (0, helpers_1.fileHit)(ctx.locate(offset === -1 ? 0 : offset).line, {
140
+ matched: 'req.method',
141
+ message: 'Pages API handler branches on the HTTP method and mutates state, but never resolves a session.',
142
+ }),
143
+ ];
144
+ },
145
+ });
146
+ /** VSB-AUTH-003 - Express-family mutation route with no auth middleware. */
147
+ const serverRouteNoAuth = (0, helpers_1.defineRule)({
148
+ id: 'VSB-AUTH-003',
149
+ slug: 'server-route-mutation-without-auth',
150
+ title: 'Mutation route registered without auth middleware',
151
+ category: 'unguarded-routes',
152
+ severity: 'high',
153
+ confidence: 'high',
154
+ cwe: shared_1.CWES.missingAuthz,
155
+ description: 'An Express/Fastify/Koa/Hono route mutates state but its middleware chain contains neither an auth guard nor an in-handler session check. Middleware is the *only* thing between the public internet and the handler.',
156
+ insecureExample: "app.post('/api/users/:id', async (req, res) => { await db.users.update(...) })",
157
+ secureExample: "app.post('/api/users/:id', requireAuth, async (req, res) => { ... })",
158
+ remediation: [
159
+ 'Add your auth middleware to the route: `app.post(path, requireAuth, handler)` — or call the session helper inside the handler before touching data.',
160
+ 'For role-gated operations add a second guard (`requireAdmin`) after authentication.',
161
+ 'Prefer mounting the guard on the router (`router.use(requireAuth)`) so new routes cannot forget it.',
162
+ ],
163
+ references: [
164
+ 'https://expressjs.com/en/guide/using-middleware.html',
165
+ 'https://cwe.mitre.org/data/definitions/862.html',
166
+ ],
167
+ extensions: JS_ROUTE_EXTENSIONS,
168
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
169
+ appliesTo: (ctx) => ctx.flags.isServerFrameworkFile,
170
+ detect(ctx) {
171
+ const hits = [];
172
+ const routerGuarded = /\b(?:app|router|server|fastify|api)\s*\.\s*(?:use|register|addHook)\s*\(\s*[^)]{0,120}(?:requireAuth|isAuthenticated|authenticate|authMiddleware|authGuard|verifyToken|ensureAuth|jwt\(|passport\.authenticate)/i.test(ctx.code);
173
+ if (routerGuarded)
174
+ return [];
175
+ for (const handler of serverFrameworkHandlers(ctx)) {
176
+ if (lexicon_1.AUTH_VERIFICATION_REGEX.test(handler.body))
177
+ continue;
178
+ if (lexicon_1.ROUTE_AUTH_MIDDLEWARE_REGEX.test(handler.body))
179
+ continue;
180
+ if (!lexicon_1.MUTATION_REGEX.test(handler.body))
181
+ continue;
182
+ // Public-by-design endpoints: login/register/webhook/health/metrics.
183
+ if (/\/(?:login|signin|sign-in|signup|sign-up|register|auth|oauth|callback|webhook|health|healthz|ping|metrics|public)(?:\/|['"`]|$)/i.test(handler.path))
184
+ continue;
185
+ hits.push({
186
+ offset: handler.offset,
187
+ matched: handler.body.slice(0, 120).trim(),
188
+ message: `\`${handler.verb.toUpperCase()} ${handler.path || '/'}\` mutates state with no auth middleware and no session check.`,
189
+ data: { verb: handler.verb.toUpperCase(), route: handler.path },
190
+ });
191
+ }
192
+ return hits;
193
+ },
194
+ });
195
+ /** VSB-AUTH-004 - IDOR: ownership key taken from the request. */
196
+ const idorOwnershipFromRequest = (0, helpers_1.defineRule)({
197
+ id: 'VSB-AUTH-004',
198
+ slug: 'idor-ownership-from-request',
199
+ title: 'Record ownership taken from the request instead of the session (IDOR)',
200
+ category: 'unguarded-routes',
201
+ severity: 'critical',
202
+ confidence: 'medium',
203
+ cwe: shared_1.CWES.idor,
204
+ description: 'When `userId`/`tenantId`/`accountId` comes from the body, query string or headers, the caller decides whose data they touch. This is the classic Insecure Direct Object Reference: change one field and you are operating inside someone else\'s account.',
205
+ insecureExample: 'await db.orders.delete({ where: { id: req.body.id, userId: req.body.userId } })',
206
+ secureExample: 'const session = await auth();\nawait db.orders.delete({ where: { id, userId: session.user.id } })',
207
+ remediation: [
208
+ 'Derive the owner from the verified session (`session.user.id`, `auth().userId`, `req.user.id`) — never from request input.',
209
+ 'Add the ownership predicate to the database query itself (`where: { id, userId: session.user.id }`) so a wrong id is a no-op rather than a leak.',
210
+ 'Do not accept `userId` in the request body at all; validate the schema with `.strict()` so extra fields are rejected.',
211
+ 'Return 404 (not 403) for records the caller cannot see, to avoid an enumeration oracle.',
212
+ ],
213
+ references: [
214
+ 'https://owasp.org/www-project-top-ten/2017/A5_2017-Broken_Access_Control',
215
+ 'https://cwe.mitre.org/data/definitions/639.html',
216
+ ],
217
+ extensions: [...JS_ROUTE_EXTENSIONS, 'php', 'py', 'rb', 'go'],
218
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
219
+ detect(ctx) {
220
+ if (!ctx.flags.mentionsMutation)
221
+ return [];
222
+ const hits = [];
223
+ const ownershipNames = lexicon_1.OWNERSHIP_KEY_REGEX.source.replace(/^\\b\(|\)\\b$/g, '');
224
+ // 1. Direct read: `req.body.userId`, `searchParams.get("tenantId")`, ...
225
+ const direct = new RegExp(`(?:${lexicon_1.UNTRUSTED_SOURCE_REGEX.source})[^\\n;]{0,40}\\b(${ownershipNames})\\b`, 'gi');
226
+ for (const match of ctx.code.matchAll(direct)) {
227
+ const key = match[1] ?? '';
228
+ const line = ctx.lines[ctx.locate(match.index).line - 1] ?? '';
229
+ if (lexicon_1.SESSION_OWNERSHIP_REGEX.test(line))
230
+ continue;
231
+ if (!/(?:where|filter|eq\(|match|delete|update|select|findFirst|findUnique|findOne|destroy|remove|insert|create)/i.test(line) && !ctx.flags.mentionsMutation)
232
+ continue;
233
+ hits.push({
234
+ offset: match.index,
235
+ matched: match[0].slice(0, 120),
236
+ message: `\`${key}\` is read from the request and used for an ownership decision.`,
237
+ data: { key },
238
+ });
239
+ }
240
+ // 2. Indirect: `const { userId } = await req.json()` then `where: { userId }`.
241
+ // This is the shape agents actually write, so resolving the local binding
242
+ // is the difference between catching IDOR and catching nothing.
243
+ const taintedVars = new Map();
244
+ const destructure = new RegExp(`const\\s*\\{([^}]{1,200})\\}\\s*=\\s*(?:await\\s+)?(?:${lexicon_1.UNTRUSTED_SOURCE_REGEX.source})`, 'gi');
245
+ for (const match of ctx.code.matchAll(destructure)) {
246
+ for (const field of (match[1] ?? '').split(',')) {
247
+ const name = field.split(':').pop()?.split('=')[0]?.trim() ?? '';
248
+ if (!name || !lexicon_1.OWNERSHIP_KEY_REGEX.test(name))
249
+ continue;
250
+ taintedVars.set(name, name);
251
+ }
252
+ }
253
+ for (const match of ctx.code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]{0,120})/g)) {
254
+ const name = match[1] ?? '';
255
+ const value = match[2] ?? '';
256
+ if (!lexicon_1.OWNERSHIP_KEY_REGEX.test(name))
257
+ continue;
258
+ if (!lexicon_1.UNTRUSTED_SOURCE_REGEX.test(value))
259
+ continue;
260
+ taintedVars.set(name, name);
261
+ }
262
+ for (const [variable, key] of taintedVars) {
263
+ const usage = new RegExp(`(?:where|filter|eq\(|match|delete|update|select|findFirst|findUnique|findOne|destroy|remove|insert|create)[^\\n;]{0,80}\\b${(0, text_1.escapeRegExp)(variable)}\\b|\\b${(0, text_1.escapeRegExp)(variable)}\\b[^\\n;]{0,40}(?:where|filter)`, 'i');
264
+ const hit = usage.exec(ctx.code);
265
+ if (!hit)
266
+ continue;
267
+ const line = ctx.lines[ctx.locate(hit.index).line - 1] ?? '';
268
+ if (lexicon_1.SESSION_OWNERSHIP_REGEX.test(line))
269
+ continue;
270
+ hits.push({
271
+ offset: hit.index,
272
+ matched: line.trim().slice(0, 120),
273
+ message: `\`${key}\` is taken from the request body and used as the ownership predicate — any caller can address another user's record.`,
274
+ data: { key, variable },
275
+ });
276
+ }
277
+ return hits;
278
+ },
279
+ });
280
+ /** VSB-AUTH-005 - Server Action mutating without auth. */
281
+ const serverActionNoAuth = (0, helpers_1.defineRule)({
282
+ id: 'VSB-AUTH-005',
283
+ slug: 'server-action-without-auth',
284
+ title: 'Next.js Server Action mutates data without authentication',
285
+ category: 'unguarded-routes',
286
+ severity: 'high',
287
+ confidence: 'high',
288
+ cwe: shared_1.CWES.missingAuth,
289
+ description: 'A `"use server"` module compiles to a publicly reachable RPC endpoint. The "it is only called from my own component" assumption is false: the action id is in the client bundle and can be invoked directly.',
290
+ insecureExample: '"use server";\nexport async function deleteAccount(id: string) { await db.users.delete({ where: { id } }); }',
291
+ secureExample: '"use server";\nexport async function deleteAccount(id: string) {\n const session = await auth();\n if (!session?.user) throw new Error("Unauthorized");\n ...\n}',
292
+ remediation: [
293
+ 'Resolve the session at the top of every Server Action that mutates data, and throw/return early when it is missing.',
294
+ 'Check that the session user may act on the argument (ownership or role) — the argument is attacker-controlled.',
295
+ 'Validate every argument with a schema; Server Action arguments are serialised from the client and are not trusted input.',
296
+ 'Add `import "server-only";` to modules that must never be imported by a client component.',
297
+ ],
298
+ references: [
299
+ 'https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#security',
300
+ 'https://cwe.mitre.org/data/definitions/306.html',
301
+ ],
302
+ extensions: JS_ROUTE_EXTENSIONS,
303
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
304
+ appliesTo: (ctx) => ctx.flags.isServerActionModule,
305
+ detect(ctx) {
306
+ const hits = [];
307
+ for (const match of ctx.code.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
308
+ const open = ctx.code.indexOf('{', match.index + match[0].length);
309
+ const body = open === -1 ? '' : (0, text_1.balancedBlock)(ctx.code, open, '{', '}');
310
+ if (!lexicon_1.MUTATION_REGEX.test(body))
311
+ continue;
312
+ if (lexicon_1.AUTH_VERIFICATION_REGEX.test(body))
313
+ continue;
314
+ hits.push({
315
+ offset: match.index,
316
+ matched: match[0].trim(),
317
+ message: `Server Action \`${match[1]}\` mutates data without checking the session.`,
318
+ data: { action: match[1] },
319
+ });
320
+ }
321
+ return hits;
322
+ },
323
+ });
324
+ /** VSB-AUTH-006 - identity accepted from an unverified source. */
325
+ const unverifiedIdentity = (0, helpers_1.defineRule)({
326
+ id: 'VSB-AUTH-006',
327
+ slug: 'unverified-identity-source',
328
+ title: 'Identity derived from an unverified token, header or decode() call',
329
+ category: 'unguarded-routes',
330
+ severity: 'critical',
331
+ confidence: 'high',
332
+ cwe: shared_1.CWES.improperCertValidation,
333
+ description: '`jwt.decode()` does not verify the signature, `atob(token.split(".")[1])` is just base64 decoding, and `x-user-id` is a header the caller writes. Any of these used as the identity is a full authentication bypass.',
334
+ insecureExample: 'const { sub } = jwt.decode(req.headers.authorization);\nreq.user = { id: sub };',
335
+ secureExample: 'const { sub } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));\nreq.user = { id: sub };',
336
+ remediation: [
337
+ 'Replace `jwt.decode` with a verifying call: `jwtVerify` (jose), `jwt.verify` with an explicit algorithm allow-list, or your auth framework\'s session helper.',
338
+ 'Never accept identity from `x-user-id`, `x-forwarded-user` or any custom header unless a trusted proxy sets it *and* strips client-supplied values.',
339
+ 'Remove `verify: false` and `algorithms: ["none"]` — both disable signature checking by construction.',
340
+ 'Derive the identity from a session cookie or a verified signature only.',
341
+ ],
342
+ references: [
343
+ 'https://github.com/panva/jose/blob/main/docs/functions/jwt_verify.jwtVerify.md',
344
+ 'https://cwe.mitre.org/data/definitions/347.html',
345
+ 'https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/',
346
+ ],
347
+ extensions: [...JS_ROUTE_EXTENSIONS, 'php', 'py'],
348
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
349
+ detect(ctx) {
350
+ const hits = [];
351
+ const patterns = [
352
+ { regex: /\bjwt\s*\.\s*decode\s*\(/g, note: '`jwt.decode()` parses the payload without verifying the signature.' },
353
+ { regex: /\batob\s*\(\s*[^)]*split\s*\(\s*['"]\.['"]/g, note: 'Base64-decoding the JWT payload is not verification.' },
354
+ { regex: /\b(?:req|request)\.headers(?:\.|\[)\s*['"`]?x-(?:user|auth|forwarded-user|uid|user-id|account-id|tenant-id|admin)['"`]?/gi, note: 'A request header is attacker-controlled; it cannot establish identity.' },
355
+ { regex: /\bverify\s*:\s*false\b/g, note: '`verify: false` disables signature validation.' },
356
+ { regex: /algorithms\s*:\s*\[\s*['"`]none['"`]/gi, note: 'The `none` algorithm accepts unsigned tokens.' },
357
+ { regex: /\bbase64url?Decode\s*\(\s*[^)]*token/gi, note: 'Decoding a token is not verifying it.' },
358
+ ];
359
+ for (const pattern of patterns) {
360
+ for (const match of ctx.code.matchAll(pattern.regex)) {
361
+ hits.push({
362
+ offset: match.index,
363
+ matched: match[0].slice(0, 120),
364
+ message: pattern.note,
365
+ data: { sink: match[0].slice(0, 40) },
366
+ });
367
+ }
368
+ }
369
+ return hits;
370
+ },
371
+ });
372
+ /** VSB-AUTH-007 - admin/destructive surface with no role check. */
373
+ const adminWithoutRoleCheck = (0, helpers_1.defineRule)({
374
+ id: 'VSB-AUTH-007',
375
+ slug: 'admin-route-without-role-check',
376
+ title: 'Administrative or destructive endpoint has no role check',
377
+ category: 'unguarded-routes',
378
+ severity: 'high',
379
+ confidence: 'medium',
380
+ cwe: shared_1.CWES.incorrectAuthz,
381
+ description: 'An admin-only or destructive operation that authenticates the caller but never checks a role lets any signed-in user — including one who self-registered a minute ago — perform it.',
382
+ insecureExample: 'export async function POST() {\n const session = await auth();\n if (!session) return unauthorized();\n await db.users.deleteMany({});\n}',
383
+ secureExample: 'if (session.user.role !== "admin") return forbidden();',
384
+ remediation: [
385
+ 'Add an explicit role/permission assertion after authentication: `requireRole("admin")`, `if (!user.isAdmin) return forbidden()`, or a CASL/authorisation-policy check.',
386
+ 'Do not rely on hiding the UI — the endpoint is public regardless of which button renders it.',
387
+ 'Log privileged operations with the acting user id for auditability.',
388
+ ],
389
+ references: [
390
+ 'https://cwe.mitre.org/data/definitions/863.html',
391
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html',
392
+ ],
393
+ extensions: [...JS_ROUTE_EXTENSIONS, 'php', 'py', 'rb', 'go'],
394
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
395
+ detect(ctx) {
396
+ const privilegedPath = ADMIN_PATH_REGEX.test(ctx.relativePath);
397
+ const destructive = DESTRUCTIVE_CODE_REGEX.test(ctx.code);
398
+ if (!privilegedPath && !destructive)
399
+ return [];
400
+ if (!ctx.flags.mentionsAuth)
401
+ return [];
402
+ if (lexicon_1.ROLE_CHECK_REGEX.test(ctx.code))
403
+ return [];
404
+ if (!(0, shared_1.verifiesAuth)(ctx))
405
+ return [];
406
+ // Ownership checks are a different (also valid) authorisation model.
407
+ if (lexicon_1.SESSION_OWNERSHIP_REGEX.test(ctx.code) && !privilegedPath)
408
+ return [];
409
+ const offset = ctx.code.search(lexicon_1.AUTH_VERIFICATION_REGEX);
410
+ return [
411
+ (0, helpers_1.fileHit)(ctx.locate(offset === -1 ? 0 : offset).line, {
412
+ matched: privilegedPath ? 'admin route' : 'destructive operation',
413
+ message: privilegedPath
414
+ ? 'Admin surface authenticates the caller but never verifies an admin role.'
415
+ : 'Destructive operation authenticates the caller but performs no role or ownership check.',
416
+ severity: 'high',
417
+ confidence: privilegedPath ? 'high' : 'medium',
418
+ }),
419
+ ];
420
+ },
421
+ });
422
+ /** VSB-AUTH-008 - auth result fetched but discarded. */
423
+ const authUnused = (0, helpers_1.defineRule)({
424
+ id: 'VSB-AUTH-008',
425
+ slug: 'auth-fetched-but-not-enforced',
426
+ title: 'Session is fetched but the result is never enforced',
427
+ category: 'unguarded-routes',
428
+ severity: 'medium',
429
+ confidence: 'medium',
430
+ cwe: shared_1.CWES.improperAuthn,
431
+ description: '`const session = await auth();` followed by no branch on `session` is dead code that *looks* like security. Reviewers and agents both move on assuming the route is protected.',
432
+ insecureExample: 'const session = await auth();\nawait db.projects.create({ data: req.body });',
433
+ secureExample: 'const session = await auth();\nif (!session?.user) return new Response("Unauthorized", { status: 401 });',
434
+ remediation: [
435
+ 'Branch on the session: `if (!session?.user) return new Response("Unauthorized", { status: 401 })`.',
436
+ 'If the endpoint is intentionally public, delete the session lookup so nobody mistakes it for a guard.',
437
+ ],
438
+ references: ['https://cwe.mitre.org/data/definitions/287.html'],
439
+ extensions: JS_ROUTE_EXTENSIONS,
440
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
441
+ appliesTo: (ctx) => ctx.flags.isAppRouterRoute || ctx.flags.isServerActionModule || ctx.flags.isPagesApiRoute,
442
+ detect(ctx) {
443
+ const hits = [];
444
+ const assignment = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:auth|getServerSession|getSession|currentUser|getCurrentUser|requireUser|getAuth)\s*\(([^)]*)\)\s*;?/g;
445
+ for (const match of ctx.code.matchAll(assignment)) {
446
+ const name = match[1] ?? '';
447
+ if (!name)
448
+ continue;
449
+ const rest = ctx.code.slice(match.index + match[0].length);
450
+ const enforced = new RegExp(`\\b(?:if\\s*\\(\\s*!?\\s*${name}\\b|!\\s*${name}\\s*[).]|${name}\\s*(?:\\?\\.|\\.)\\s*(?:user|userId|id|session|sub)\\b|${name}\\s*&&|return\\s+${name}\\b|throw\\b[^;]{0,80}${name})`);
451
+ if (enforced.test(rest.slice(0, 2000)))
452
+ continue;
453
+ // `auth()` used purely to satisfy a redirect() helper still counts.
454
+ if (new RegExp(`redirect\\(`).test(rest.slice(0, 2000)) && new RegExp(`${name}\\s*\\?\\.`).test(rest.slice(0, 2000)))
455
+ continue;
456
+ hits.push({
457
+ offset: match.index,
458
+ matched: match[0].trim().slice(0, 120),
459
+ message: `\`${name}\` is captured from the auth helper but never checked — the route is effectively public.`,
460
+ data: { variable: name },
461
+ });
462
+ }
463
+ return hits;
464
+ },
465
+ });
466
+ /** VSB-AUTH-009 - cookie-session mutation without CSRF protection. */
467
+ const missingCsrf = (0, helpers_1.defineRule)({
468
+ id: 'VSB-AUTH-009',
469
+ slug: 'cookie-session-mutation-without-csrf',
470
+ title: 'Cookie-authenticated mutation has no CSRF protection',
471
+ category: 'unguarded-routes',
472
+ severity: 'medium',
473
+ confidence: 'low',
474
+ cwe: shared_1.CWES.csrf,
475
+ description: 'A mutation endpoint authenticated by a cookie accepts cross-site form posts by default. If the session cookie is `SameSite=None` (or the project sets none), a malicious page can trigger the mutation on behalf of a signed-in user.',
476
+ insecureExample: "app.post('/api/transfer', requireAuth, handler) // no csrf middleware",
477
+ secureExample: "app.post('/api/transfer', requireAuth, csrfProtection, handler)",
478
+ remediation: [
479
+ 'Mount CSRF middleware (`csurf`, `csrf-csrf`, `@fastify/csrf-protection`) on cookie-authenticated mutating routes.',
480
+ 'Set the session cookie to `SameSite=Lax` or `Strict`; use `SameSite=None; Secure` only when a cross-site flow truly requires it.',
481
+ 'Require a custom header (`X-Requested-With`) or a double-submit token for state-changing requests.',
482
+ 'Prefer bearer tokens in `Authorization` for machine clients — they are not sent automatically.',
483
+ ],
484
+ references: [
485
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html',
486
+ 'https://cwe.mitre.org/data/definitions/352.html',
487
+ ],
488
+ extensions: JS_ROUTE_EXTENSIONS,
489
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
490
+ appliesTo: (ctx) => ctx.flags.isServerFrameworkFile && !ctx.project.hasCsrfProtection,
491
+ detect(ctx) {
492
+ if (!/session|cookie/i.test(ctx.code))
493
+ return [];
494
+ if (/\bcsrf|xsrf|_token\s*===|sameSite\s*[:=]\s*['"`]?(?:strict|lax)/i.test(ctx.code))
495
+ return [];
496
+ const hits = [];
497
+ for (const handler of serverFrameworkHandlers(ctx)) {
498
+ if (!['post', 'put', 'patch', 'delete'].includes(handler.verb))
499
+ continue;
500
+ if (!/session\b|cookie/i.test(handler.body))
501
+ continue;
502
+ if (!lexicon_1.MUTATION_REGEX.test(handler.body))
503
+ continue;
504
+ if (/\/(?:webhook|api\/v\d|graphql|login|signin|signup|register|oauth|callback)(?:\/|['"`]|$)/i.test(handler.path))
505
+ continue;
506
+ hits.push({
507
+ offset: handler.offset,
508
+ matched: handler.body.slice(0, 120).trim(),
509
+ message: `\`${handler.verb.toUpperCase()} ${handler.path || '/'}\` mutates state using a cookie session with no CSRF token check.`,
510
+ data: { verb: handler.verb.toUpperCase(), route: handler.path },
511
+ });
512
+ }
513
+ return hits;
514
+ },
515
+ });
516
+ exports.UNGUARDED_ROUTE_RULES = [
517
+ appRouterMutationNoAuth,
518
+ pagesApiMutationNoAuth,
519
+ serverRouteNoAuth,
520
+ idorOwnershipFromRequest,
521
+ serverActionNoAuth,
522
+ unverifiedIdentity,
523
+ adminWithoutRoleCheck,
524
+ authUnused,
525
+ missingCsrf,
526
+ ];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * vibe-shield :: text primitives
3
+ * ------------------------------
4
+ * Zero-dependency helpers shared by every rule. The most important one is
5
+ * `maskComments`: it blanks out comments while preserving the exact character
6
+ * length of the file, so byte offsets (and therefore line/column numbers)
7
+ * stay identical between the raw source and the "code only" view that rules
8
+ * match against. That single trick removes the largest class of false
9
+ * positives (vulnerabilities that only exist inside a comment) without a
10
+ * real parser.
11
+ */
12
+ import type { Language } from './types';
13
+ /** Incremental offset -> line/column resolver. */
14
+ export declare class LineIndex {
15
+ private readonly content;
16
+ private readonly starts;
17
+ constructor(content: string);
18
+ get lineCount(): number;
19
+ /** 1-based line and column for a character offset. */
20
+ locate(offset: number): {
21
+ line: number;
22
+ column: number;
23
+ };
24
+ /** Offset of the first character of a 1-based line. */
25
+ lineStart(line: number): number;
26
+ }
27
+ /** Split content into lines without the trailing newline characters. */
28
+ export declare const splitLines: (content: string) => string[];
29
+ /**
30
+ * Iterate every match of `regex` (which must be global) over `content`.
31
+ * Guards against zero-length infinite loops.
32
+ */
33
+ export declare function forEachMatch(content: string, regex: RegExp, handler: (match: RegExpExecArray) => void): void;
34
+ /** All matches of a global regex, as `{ text, offset }`. */
35
+ export declare function collectMatches(content: string, regex: RegExp): Array<{
36
+ text: string;
37
+ offset: number;
38
+ match: RegExpExecArray;
39
+ }>;
40
+ /** Shannon entropy in bits per character. */
41
+ export declare function shannonEntropy(value: string): number;
42
+ /** Size of the character alphabet actually used (hex, base64, mixed-case...). */
43
+ export declare const alphabetSize: (value: string) => number;
44
+ /**
45
+ * True when a candidate secret is obviously not a real credential: template
46
+ * syntax, environment references, masked output, repeated characters or
47
+ * documentation placeholders.
48
+ */
49
+ export declare function isPlaceholderSecret(value: string): boolean;
50
+ /**
51
+ * Mask a credential for terminal/JSON/SARIF output. Keeps a short prefix and
52
+ * the final two characters so a human can identify *which* key leaked without
53
+ * re-publishing the secret in a CI log.
54
+ */
55
+ export declare function redactSecret(value: string, keepPrefix?: number): string;
56
+ /** Escape a string for safe inclusion in a RegExp. */
57
+ export declare const escapeRegExp: (value: string) => string;
58
+ /** Build a case-insensitive "any of these words" regex source fragment. */
59
+ export declare const alternation: (words: readonly string[]) => string;
60
+ /**
61
+ * Blank out comments for C-like languages (JS/TS/JSX/Java/Go/Rust/C#/PHP-ish),
62
+ * preserving string, template-literal and (best-effort) regex-literal bodies
63
+ * and the overall character length of the input.
64
+ */
65
+ export declare function maskCStyleComments(source: string): string;
66
+ /** Blank out `--` and `/* *\/` comments in SQL / Prisma. */
67
+ export declare function maskSqlComments(source: string): string;
68
+ /** Blank out `#` comments in shell / YAML / TOML / dotenv / Dockerfile. */
69
+ export declare function maskHashComments(source: string): string;
70
+ /** Blank out Python/Ruby `#` comments (strings preserved). */
71
+ export declare const maskPythonComments: typeof maskHashComments;
72
+ /**
73
+ * Pick the right comment masker for a language. Markdown, HTML and JSON are
74
+ * returned untouched on purpose: hidden instructions inside HTML comments are
75
+ * exactly what the agent-integrity rules need to see.
76
+ */
77
+ export declare function maskComments(source: string, language: Language): string;
78
+ /** Indentation-preserving trim used for snippets in reports. */
79
+ export declare function trimSnippet(line: string, max?: number): string;
80
+ /** Join a window of source lines around `line` (1-based) for context blocks. */
81
+ export declare function contextWindow(lines: string[], line: number, radius?: number): string[];
82
+ /**
83
+ * Extract a balanced `{...}` / `(...)` block starting at `openIndex`.
84
+ * Returns the slice (inclusive of both braces) or an empty string.
85
+ */
86
+ export declare function balancedBlock(source: string, openIndex: number, open?: string, close?: string): string;
87
+ /** Find the offset of the first `{` at/after `from`. */
88
+ export declare const nextBrace: (source: string, from: number) => number;
89
+ /** Naive but effective "is this a dynamic string?" probe for injection rules. */
90
+ export declare const looksDynamic: (fragment: string) => boolean;