diff --git a/CHANGELOG.d/semantic-fixed-example-database-names.md b/CHANGELOG.d/semantic-fixed-example-database-names.md new file mode 100644 index 00000000..65efbb21 --- /dev/null +++ b/CHANGELOG.d/semantic-fixed-example-database-names.md @@ -0,0 +1,5 @@ +### Documentation + +- Updated the fixed and intentionally vulnerable Next.js/Supabase security examples so organization-owned project schema and internal identifiers use bounded-context-specific multiword names (`project_records`, `project_id`, `owner_user_id`, `project_name`, `project_payload`) while vendor-owned NextAuth/Supabase/Zod identifiers remain at their adapter boundaries. +- Preserved the vulnerable fixture's intentional security failures while decoupling them from ambiguous organization-owned naming, so readers do not learn generic database names as part of the vulnerability demonstration. +- Added regression coverage that prevents either example from reintroducing the previous generic project table and column names, protects the fixed sample's existing API response/path contract, and keeps protected-request authentication/validation ordering explicit. diff --git a/examples/fixed-vibe-app/README.md b/examples/fixed-vibe-app/README.md index 5588d2c9..5825a515 100644 --- a/examples/fixed-vibe-app/README.md +++ b/examples/fixed-vibe-app/README.md @@ -4,48 +4,64 @@ See `../vulnerable-vibe-app/README.md` for the list of vulnerabilities that were fixed. +The fixed example also follows the ContextualWisdomLab naming contract: organization-owned database objects and internal identifiers use bounded-context-specific multiword names. Vendor-owned fields such as NextAuth `session.user.id`, Zod parse-result `data`, and Supabase `auth.users(id)` remain unchanged at their adapter boundaries. + --- ## Security Fixes Applied ### 1. Ownership Check Added (IDOR Fixed) -`app/api/projects/[id]/route.ts` +`app/api/projects/[projectId]/route.ts` ```typescript -// ✅ SECURE: Authentication + ownership verification +// ✅ SECURE: Authentication + validated project identifier + ownership verification import { auth } from '@/auth'; import { createClient } from '@/lib/supabase/server'; +import { z } from 'zod'; + +const projectIdSchema = z.string().uuid(); export async function GET( - req: Request, - { params }: { params: { id: string } } + httpRequest: Request, + { params }: { params: { projectId: string } } ) { // Step 1: Check authentication - const session = await auth(); - if (!session) { + const authSession = await auth(); + if (!authSession) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - const supabase = createClient(); + // Step 2: Validate route input before using it in a database predicate. + const projectIdResult = projectIdSchema.safeParse(params.projectId); + if (!projectIdResult.success) { + return Response.json({ error: 'Invalid project identifier' }, { status: 400 }); + } + const validatedProjectId = projectIdResult.data; + + const supabaseClient = createClient(); - // Step 2: Fetch the project - const { data: project, error } = await supabase - .from('projects') + // Step 3: Fetch the project + const { data: projectRecord, error: projectQueryError } = await supabaseClient + .from('project_records') .select('*') - .eq('id', params.id) + .eq('project_id', validatedProjectId) .single(); - // Step 3: Verify ownership - if (error || !project || project.user_id !== session.user.id) { + // Step 4: Verify ownership. `authSession.user.id` is NextAuth-owned. + if ( + projectQueryError || + !projectRecord || + projectRecord.owner_user_id !== authSession.user.id + ) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } - return Response.json(project); + return Response.json(projectRecord); } ``` -**Fix:** Authentication check + server-side ownership verification. Returns 403 (not 404) on ownership violation. +**Fix:** Authentication check + server-side UUID validation + ownership verification. Returns 403 (not 404) on ownership violation. --- @@ -59,7 +75,7 @@ import 'server-only'; // prevents import in client components import { createClient } from '@supabase/supabase-js'; // This file can only be imported by server-side code -export const supabaseAdmin = createClient( +export const supabaseAdminClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! // no NEXT_PUBLIC_ prefix! ); @@ -71,7 +87,7 @@ export const supabaseAdmin = createClient( // ✅ SECURE: Client uses anon key only import { createBrowserClient } from '@supabase/ssr'; -export function createClient() { +export function createSupabaseBrowserClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // anon key only @@ -89,29 +105,29 @@ export function createClient() { // ✅ SECURE: Signature verified before processing import Stripe from 'stripe'; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!); -export async function POST(req: Request) { - const rawBody = await req.text(); // raw body for signature verification - const sig = req.headers.get('stripe-signature')!; +export async function POST(httpRequest: Request) { + const rawRequestBody = await httpRequest.text(); // raw body for signature verification + const stripeSignature = httpRequest.headers.get('stripe-signature')!; - let event: Stripe.Event; + let stripeEvent: Stripe.Event; try { - event = stripe.webhooks.constructEvent( - rawBody, - sig, + stripeEvent = stripeClient.webhooks.constructEvent( + rawRequestBody, + stripeSignature, process.env.STRIPE_WEBHOOK_SECRET! ); - } catch (err) { - console.error('Webhook signature verification failed:', err); + } catch (signatureError) { + console.error('Webhook signature verification failed:', signatureError); return Response.json({ error: 'Invalid signature' }, { status: 400 }); } - if (event.type === 'checkout.session.completed') { - const session = event.data.object as Stripe.Checkout.Session; - await db.user.update({ - where: { stripeCustomerId: session.customer as string }, - data: { plan: 'pro' }, + if (stripeEvent.type === 'checkout.session.completed') { + const checkoutSession = stripeEvent.data.object as Stripe.Checkout.Session; + await applicationDatabase.userAccount.update({ + where: { stripeCustomerId: checkoutSession.customer as string }, + data: { subscriptionPlan: 'pro' }, }); } @@ -129,8 +145,16 @@ export async function POST(req: Request) { // ✅ SECURE: Price ID comes from environment, not the client import { auth } from '@/auth'; import Stripe from 'stripe'; +import { z } from 'zod'; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!); + +// Public request key `plan` is preserved; the validated internal name is `selectedPlan`. +const checkoutRequestSchema = z + .object({ + plan: z.enum(['pro_monthly', 'pro_annual']), + }) + .strict(); // Price IDs are defined server-side only const PRICE_IDS = { @@ -138,28 +162,32 @@ const PRICE_IDS = { pro_annual: process.env.STRIPE_PRICE_ID_PRO_ANNUAL!, } as const; -export async function POST(req: Request) { - const session = await auth(); - if (!session) { +export async function POST(httpRequest: Request) { + const authSession = await auth(); + if (!authSession) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - const { plan } = await req.json(); + const checkoutRequestResult = checkoutRequestSchema.safeParse( + await httpRequest.json().catch(() => null) + ); + if (!checkoutRequestResult.success) { + return Response.json({ error: 'Invalid checkout request' }, { status: 400 }); + } + const selectedPlan = checkoutRequestResult.data.plan; // Look up the price server-side — never from client - const priceId = PRICE_IDS[plan as keyof typeof PRICE_IDS]; - if (!priceId) { - return Response.json({ error: 'Invalid plan' }, { status: 400 }); - } + const stripePriceId = PRICE_IDS[selectedPlan]; - const checkoutSession = await stripe.checkout.sessions.create({ - customer_email: session.user.email!, - line_items: [{ price: priceId, quantity: 1 }], + const checkoutSession = await stripeClient.checkout.sessions.create({ + customer_email: authSession.user.email!, + line_items: [{ price: stripePriceId, quantity: 1 }], mode: 'subscription', success_url: `${process.env.NEXT_PUBLIC_URL}/success`, cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, }); + // Preserve the sample's established public response key. return Response.json({ url: checkoutSession.url }); } ``` @@ -184,7 +212,7 @@ DATABASE_URL=******host:5432/dbname ```typescript // ✅ SECURE: URL from environment variable -const db = new PrismaClient(); // uses DATABASE_URL from process.env automatically +const applicationDatabase = new PrismaClient(); // uses DATABASE_URL from process.env automatically ``` --- @@ -194,39 +222,42 @@ const db = new PrismaClient(); // uses DATABASE_URL from process.env automatical `supabase/migrations/001_initial.sql` ```sql --- ✅ SECURE: RLS enabled with proper policies -CREATE TABLE projects ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id) NOT NULL, - name TEXT NOT NULL, - data JSONB +-- ✅ SECURE: RLS enabled with proper policies and semantic owned names. +-- `auth.users(id)` is Supabase-owned and intentionally remains unchanged. +CREATE TABLE project_records ( + project_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID REFERENCES auth.users(id) NOT NULL, + project_name TEXT NOT NULL, + project_payload JSONB ); -- Enable Row Level Security -ALTER TABLE projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE project_records ENABLE ROW LEVEL SECURITY; -- Users can only see their own projects -CREATE POLICY "Users can view own projects" - ON projects FOR SELECT - USING (auth.uid() = user_id); +CREATE POLICY "Users can view own project records" + ON project_records FOR SELECT + USING (auth.uid() = owner_user_id); -- Users can only create projects owned by themselves -CREATE POLICY "Users can create own projects" - ON projects FOR INSERT - WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users can create own project records" + ON project_records FOR INSERT + WITH CHECK (auth.uid() = owner_user_id); -- Users can only update their own projects -CREATE POLICY "Users can update own projects" - ON projects FOR UPDATE - USING (auth.uid() = user_id) - WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users can update own project records" + ON project_records FOR UPDATE + USING (auth.uid() = owner_user_id) + WITH CHECK (auth.uid() = owner_user_id); -- Users can only delete their own projects -CREATE POLICY "Users can delete own projects" - ON projects FOR DELETE - USING (auth.uid() = user_id); +CREATE POLICY "Users can delete own project records" + ON project_records FOR DELETE + USING (auth.uid() = owner_user_id); ``` +The example is a fresh illustrative schema rather than a migration of an existing deployment. A real application that already has `projects(id, user_id, name, data)` must use an explicit forward migration and compatibility plan; renaming live PostgreSQL objects in place without tracing foreign keys, indexes, ORM mappings, RLS policies, UPSERT paths, locks, rollback, and deployed consumers is not safe. + --- ### 7. Admin Auth Check Added @@ -234,33 +265,43 @@ CREATE POLICY "Users can delete own projects" `app/api/admin/users/route.ts` ```typescript -// ✅ SECURE: Admin role verified server-side +// ✅ SECURE: Admin role verified server-side and unsupported query input rejected import { auth } from '@/auth'; -export async function GET(req: Request) { - const session = await auth(); - - // Step 1: Require authentication - if (!session) { +export async function GET(httpRequest: Request) { + // Step 1: Authenticate before processing any untrusted request input. + const authSession = await auth(); + if (!authSession) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - // Step 2: Require admin role (from database, not just session claim) - const user = await db.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, + // Step 2: This listing accepts no query parameters; fail closed on unexpected input. + const adminRequestUrl = new URL(httpRequest.url); + if ([...adminRequestUrl.searchParams.keys()].length > 0) { + return Response.json({ error: 'Unexpected query parameters' }, { status: 400 }); + } + + // Step 3: Require admin role (from database, not just session claim) + const userAccount = await applicationDatabase.userAccount.findUnique({ + where: { userAccountId: authSession.user.id }, + select: { accountRole: true }, }); - if (user?.role !== 'admin') { + if (userAccount?.accountRole !== 'admin') { return Response.json({ error: 'Forbidden' }, { status: 403 }); } - const users = await db.user.findMany({ - select: { id: true, email: true, createdAt: true, plan: true }, + const userAccounts = await applicationDatabase.userAccount.findMany({ + select: { + userAccountId: true, + emailAddress: true, + createdAt: true, + subscriptionPlan: true, + }, // Never include passwords, secrets, or sensitive fields }); - return Response.json(users); + return Response.json(userAccounts); } ``` @@ -268,25 +309,35 @@ export async function GET(req: Request) { ## Security Tests -Each fixed endpoint has corresponding tests: +Each fixed endpoint has corresponding tests. Route fixtures use UUID-shaped project identifiers so identifier validation and ownership behavior are tested independently: ```typescript -describe('GET /api/projects/[id]', () => { +describe('GET /api/projects/[projectId]', () => { + const ownerUserId = '11111111-1111-4111-8111-111111111111'; + const ownerProjectId = '33333333-3333-4333-8333-333333333333'; + const otherProjectId = '44444444-4444-4444-8444-444444444444'; + it('returns 401 when unauthenticated', async () => { - const res = await GET(request('/api/projects/test-id')); - expect(res.status).toBe(401); + const httpResponse = await GET(request(`/api/projects/${ownerProjectId}`)); + expect(httpResponse.status).toBe(401); + }); + + it('returns 400 for an invalid project identifier', async () => { + mockSession({ user: { id: ownerUserId } }); + const httpResponse = await GET(request('/api/projects/not-a-uuid')); + expect(httpResponse.status).toBe(400); }); it('returns 403 when accessing another user\\'s project', async () => { - mockSession({ user: { id: 'user-a' } }); - const res = await GET(request('/api/projects/user-b-project-id')); - expect(res.status).toBe(403); + mockSession({ user: { id: ownerUserId } }); + const httpResponse = await GET(request(`/api/projects/${otherProjectId}`)); + expect(httpResponse.status).toBe(403); }); it('returns 200 for the project owner', async () => { - mockSession({ user: { id: 'user-a' } }); - const res = await GET(request('/api/projects/user-a-project-id')); - expect(res.status).toBe(200); + mockSession({ user: { id: ownerUserId } }); + const httpResponse = await GET(request(`/api/projects/${ownerProjectId}`)); + expect(httpResponse.status).toBe(200); }); }); ``` diff --git a/examples/vulnerable-vibe-app/README.md b/examples/vulnerable-vibe-app/README.md index 0a0164f3..68d74c75 100644 --- a/examples/vulnerable-vibe-app/README.md +++ b/examples/vulnerable-vibe-app/README.md @@ -3,8 +3,8 @@ **This is a demonstration of insecure code patterns for educational purposes.** **DO NOT deploy this to a public URL. DO NOT use real credentials.** -This example shows common security mistakes in AI-generated Next.js + Supabase apps. -See `../fixed-vibe-app/` for the corrected version. +This example shows common security mistakes in AI-generated Next.js + Supabase apps. The security flaws are intentional; ambiguous ContextualWisdomLab-owned names are not. Organization-owned identifiers use bounded-context-specific names while vendor-owned fields such as Supabase `auth.users(id)` and response `data`/`error` remain at their adapter boundaries. +See `../fixed-vibe-app/` for the corrected security version. --- @@ -12,28 +12,30 @@ See `../fixed-vibe-app/` for the corrected version. ### 1. Missing Ownership Check (IDOR) -`app/api/projects/[id]/route.ts` +`app/api/projects/[projectId]/route.ts` ```typescript // ❌ VULNERABLE: No ownership check — any user can access any project export async function GET( - req: Request, - { params }: { params: { id: string } } + httpRequest: Request, + { params }: { params: { projectId: string } } ) { - const supabase = createClient( + const supabaseClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); // No authentication check! // No ownership verification! - const { data, error } = await supabase - .from('projects') + const { data: projectRecord, error: projectQueryError } = await supabaseClient + .from('project_records') .select('*') - .eq('id', params.id) + .eq('project_id', params.projectId) .single(); - return Response.json(data); + // The ignored error and missing authorization are intentionally vulnerable. + void projectQueryError; + return Response.json(projectRecord); } ``` @@ -49,7 +51,7 @@ export async function GET( // ❌ VULNERABLE: Service role key used in a file that can be imported by client components import { createClient } from '@supabase/supabase-js'; -export const supabaseAdmin = createClient( +export const supabaseAdminClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // exposed in browser bundle! ); @@ -65,15 +67,15 @@ export const supabaseAdmin = createClient( ```typescript // ❌ VULNERABLE: No signature verification -export async function POST(req: Request) { - const event = await req.json(); // trusting the body directly! +export async function POST(httpRequest: Request) { + const unverifiedStripeEvent = await httpRequest.json(); // trusting the body directly! - if (event.type === 'checkout.session.completed') { + if (unverifiedStripeEvent.type === 'checkout.session.completed') { // Attacker can POST a fake 'checkout.session.completed' event // and get any account upgraded to Pro for free - await db.user.update({ - where: { stripeCustomerId: event.data.object.customer }, - data: { plan: 'pro' }, + await applicationDatabase.userAccount.update({ + where: { stripeCustomerId: unverifiedStripeEvent.data.object.customer }, + data: { subscriptionPlan: 'pro' }, }); } @@ -91,17 +93,17 @@ export async function POST(req: Request) { ```typescript // ❌ VULNERABLE: Price ID comes from the client -export async function POST(req: Request) { - const { priceId } = await req.json(); // attacker controls this! +export async function POST(httpRequest: Request) { + const { priceId: clientPriceId } = await httpRequest.json(); // attacker controls this! - const session = await stripe.checkout.sessions.create({ - line_items: [{ price: priceId, quantity: 1 }], // price from attacker! + const checkoutSession = await stripe.checkout.sessions.create({ + line_items: [{ price: clientPriceId, quantity: 1 }], // price from attacker! mode: 'subscription', success_url: `${process.env.NEXT_PUBLIC_URL}/success`, cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, }); - return Response.json({ url: session.url }); + return Response.json({ url: checkoutSession.url }); } ``` @@ -115,7 +117,7 @@ export async function POST(req: Request) { ```typescript // ❌ VULNERABLE: Hardcoded database URL -const db = new PrismaClient({ +const applicationDatabase = new PrismaClient({ datasources: { db: { url: '******db.example.com:5432/myapp', @@ -134,18 +136,19 @@ const db = new PrismaClient({ ```sql -- ❌ VULNERABLE: RLS never enabled, no policies -CREATE TABLE projects ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id), - name TEXT, - data JSONB +-- `auth.users(id)` is Supabase-owned and intentionally remains unchanged. +CREATE TABLE project_records ( + project_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID REFERENCES auth.users(id), + project_name TEXT, + project_payload JSONB ); --- Missing: ALTER TABLE projects ENABLE ROW LEVEL SECURITY; --- Missing: CREATE POLICY ... USING (auth.uid() = user_id); +-- Missing: ALTER TABLE project_records ENABLE ROW LEVEL SECURITY; +-- Missing: CREATE POLICY ... USING (auth.uid() = owner_user_id); ``` -**Impact:** Any authenticated user can read, modify, or delete any row in the `projects` table via the Supabase client. +**Impact:** Any authenticated user can read, modify, or delete any row in the `project_records` table via the Supabase client. --- @@ -157,30 +160,35 @@ CREATE TABLE projects ( // ✅ SECURE: Admin role verified server-side import { auth } from '@/auth'; -export async function GET(req: Request) { - const session = await auth(); +export async function GET(httpRequest: Request) { + const authSession = await auth(); // Step 1: Require authentication - if (!session) { + if (!authSession) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } // Step 2: Require admin role (from database, not just session claim) - const user = await db.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, + const userAccount = await applicationDatabase.userAccount.findUnique({ + where: { userAccountId: authSession.user.id }, + select: { accountRole: true }, }); - if (user?.role !== 'admin') { + if (userAccount?.accountRole !== 'admin') { return Response.json({ error: 'Forbidden' }, { status: 403 }); } - const users = await db.user.findMany({ - select: { id: true, email: true, createdAt: true, plan: true }, + const userAccounts = await applicationDatabase.userAccount.findMany({ + select: { + userAccountId: true, + emailAddress: true, + createdAt: true, + subscriptionPlan: true, + }, // Never include passwords, secrets, or sensitive fields }); - return Response.json(users); + return Response.json(userAccounts); } ``` @@ -207,4 +215,4 @@ npm run dev ## See the Fixed Version -All of these vulnerabilities are fixed in `../fixed-vibe-app/`. Compare the two to understand each fix. +All of these vulnerabilities are fixed in `../fixed-vibe-app/`. Compare the two to understand each security fix without conflating insecure behavior with ambiguous organization-owned naming. diff --git a/tests/test_fixed_example_naming_contract.py b/tests/test_fixed_example_naming_contract.py new file mode 100644 index 00000000..55eee35c --- /dev/null +++ b/tests/test_fixed_example_naming_contract.py @@ -0,0 +1,76 @@ +"""Regression coverage for semantic names in the fixed security example.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FIXED_EXAMPLE_README = ( + REPOSITORY_ROOT / "examples" / "fixed-vibe-app" / "README.md" +) + + +def test_fixed_example_uses_semantic_project_database_names() -> None: + """Keep generic owned database names out of the fixed project example.""" + example_text = FIXED_EXAMPLE_README.read_text(encoding="utf-8") + + required_semantic_names = ( + "CREATE TABLE project_records (", + "project_id UUID PRIMARY KEY", + "owner_user_id UUID REFERENCES auth.users(id) NOT NULL", + "project_name TEXT NOT NULL", + "project_payload JSONB", + ".from('project_records')", + ".eq('project_id', validatedProjectId)", + ) + forbidden_owned_names = ( + "CREATE TABLE projects (", + " id UUID PRIMARY KEY", + " user_id UUID REFERENCES auth.users(id) NOT NULL", + " name TEXT NOT NULL", + " data JSONB", + ".from('projects')", + ".eq('id', params.id)", + ) + + for semantic_name in required_semantic_names: + assert semantic_name in example_text + for generic_name in forbidden_owned_names: + assert generic_name not in example_text + + +def test_fixed_example_validates_protected_request_inputs() -> None: + """Require schema/parameter validation before protected sample operations.""" + example_text = FIXED_EXAMPLE_README.read_text(encoding="utf-8") + + required_validation_contracts = ( + "projectIdSchema.safeParse(params.projectId)", + "checkoutRequestSchema.safeParse(", + "await httpRequest.json().catch(() => null)", + "adminRequestUrl.searchParams.keys()", + "Unexpected query parameters", + ) + for validation_contract in required_validation_contracts: + assert validation_contract in example_text + + +def test_admin_example_authenticates_before_processing_query_input() -> None: + """Authenticate protected admin requests before touching untrusted query input.""" + example_text = FIXED_EXAMPLE_README.read_text(encoding="utf-8") + admin_section_text = example_text.split("### 7. Admin Auth Check Added", 1)[1] + + authentication_position = admin_section_text.index("const authSession = await auth();") + query_validation_position = admin_section_text.index( + "const adminRequestUrl = new URL(httpRequest.url);" + ) + + assert authentication_position < query_validation_position + + +def test_fixed_example_preserves_existing_sample_api_surface() -> None: + """Naming repairs must not introduce unrelated sample API contract changes.""" + example_text = FIXED_EXAMPLE_README.read_text(encoding="utf-8") + + assert "return Response.json({ url: checkoutSession.url });" in example_text + assert "`app/api/admin/users/route.ts`" in example_text + assert "checkout_url" not in example_text + assert "`app/api/admin/user-accounts/route.ts`" not in example_text diff --git a/tests/test_vulnerable_example_naming_contract.py b/tests/test_vulnerable_example_naming_contract.py new file mode 100644 index 00000000..76541680 --- /dev/null +++ b/tests/test_vulnerable_example_naming_contract.py @@ -0,0 +1,48 @@ +"""Naming-contract regression for the intentionally vulnerable security fixture.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +VULNERABLE_EXAMPLE_README = ( + REPOSITORY_ROOT / "examples" / "vulnerable-vibe-app" / "README.md" +) + + +def test_vulnerable_example_keeps_security_flaws_but_uses_semantic_project_names() -> None: + """Security vulnerabilities must not be coupled to ambiguous owned naming.""" + example_text = VULNERABLE_EXAMPLE_README.read_text(encoding="utf-8") + + required_semantic_names = ( + "app/api/projects/[projectId]/route.ts", + ".from('project_records')", + ".eq('project_id', params.projectId)", + "CREATE TABLE project_records (", + "project_id UUID PRIMARY KEY", + "owner_user_id UUID REFERENCES auth.users(id)", + "project_name TEXT", + "project_payload JSONB", + ) + preserved_vulnerability_markers = ( + "No authentication check!", + "No ownership verification!", + "RLS never enabled, no policies", + "Missing: ALTER TABLE project_records ENABLE ROW LEVEL SECURITY;", + ) + forbidden_generic_names = ( + "app/api/projects/[id]/route.ts", + ".from('projects')", + ".eq('id', params.id)", + "CREATE TABLE projects (", + " id UUID PRIMARY KEY", + " user_id UUID REFERENCES auth.users(id)", + " name TEXT", + " data JSONB", + ) + + for semantic_name in required_semantic_names: + assert semantic_name in example_text + for vulnerability_marker in preserved_vulnerability_markers: + assert vulnerability_marker in example_text + for generic_name in forbidden_generic_names: + assert generic_name not in example_text