@vibefoundry/vibe-shield 0.0.0-stage → 1.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +571 -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 +541 -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 +122 -0
  10. package/dist/src/init/scaffold.js +573 -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 +722 -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 +94 -4
@@ -0,0 +1,18 @@
1
+ /**
2
+ * vibe-shield :: client-exposure rules
3
+ * ------------------------------------
4
+ * The single most common way an AI coding agent leaks a production credential
5
+ * is *not* a typo in a test fixture - it is a perfectly reasonable-looking
6
+ * `process.env.NEXT_PUBLIC_OPENAI_API_KEY`. Next.js, Vite, Nuxt, Expo, Gatsby
7
+ * and every other bundler inline those values into the JavaScript the browser
8
+ * downloads, so the "environment variable" is really a hardcoded secret that
9
+ * happens to live in `.env`.
10
+ *
11
+ * These seven rules cover that failure mode end to end: the variable name, the
12
+ * server-only read that a client component still performs, the Supabase
13
+ * service-role client, the admin SDK imported into the browser graph, the
14
+ * "make the SDK work in the browser" escape hatch, the env file that carries a
15
+ * real key, and the `next.config.js` `env` block that re-inlines one.
16
+ */
17
+ import type { Rule } from '../types';
18
+ export declare const CLIENT_EXPOSURE_RULES: readonly Rule[];
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ /**
3
+ * vibe-shield :: client-exposure rules
4
+ * ------------------------------------
5
+ * The single most common way an AI coding agent leaks a production credential
6
+ * is *not* a typo in a test fixture - it is a perfectly reasonable-looking
7
+ * `process.env.NEXT_PUBLIC_OPENAI_API_KEY`. Next.js, Vite, Nuxt, Expo, Gatsby
8
+ * and every other bundler inline those values into the JavaScript the browser
9
+ * downloads, so the "environment variable" is really a hardcoded secret that
10
+ * happens to live in `.env`.
11
+ *
12
+ * These seven rules cover that failure mode end to end: the variable name, the
13
+ * server-only read that a client component still performs, the Supabase
14
+ * service-role client, the admin SDK imported into the browser graph, the
15
+ * "make the SDK work in the browser" escape hatch, the env file that carries a
16
+ * real key, and the `next.config.js` `env` block that re-inlines one.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.CLIENT_EXPOSURE_RULES = void 0;
20
+ const lexicon_1 = require("../lexicon");
21
+ const text_1 = require("../text");
22
+ const helpers_1 = require("./helpers");
23
+ const shared_1 = require("./shared");
24
+ const PREFIX_SRC = lexicon_1.PUBLIC_ENV_PREFIXES.map(text_1.escapeRegExp).join('|');
25
+ const SECRET_SRC = [...lexicon_1.SERVER_ONLY_SECRET_NAMES]
26
+ .slice()
27
+ .sort((a, b) => b.length - a.length)
28
+ .map(text_1.escapeRegExp)
29
+ .join('|');
30
+ /**
31
+ * Names that contain a "secret-looking" substring but are public by design.
32
+ * `NEXT_PUBLIC_FIREBASE_API_KEY` and the Supabase anon key are the two the
33
+ * ecosystem trips over most often; both are safe to ship to a browser.
34
+ */
35
+ const SAFE_PUBLIC_NAME_REGEX = new RegExp([
36
+ ...lexicon_1.PUBLIC_SAFE_NAME_SUFFIXES,
37
+ 'FIREBASE',
38
+ 'ALGOLIA',
39
+ 'MAPBOX',
40
+ 'GOOGLE_MAPS',
41
+ 'MAPS_API_KEY',
42
+ 'RECAPTCHA',
43
+ 'HCAPTCHA',
44
+ 'POSTHOG',
45
+ 'SEGMENT',
46
+ 'AMPLITUDE',
47
+ 'MIXPANEL',
48
+ 'SENTRY_DSN',
49
+ 'STRIPE_PUBLISHABLE',
50
+ 'SANITY',
51
+ 'CONTENTFUL',
52
+ 'CLOUDINARY_CLOUD_NAME',
53
+ 'TURNSTILE',
54
+ ]
55
+ .map(text_1.escapeRegExp)
56
+ .join('|'), 'i');
57
+ /** `NEXT_PUBLIC_OPENAI_API_KEY`, `VITE_STRIPE_SECRET_KEY`, ... */
58
+ const PUBLIC_SECRET_ENV_REGEX = new RegExp(`\\b(?:${PREFIX_SRC})[A-Za-z0-9_]*?(?:${SECRET_SRC})[A-Za-z0-9_]*\\b`, 'gi');
59
+ /** The build-time guard that keeps a module out of the client bundle. */
60
+ const SERVER_ONLY_GUARD_REGEX = /import\s+['"]server-only['"]|\brequire\(\s*['"]server-only['"]\s*\)/;
61
+ const hasPublicPrefix = (name) => lexicon_1.PUBLIC_ENV_PREFIXES.some((prefix) => name.toUpperCase().startsWith(prefix));
62
+ /** Names that read as secrets but are intentionally shipped to browsers. */
63
+ const PUBLIC_BY_DESIGN_REGEX = /(?:ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|SITE_KEY|CLIENT_ID|APP_ID|MEASUREMENT_ID|_URL|_URI|_HOST|_REGION)$/i;
64
+ const SERVER_ONLY_NAME_REGEX = /(?:SECRET|SERVICE_ROLE|SERVICE_KEY|PRIVATE_KEY|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|ACCESS_TOKEN|AUTH_TOKEN|JWT_SECRET|SESSION_SECRET|ENCRYPTION_KEY|DATABASE_URL|DB_URL|CONNECTION_STRING|WEBHOOK_SECRET|MNEMONIC|SIGNING_KEY)/i;
65
+ const isBrowserFile = (ctx) => ctx.flags.isClientComponent || ctx.flags.isClientPath;
66
+ /**
67
+ * Documentation shows the vulnerable shape on purpose. A rule that fires on
68
+ * README/SKILL fenced blocks punishes the project for documenting the bug it
69
+ * warns about, so examples are excluded from *code* rules.
70
+ */
71
+ const isDocumentationExample = (ctx) => ctx.language === 'markdown' || ctx.flags.isDocumentation;
72
+ /**
73
+ * Same idea for templates shipped as code: `init/scaffold.ts` holds the SKILL.md
74
+ * and AGENTS.md text, which necessarily quotes the vulnerable shapes it teaches
75
+ * the agent to fix. The file is documentation in a .ts container.
76
+ */
77
+ const isTemplateMaterial = (ctx) => /(?:^|\/)src\/(?:init\/scaffold|mcp-guard\/skill-auditor)\.ts$/.test(ctx.relativePath);
78
+ const looksLikeRealSecret = (value) => {
79
+ const trimmed = value.trim();
80
+ if (trimmed.length < 16)
81
+ return false;
82
+ if ((0, text_1.isPlaceholderSecret)(trimmed))
83
+ return false;
84
+ return (0, text_1.shannonEntropy)(trimmed) >= 3.2;
85
+ };
86
+ const ENV_LINE_REGEX = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
87
+ /** VSB-CLIENT-001 - a server-only secret behind a public env prefix. */
88
+ const publicEnvSecret = (0, helpers_1.defineRule)({
89
+ id: 'VSB-CLIENT-001',
90
+ slug: 'public-env-secret-exposure',
91
+ title: 'Server-only secret exposed through a public environment prefix',
92
+ category: 'client-exposure',
93
+ severity: 'critical',
94
+ confidence: 'high',
95
+ cwe: shared_1.CWES.exposureOfSecrets,
96
+ description: 'Variables prefixed with NEXT_PUBLIC_ / VITE_ / PUBLIC_ / REACT_APP_ / EXPO_PUBLIC_ are string-replaced into the client bundle at build time. Naming a service-role key, API secret or database URL that way ships the credential to every visitor.',
97
+ insecureExample: 'const key = process.env.NEXT_PUBLIC_OPENAI_API_KEY;',
98
+ secureExample: '// server-only: app/api/chat/route.ts\nconst key = process.env.OPENAI_API_KEY;',
99
+ remediation: [
100
+ 'Rename the variable so it is server-only (drop the NEXT_PUBLIC_/VITE_/PUBLIC_ prefix) in `.env`, `.env.local` and every deployment environment.',
101
+ 'Move every read of it into a server module: App Router route handler, Server Action, `getServerSideProps`, or an API route. Never import that module from a `"use client"` file.',
102
+ 'Rotate the credential. Anything that ever shipped in a bundle must be treated as public - deleting the line does not un-leak it from previous deploys.',
103
+ 'Expose only the value the browser genuinely needs (public URL, publishable key) under a name that ends in `_URL`, `_ANON_KEY` or `_PUBLISHABLE_KEY`.',
104
+ ],
105
+ references: [
106
+ 'https://nextjs.org/docs/app/building-your-application/configuring/environment-variables',
107
+ 'https://vitejs.dev/guide/env-and-mode#env-variables',
108
+ 'https://supabase.com/docs/guides/api/api-keys',
109
+ ],
110
+ pathPatterns: [/\.(?:[cm]?[jt]sx?|vue|svelte|json|ya?ml|toml|env|ex|exs|tf|[cm]?js)$|(?:^|\/)\.env/i],
111
+ appliesTo: (ctx) => !isDocumentationExample(ctx) && !isTemplateMaterial(ctx),
112
+ detect(ctx) {
113
+ const hits = [];
114
+ for (const match of ctx.code.matchAll(PUBLIC_SECRET_ENV_REGEX)) {
115
+ const name = match[0];
116
+ if (SAFE_PUBLIC_NAME_REGEX.test(name))
117
+ continue;
118
+ if (!SERVER_ONLY_NAME_REGEX.test(name.toUpperCase().replace(PREFIX_SRC.toUpperCase(), '')))
119
+ continue;
120
+ hits.push({
121
+ offset: match.index,
122
+ matched: name,
123
+ severity: 'critical',
124
+ confidence: 'high',
125
+ message: `\`${name}\` is inlined into the client bundle; anyone can read it from devtools.`,
126
+ data: { variable: name },
127
+ });
128
+ }
129
+ return hits;
130
+ },
131
+ });
132
+ /** VSB-CLIENT-002 - server-only env read inside a client component. */
133
+ const clientProcessEnv = (0, helpers_1.defineRule)({
134
+ id: 'VSB-CLIENT-002',
135
+ slug: 'server-env-in-client-component',
136
+ title: 'Client component reads a server-only environment variable',
137
+ category: 'client-exposure',
138
+ severity: 'high',
139
+ confidence: 'high',
140
+ cwe: shared_1.CWES.exposureOfSecrets,
141
+ description: 'A `"use client"` module can only see variables the bundler inlined. Reading `process.env.SOME_SECRET` there either returns `undefined` in production (a broken feature) or, in setups that inline all env vars, exposes the secret to the browser.',
142
+ insecureExample: 'const stripeKey = process.env.STRIPE_SECRET_KEY;',
143
+ secureExample: 'const { publishedKey } = await fetch("/api/stripe/config").then((r) => r.json());',
144
+ remediation: [
145
+ 'Delete the client-side read and fetch the value you actually need from a server endpoint that authenticates and authorises the caller.',
146
+ 'If the value is genuinely public (publishable key, project URL), expose it under a `NEXT_PUBLIC_*` name instead of reading a server variable.',
147
+ 'Keep `STRIPE_SECRET_KEY`, `OPENAI_API_KEY`, `DATABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` and friends in server modules only.',
148
+ ],
149
+ references: [
150
+ 'https://nextjs.org/docs/app/building-your-application/rendering/client-components',
151
+ 'https://react.dev/reference/rsc/use-client',
152
+ ],
153
+ pathPatterns: [/\.(?:[cm]?[jt]sx?|vue|svelte)$/i],
154
+ skipFlags: ['isTestFile', 'isDocumentation', 'isGenerated', 'isLockfile'],
155
+ appliesTo: (ctx) => isBrowserFile(ctx) && !isDocumentationExample(ctx) && !isTemplateMaterial(ctx),
156
+ detect(ctx) {
157
+ const hits = [];
158
+ for (const match of ctx.code.matchAll(/\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
159
+ const name = match[1] ?? '';
160
+ if (!name || name === 'NODE_ENV')
161
+ continue;
162
+ if (hasPublicPrefix(name))
163
+ continue;
164
+ if (!SERVER_ONLY_NAME_REGEX.test(name))
165
+ continue;
166
+ if (PUBLIC_BY_DESIGN_REGEX.test(name))
167
+ continue;
168
+ hits.push({
169
+ offset: match.index,
170
+ matched: match[0],
171
+ message: `Client component reads \`${name}\`, which only exists on the server — it is \`undefined\` in the browser (or leaked if your bundler inlines it).`,
172
+ data: { variable: name },
173
+ });
174
+ }
175
+ return hits;
176
+ },
177
+ });
178
+ /** VSB-CLIENT-003 - Supabase client constructed with the service-role key. */
179
+ const supabaseServiceRoleClient = (0, helpers_1.defineRule)({
180
+ id: 'VSB-CLIENT-003',
181
+ slug: 'supabase-client-with-service-role-key',
182
+ title: 'Supabase client created with the service-role key',
183
+ category: 'client-exposure',
184
+ severity: 'critical',
185
+ confidence: 'high',
186
+ cwe: shared_1.CWES.privilegeEscalation,
187
+ description: 'A Supabase client created with `SUPABASE_SERVICE_ROLE_KEY` bypasses Row Level Security entirely. If the module can reach the browser bundle, an attacker can read and write every row in the database.',
188
+ insecureExample: 'createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!)',
189
+ secureExample: 'createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)',
190
+ remediation: [
191
+ 'Create the privileged client only inside server code (`app/**/route.ts`, Server Actions, cron jobs) in a module that is never imported by a client component.',
192
+ 'For anything that runs in the browser, use the anon/publishable key and rely on RLS + policies for authorisation.',
193
+ 'Add `import "server-only";` to the top of the admin-client module so the build fails if it is ever pulled into the client graph.',
194
+ 'Rotate the service-role key if this file was ever shipped.',
195
+ ],
196
+ references: [
197
+ 'https://supabase.com/docs/guides/api/api-keys',
198
+ 'https://supabase.com/docs/guides/auth/server-side/creating-a-client',
199
+ ],
200
+ pathPatterns: [/\.(?:[cm]?[jt]sx?|vue|svelte)$/i],
201
+ skipFlags: ['isTestFile', 'isDocumentation', 'isGenerated'],
202
+ detect(ctx) {
203
+ return (0, helpers_1.regexHits)(ctx.code, /\b(?:createClient|createBrowserClient|createServerClient|createClientComponentClient|createRouteHandlerClient|createServerActionClient)\s*\(([^)]{0,400})\)/g, (match) => {
204
+ const args = match[1] ?? '';
205
+ if (!/(?:SERVICE_ROLE_KEY|SUPABASE_SERVICE_ROLE|SERVICE_ROLE_KEY|service_role|serviceRoleKey|SERVICE_KEY)/.test(args))
206
+ return null;
207
+ // `import "server-only"` is the framework's own guard: the build fails if
208
+ // this module is ever pulled into a client component.
209
+ if (SERVER_ONLY_GUARD_REGEX.test(ctx.code))
210
+ return null;
211
+ return {
212
+ matched: match[0].slice(0, 160),
213
+ severity: isBrowserFile(ctx) ? 'critical' : 'critical',
214
+ confidence: 'high',
215
+ message: isBrowserFile(ctx)
216
+ ? 'This file reaches the browser bundle, and the client is built with the RLS-bypassing service-role key.'
217
+ : 'Service-role clients must stay in server-only modules — confirm this file is never imported by a `"use client"` component.',
218
+ data: { privilegedClient: true },
219
+ };
220
+ });
221
+ },
222
+ });
223
+ /** VSB-CLIENT-004 - admin client imported into browser code. */
224
+ const adminClientInClient = (0, helpers_1.defineRule)({
225
+ id: 'VSB-CLIENT-004',
226
+ slug: 'admin-client-in-client-code',
227
+ title: 'Admin/service-role client used in browser code',
228
+ category: 'client-exposure',
229
+ severity: 'critical',
230
+ confidence: 'medium',
231
+ cwe: shared_1.CWES.privilegeEscalation,
232
+ description: '`supabaseAdmin`, `adminClient`, `createServiceRoleClient` and friends execute with elevated privileges. Referencing one from a component, hook or page that ships to the browser hands the attacker the keys to the database.',
233
+ insecureExample: 'const { data } = await supabaseAdmin.from("users").select("*");',
234
+ secureExample: 'const { data } = await supabase.from("users").select("*"); // RLS applies',
235
+ remediation: [
236
+ 'Move the privileged call into a server route/action and return only the fields the page needs.',
237
+ 'Import the admin client from a module guarded with `import "server-only";`.',
238
+ 'Audit the import graph of this file — an admin module imported "just for types" is still bundled unless it is `import type`.',
239
+ ],
240
+ references: ['https://supabase.com/docs/guides/auth/server-side/creating-a-client'],
241
+ pathPatterns: [/\.(?:[cm]?[jt]sx?|vue|svelte)$/i],
242
+ skipFlags: ['isTestFile', 'isDocumentation', 'isGenerated'],
243
+ appliesTo: (ctx) => isBrowserFile(ctx) && !SERVER_ONLY_GUARD_REGEX.test(ctx.code),
244
+ detect(ctx) {
245
+ return (0, helpers_1.regexHits)(ctx.code, /\b(supabaseAdmin|adminClient|serviceRoleClient|serviceRoleSb|createAdminClient|createServiceRoleClient|getServiceRoleClient|getAdminClient|supabaseServiceRole)\b/g, (match) => ({
246
+ matched: match[0],
247
+ message: `\`${match[1]}\` is a privileged client referenced from browser code.`,
248
+ data: { identifier: match[1] },
249
+ }));
250
+ },
251
+ });
252
+ /** VSB-CLIENT-005 - `dangerouslyAllowBrowser` / provider SDK keyed in the browser. */
253
+ const browserSdkKey = (0, helpers_1.defineRule)({
254
+ id: 'VSB-CLIENT-005',
255
+ slug: 'provider-sdk-keyed-in-browser',
256
+ title: 'LLM/payment SDK instantiated with a secret key in browser code',
257
+ category: 'client-exposure',
258
+ severity: 'high',
259
+ confidence: 'high',
260
+ cwe: shared_1.CWES.dangerousFunction,
261
+ description: '`dangerouslyAllowBrowser: true` disables the SDK guard that stops API keys reaching the browser. It is almost always paired with a long-lived secret key that the visitor can extract in one line of devtools JavaScript.',
262
+ insecureExample: 'new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, dangerouslyAllowBrowser: true })',
263
+ secureExample: 'await fetch("/api/chat", { method: "POST", body }); // key stays on the server',
264
+ remediation: [
265
+ 'Remove `dangerouslyAllowBrowser: true` and call your own authenticated server endpoint instead.',
266
+ 'Proxy the provider request from a route handler that enforces session auth, rate limiting and a per-user quota.',
267
+ 'Rotate the key — it was readable by anyone with the page open.',
268
+ ],
269
+ references: [
270
+ 'https://github.com/openai/openai-node#browser-support',
271
+ 'https://platform.openai.com/docs/guides/production-best-practices/api-keys',
272
+ ],
273
+ pathPatterns: [/\.(?:[cm]?[jt]sx?|vue|svelte)$/i],
274
+ skipFlags: ['isTestFile', 'isDocumentation', 'isGenerated'],
275
+ detect(ctx) {
276
+ const hits = [];
277
+ for (const match of ctx.code.matchAll(/\bdangerouslyAllowBrowser\s*:\s*(true|!0)\b/g)) {
278
+ hits.push({
279
+ offset: match.index,
280
+ matched: 'dangerouslyAllowBrowser: true',
281
+ message: 'Provider SDK key is exposed to the browser because `dangerouslyAllowBrowser` is enabled.',
282
+ data: { sink: 'dangerouslyAllowBrowser' },
283
+ });
284
+ }
285
+ for (const match of ctx.code.matchAll(/\bnew\s+(?:OpenAI|Anthropic|Groq|MistralClient|CohereClient|GoogleGenerativeAI)\s*\(/g)) {
286
+ if (!isBrowserFile(ctx))
287
+ continue;
288
+ const body = (0, text_1.balancedBlock)(ctx.code, ctx.code.indexOf('(', match.index), '(', ')');
289
+ if (!/apiKey\s*:/.test(body))
290
+ continue;
291
+ hits.push({
292
+ offset: match.index,
293
+ matched: match[0],
294
+ message: 'A provider SDK is constructed with an API key inside browser code.',
295
+ data: { sink: 'provider-sdk' },
296
+ });
297
+ }
298
+ return hits;
299
+ },
300
+ });
301
+ /** VSB-CLIENT-006 - real secret stored behind a public prefix in an env file. */
302
+ const envFilePublicSecret = (0, helpers_1.defineRule)({
303
+ id: 'VSB-CLIENT-006',
304
+ slug: 'env-file-public-secret-value',
305
+ title: 'Env file assigns a real secret to a public variable',
306
+ category: 'client-exposure',
307
+ severity: 'critical',
308
+ confidence: 'high',
309
+ cwe: shared_1.CWES.credentialLeak,
310
+ description: 'A `.env*` file declares a browser-visible variable whose value looks like a live credential. Everything behind a public prefix is published to every visitor at build time, so this is equivalent to committing the key to the README.',
311
+ insecureExample: 'NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
312
+ secureExample: 'SUPABASE_SERVICE_ROLE_KEY=<server-only value>\nNEXT_PUBLIC_SUPABASE_URL=https://project.supabase.co',
313
+ remediation: [
314
+ 'Rename the variable to drop the public prefix, or delete it if the browser does not need it.',
315
+ 'Rotate the credential and purge it from build logs, CDN caches and any artifact that already shipped.',
316
+ 'Add a unit test or CI check that fails when a public env var name matches `SECRET|SERVICE_ROLE|PASSWORD|PRIVATE`.',
317
+ ],
318
+ references: [
319
+ 'https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables',
320
+ 'https://cwe.mitre.org/data/definitions/540.html',
321
+ ],
322
+ appliesTo: (ctx) => ctx.flags.isEnvFile,
323
+ detect(ctx) {
324
+ if (/(?:example|sample|template|dist|schema)$/i.test(ctx.fileName))
325
+ return [];
326
+ const hits = [];
327
+ ctx.lines.forEach((line, index) => {
328
+ const match = ENV_LINE_REGEX.exec(line);
329
+ if (!match)
330
+ return;
331
+ const [, name = '', rawValue = ''] = match;
332
+ if (!hasPublicPrefix(name))
333
+ return;
334
+ if (SAFE_PUBLIC_NAME_REGEX.test(name))
335
+ return;
336
+ if (!SERVER_ONLY_NAME_REGEX.test(name.toUpperCase().slice(name.indexOf('_') + 1)))
337
+ return;
338
+ const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
339
+ if (!looksLikeRealSecret(value))
340
+ return;
341
+ hits.push((0, helpers_1.fileHit)(index + 1, {
342
+ matched: name,
343
+ severity: 'critical',
344
+ confidence: 'high',
345
+ message: `\`${name}\` is published to the browser and is set to what looks like a live credential.`,
346
+ data: { variable: name },
347
+ }));
348
+ });
349
+ return hits;
350
+ },
351
+ });
352
+ /** VSB-CLIENT-007 - secret literal inlined through a bundler config. */
353
+ const bundlerConfigInline = (0, helpers_1.defineRule)({
354
+ id: 'VSB-CLIENT-007',
355
+ slug: 'bundler-config-inlines-secret',
356
+ title: 'Build config inlines a secret into the client bundle',
357
+ category: 'client-exposure',
358
+ severity: 'critical',
359
+ confidence: 'high',
360
+ cwe: shared_1.CWES.hardcodedCredentials,
361
+ description: '`next.config.js` `env`, `publicRuntimeConfig` and Vite `define` copy their values into the browser bundle verbatim, regardless of the variable name. A literal key here is a hardcoded credential in every build artifact.',
362
+ insecureExample: "module.exports = { env: { STRIPE_SECRET_KEY: 'sk_live_51...' } };",
363
+ secureExample: "module.exports = {}; // secrets are read at runtime from process.env on the server",
364
+ remediation: [
365
+ 'Remove the secret from the config file and read it at runtime inside a server module.',
366
+ 'If a value must reach the browser, expose the *publishable* counterpart only.',
367
+ 'Rotate the key: the previous build artifacts keep shipping it forever.',
368
+ ],
369
+ references: [
370
+ 'https://nextjs.org/docs/pages/api-reference/next-config-js/env',
371
+ 'https://nextjs.org/docs/pages/api-reference/next-config-js/runtime-configuration',
372
+ 'https://vitejs.dev/config/shared-options#define',
373
+ ],
374
+ pathPatterns: [/(?:^|\/)(?:next|nuxt|vite|astro|svelte|webpack|rollup|rsbuild|rslib|expo)\.config\.[cm]?[jt]s$/i],
375
+ detect(ctx) {
376
+ const hits = [];
377
+ const blocks = [];
378
+ for (const marker of [/env\s*:\s*\{/g, /publicRuntimeConfig\s*:\s*\{/g, /define\s*:\s*\{/g]) {
379
+ for (const match of ctx.code.matchAll(marker)) {
380
+ const open = ctx.code.indexOf('{', match.index);
381
+ blocks.push({ start: match.index, source: (0, text_1.balancedBlock)(ctx.code, open, '{', '}') });
382
+ }
383
+ }
384
+ for (const block of blocks) {
385
+ for (const entry of block.source.matchAll(/['"`]?([A-Za-z_][A-Za-z0-9_.]*)['"`]?\s*:\s*(['"`])([^'"`\n]{8,})(\2)/g)) {
386
+ const [, name = '', , value = ''] = entry;
387
+ const serverOnlyName = SERVER_ONLY_NAME_REGEX.test(name.toUpperCase());
388
+ const looksLive = looksLikeRealSecret(value) || /^(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/.test(value) || /^gh[pousr]_[A-Za-z0-9]{20,}/.test(value);
389
+ if (!serverOnlyName && !looksLive)
390
+ continue;
391
+ hits.push({
392
+ offset: block.start,
393
+ matched: `${name}: '${value.slice(0, 6)}…'`,
394
+ message: `\`${name}\` is a hardcoded credential inlined into the client bundle by the build config.`,
395
+ data: { variable: name },
396
+ });
397
+ }
398
+ }
399
+ return hits;
400
+ },
401
+ });
402
+ exports.CLIENT_EXPOSURE_RULES = [
403
+ publicEnvSecret,
404
+ clientProcessEnv,
405
+ supabaseServiceRoleClient,
406
+ adminClientInClient,
407
+ browserSdkKey,
408
+ envFilePublicSecret,
409
+ bundlerConfigInline,
410
+ ];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * vibe-shield :: database-exposure rules
3
+ * --------------------------------------
4
+ * Supabase + Postgres is where AI-generated code gets *quietly* dangerous. The
5
+ * `CREATE TABLE` an agent writes is perfect; the `ALTER TABLE ... ENABLE ROW
6
+ * LEVEL SECURITY` that should follow it simply never gets written, and with the
7
+ * anon key in every browser the table is world-readable through PostgREST.
8
+ *
9
+ * The RLS rules live here rather than in a linter because the mistake spans
10
+ * files: a table created in migration #1 and secured in migration #7 is fine,
11
+ * and only a project-wide index can tell the difference. `detectProject()`
12
+ * builds that index (`ProjectContext.sqlTables`).
13
+ */
14
+ import type { Rule } from '../types';
15
+ export declare const DATABASE_EXPOSURE_RULES: readonly Rule[];