launchprep 0.4.0 → 0.5.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.
package/src/rules.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.1","count":289,"rules":[{"id":"SEC-001","title":".env committed to version control","severity":"critical","requires":[],"tier":1,"check":"git ls-files matches ^\\.env(\\.|$) and not .env.example","fix":"git rm --cached .env; add to .gitignore; ROTATE every key in it","note":"rotation is the part everyone skips. history retains the file.","category":"secrets"},{"id":"SEC-002","title":".env absent from .gitignore","severity":"high","requires":[],"tier":1,"check":".gitignore lacks a pattern matching .env","category":"secrets"},{"id":"SEC-003","title":"Hardcoded API key / token / password in source","severity":"critical","requires":[],"tier":1,"check":"entropy + provider prefix scan (sk-, sk-ant-, AKIA, ghp_, xoxb-, eyJ...) outside .env","category":"secrets"},{"id":"SEC-004","title":"Server-only secret exposed to the client bundle","severity":"critical","requires":["surface in [web-app, web-site]"],"tier":1,"check":"NEXT_PUBLIC_/VITE_/REACT_APP_ prefix on a name matching (KEY|SECRET|TOKEN|PASSWORD), or a secret literal reachable from a client component","note":"the single most common vibe-coder leak. the prefix makes it PUBLIC, and the name reads like it is protected.","category":"secrets"},{"id":"SEC-005","title":"Supabase service_role key used outside a server context","severity":"critical","requires":["stack.database == supabase"],"tier":1,"check":"service_role key referenced in a client component, edge config, or NEXT_PUBLIC_ var","note":"service_role bypasses RLS entirely. in the browser it is a full database handover.","category":"secrets"},{"id":"SEC-006","title":"Paid-API key called directly from the browser","severity":"critical","requires":["calls_llm == true"],"tier":1,"check":"LLM SDK instantiated in client-side code, or provider endpoint called from browser","fix":"proxy through your own server route; never ship the provider key","category":"secrets"},{"id":"SEC-007","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"SEC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"AUTH-001","title":"Session token stored in localStorage","severity":"high","requires":["has_accounts == true","surface in [web-app, web-site]"],"tier":1,"check":"localStorage.setItem / getItem with a token-like key","fix":"httpOnly + Secure + SameSite cookie","note":"localStorage is readable by any XSS. a cookie with httpOnly is not.","category":"authentication"},{"id":"AUTH-002","title":"Role or permission read from client-controlled storage","severity":"critical","requires":["has_accounts == true"],"tier":1,"check":"role/isAdmin/plan/tier read from localStorage, sessionStorage, cookie, or JWT claim without server-side re-verification","note":"the corpus demo is literally 'open Application storage, change Member to Admin, refresh'.","category":"authentication"},{"id":"AUTH-003","title":"No brute-force limit on login","severity":"high","requires":["has_accounts == true"],"tier":1,"check":"login route has no attempt counter / lockout / rate limit","fix":"max 5 attempts per identifier per 15 min, plus IP-level cap","category":"authentication"},{"id":"AUTH-004","title":"Password reset endpoint unthrottled","severity":"high","requires":["has_accounts == true","sends_email == true"],"tier":1,"check":"password-reset route lacks rate limiting","note":"corpus example — 67,000 requests at 2am against one reset endpoint.","category":"authentication"},{"id":"AUTH-005","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AUTH-006","tier":2,"severity":"medium","requires":["has_admin_panel == true","stage == production"]},{"id":"AUTH-007","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"AUTH-008","title":"Password stored without a modern KDF","severity":"critical","requires":["stack.auth == custom"],"tier":1,"check":"password hashing absent, or md5/sha1/sha256 without bcrypt/argon2/scrypt","category":"authentication"},{"id":"AUTH-009","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production","is_public == true"]},{"id":"AUTH-010","tier":2,"severity":"high","requires":["stack.host in [vps, aws, gcp]"]},{"id":"AUTHZ-001","title":"Supabase table with RLS disabled","severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"tier":1,"check":"table in migrations without ENABLE ROW LEVEL SECURITY","note":"corpus calls this the single most common vulnerability found across startups AND enterprises. RLS is OFF by default for raw-SQL tables.","category":"authorization"},{"id":"AUTHZ-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"has_static_approximation":true,"title":"RLS enabled but policy is permissive"},{"id":"AUTHZ-003","tier":2,"severity":"critical","requires":["has_accounts == true"],"has_static_approximation":true,"title":"IDOR — object accessed by ID without an ownership check"},{"id":"AUTHZ-004","tier":2,"severity":"critical","requires":["has_admin_panel == true"],"has_static_approximation":true,"title":"Admin authorization enforced only in the UI"},{"id":"AUTHZ-005","tier":2,"severity":"critical","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"Usage limits stored in a row the user can modify"},{"id":"AUTHZ-006","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"],"has_static_approximation":true,"title":"Missing tenant scope on a domain query"},{"id":"AUTHZ-007","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"AUTHZ-008","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RATE-001","title":"No rate limiting on any endpoint","severity":"high","requires":["is_public == true"],"tier":1,"check":"no rate-limit middleware, no gateway policy, no per-route limiter","category":"rate-limiting"},{"id":"RATE-002","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"RATE-003","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"No spend ceiling on paid-API endpoints"},{"id":"RATE-004","tier":2,"severity":"high","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"No per-user quota on AI features"},{"id":"RATE-005","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RATE-006","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"RATE-007","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AI-001","tier":2,"severity":"critical","requires":["calls_llm == true","llm_input_from_user == true"],"has_static_approximation":true,"title":"User input reaches the system-prompt position"},{"id":"AI-002","tier":2,"severity":"high","requires":["calls_llm == true","llm_input_from_user == true"]},{"id":"AI-003","title":"No max_tokens ceiling on completions","severity":"high","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"provider call omits max_tokens, or sets it above the plan's economics","note":"unbounded output is unbounded cost, per request.","category":"ai-safety"},{"id":"AI-004","title":"No model allowlist","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"model id derived from request input, or no constant/allowlist","note":"a client-chosen model is a client-chosen price.","category":"ai-safety"},{"id":"AI-005","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AI-006","tier":2,"severity":"critical","requires":["calls_llm == true","llm_tools_enabled == true"]},{"id":"AI-007","title":"Model output rendered as HTML without sanitisation","severity":"critical","requires":["calls_llm == true","surface in [web-app, web-site]"],"tier":1,"origin":"gap","check":"completion piped to dangerouslySetInnerHTML / v-html / innerHTML","note":"injection in, XSS out.","category":"ai-safety"},{"id":"AI-008","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"Model output used in a privileged operation without validation"},{"id":"AI-009","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AI-010","tier":2,"severity":"high","requires":["calls_llm == true","data_sensitivity != none"]},{"id":"AI-011","title":"No retry/backoff on provider 429s","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","category":"ai-safety"},{"id":"AI-012","tier":2,"severity":"high","requires":["calls_llm == true","stack.host in [vercel, netlify]"]},{"id":"AI-013","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2b-saas, b2c, marketplace]"]},{"id":"PAY-001","title":"Webhook signature not verified","severity":"critical","requires":["handles_payments != none","has_webhooks_in == true"],"tier":1,"check":"webhook route parses the body without constructEvent / signature check","note":"an unverified webhook endpoint is an unauthenticated 'mark this order paid' API.","category":"payments"},{"id":"PAY-002","tier":2,"severity":"high","requires":["handles_payments in [stripe, paddle, lemonsqueezy]"]},{"id":"PAY-003","tier":2,"severity":"high","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-004","tier":2,"severity":"medium","requires":["handles_payments != none"]},{"id":"PAY-005","tier":2,"severity":"high","requires":["has_subscriptions == true"]},{"id":"PAY-006","title":"Price or amount accepted from the client","severity":"critical","requires":["handles_payments != none"],"tier":1,"check":"amount/price/currency read from the request body when creating a charge","category":"payments"},{"id":"PAY-007","tier":2,"severity":"medium","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-008","tier":2,"severity":"high","requires":["surface in [mobile-ios]","has_subscriptions == true"]},{"id":"DATA-001","title":"Unbounded list query — no pagination","severity":"high","requires":["stack.database != none"],"tier":1,"check":"list endpoint without limit/offset or cursor","note":"corpus — fine at 50 users, loads 50,000 rows at 500.","category":"data-and-scale"},{"id":"DATA-002","tier":2,"severity":"high","requires":["stack.database != none"]},{"id":"DATA-003","title":"Missing index on a filtered or joined column","severity":"high","requires":["stack.database in [postgres, supabase, mysql, planetscale]"],"tier":1,"check":"WHERE / JOIN / ORDER BY column with no index in migrations","note":"50ms at 100 rows, 30s at 100k. the code never changed.","category":"data-and-scale"},{"id":"DATA-004","title":"No connection pooling","severity":"high","requires":["stack.database in [postgres, supabase, mysql]","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"serverless multiplies connections per invocation; the DB runs out first.","category":"data-and-scale"},{"id":"DATA-005","title":"SELECT * on wide or hot tables","severity":"medium","requires":["stack.database != none"],"tier":1,"category":"data-and-scale"},{"id":"DATA-006","tier":2,"severity":"critical","requires":[]},{"id":"DATA-007","title":"Raw SQL built by string concatenation","severity":"critical","requires":["stack.orm == raw-sql"],"tier":1,"check":"query string interpolating a request value","category":"data-and-scale"},{"id":"DATA-008","title":"No payload size limit","severity":"medium","requires":["is_public == true"],"tier":1,"check":"body parser without a size cap","category":"data-and-scale"},{"id":"DATA-009","title":"Text columns not 4-byte-safe","severity":"medium","requires":["stack.database in [mysql, planetscale]"],"tier":1,"check":"utf8 rather than utf8mb4 on user-facing text columns","note":"corpus failure — a user typed an emoji and the app crashed. 3-byte utf8 cannot hold a 4-byte codepoint.","category":"data-and-scale"},{"id":"DATA-010","tier":2,"severity":"medium","requires":["stage == pre-launch","expected_scale != hobby"]},{"id":"DATA-011","tier":2,"severity":"medium","requires":["sends_email == true or has_file_uploads == true"]},{"id":"DATA-012","tier":2,"severity":"critical","requires":["stage == production","stack.database != none"]},{"id":"DATA-013","tier":2,"severity":"critical","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"DATA-014","title":"User uploads stored on ephemeral disk","severity":"critical","requires":["has_file_uploads == true","stack.host in [vercel, netlify, railway, fly, render]"],"tier":1,"check":"writes to local fs on a platform with an ephemeral filesystem","note":"matches CRMini — files vanish on every redeploy.","origin":"gap","category":"data-and-scale"},{"id":"DATA-015","title":"User input concatenated into a SQL query","severity":"critical","tier":1,"check":"a request value interpolated into a SQL string instead of passed as a parameter","note":"the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.","origin":"gap","category":"data-and-scale"},{"id":"INF-001","tier":2,"severity":"medium","requires":["stage == production","expected_scale in [under-100k, over-100k]","stack.host in [vps, aws, gcp]"]},{"id":"INF-002","title":"No health check or auto-restart","severity":"medium","requires":["stage == production","stack.host in [vps, aws, gcp]"],"tier":1,"category":"infrastructure"},{"id":"INF-003","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-004","tier":2,"severity":"low","requires":["stage == production","expected_scale != hobby"]},{"id":"INF-005","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-006","title":"HTTPS not enforced","severity":"high","requires":["is_public == true"],"tier":1,"check":"no HSTS, no http->https redirect","category":"infrastructure"},{"id":"INF-007","title":"Permissive CORS","severity":"high","requires":["surface in [web-app, api-only]"],"tier":1,"check":"Access-Control-Allow-Origin: * on credentialed routes","origin":"gap","category":"infrastructure"},{"id":"INF-008","tier":2,"severity":"critical","requires":["stack.database != none","stack.host in [vps, aws, gcp]"]},{"id":"INF-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"INF-010","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"INF-011","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, marketplace]"]},{"id":"LEG-001","title":"No privacy policy while collecting personal data","severity":"high","requires":["data_sensitivity != none","is_public == true"],"tier":1,"note":"corpus is emphatic — a single contact form collecting an email is already personal data.","category":"legal"},{"id":"LEG-002","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LEG-003","title":"No terms of service","severity":"medium","requires":["is_public == true","has_accounts == true"],"tier":1,"category":"legal"},{"id":"LEG-004","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk, ca]"]},{"id":"LEG-005","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, ru, kz]"]},{"id":"LEG-006","title":"Accessibility baseline unmet","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"missing alt text, insufficient contrast, no heading hierarchy, unlabelled controls","note":"corpus flags active litigation risk in US and AU.","category":"legal"},{"id":"LEG-007","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]","business_model != hobby"]},{"id":"LEG-008","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"LEG-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [ru, kz, other]"]},{"id":"LEG-010","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"OBS-001","title":"No error monitoring","severity":"high","requires":["stage == production"],"tier":1,"origin":"gap","category":"observability"},{"id":"OBS-002","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"OBS-003","title":"No product analytics","severity":"low","requires":["stage in [pre-launch, production]","business_model != internal-tool"],"tier":1,"category":"observability"},{"id":"OBS-004","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"UX-001","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-002","tier":2,"severity":"high","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-003","tier":2,"severity":"low","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-004","tier":2,"severity":"low","requires":["stage in [pre-launch, production]","business_model in [b2c, b2b-saas]"]},{"id":"UX-005","title":"No social preview image","severity":"low","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"category":"ux-readiness"},{"id":"UX-006","title":"No sitemap.xml / not indexable","severity":"low","requires":["is_public == true","surface == web-site"],"tier":1,"category":"ux-readiness"},{"id":"AIOP-001","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-002","title":"Model id pinned to an alias that can silently change","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","note":"floating aliases shift behaviour and price under you with no deploy.","category":"ai-operations"},{"id":"AIOP-003","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-004","tier":2,"severity":"low","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-005","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-006","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-007","tier":2,"severity":"critical","requires":["has_rag == true"]},{"id":"AIOP-008","tier":2,"severity":"critical","requires":["has_rag == true","tenancy == multi-tenant-shared-db"]},{"id":"AIOP-009","tier":2,"severity":"medium","requires":["has_rag == true"]},{"id":"AIOP-010","title":"Agent loop has no iteration ceiling","severity":"critical","requires":["llm_tools_enabled == true"],"tier":1,"origin":"gap","note":"an unbounded loop is an unbounded invoice.","category":"ai-operations"},{"id":"AIOP-011","tier":2,"severity":"high","requires":["llm_tools_enabled == true"]},{"id":"AIOP-012","tier":2,"severity":"critical","requires":["llm_tools_enabled == true"]},{"id":"AIOP-013","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-014","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-015","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2c, b2b-saas]"]},{"id":"AIOP-016","title":"User-facing AI errors leak provider internals","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"AIOP-017","tier":2,"severity":"medium","requires":["calls_llm == true","is_public == true","business_model == b2c"]},{"id":"AIOP-018","tier":2,"severity":"critical","requires":["calls_llm == true","is_public == true"],"has_static_approximation":true,"title":"Free tier allows unauthenticated AI calls"},{"id":"AIOP-019","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AIOP-020","title":"Temperature or sampling unset for a deterministic task","severity":"low","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"DEP-001","title":"Secrets injected at build time instead of runtime","severity":"high","requires":["stage in [pre-launch, production]"],"tier":1,"note":"build-time inlining bakes the value into the artifact; rotation needs a rebuild.","category":"deployment"},{"id":"DEP-002","title":"Secrets present in CI logs or config","severity":"critical","requires":["has_ci == true"],"tier":1,"check":"plaintext credential in workflow yaml, or echoed to build output","category":"deployment"},{"id":"DEP-003","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-004","tier":2,"severity":"high","requires":["has_migrations == true","stage == production"]},{"id":"DEP-005","title":"Migrations are not reversible","severity":"medium","requires":["has_migrations == true","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-006","tier":2,"severity":"critical","requires":["has_migrations == true","stage == production"]},{"id":"DEP-007","tier":2,"severity":"high","requires":["stage == production"]},{"id":"DEP-008","title":"No health endpoint","severity":"medium","requires":["stage == production","surface in [web-app, api-only]"],"tier":1,"category":"deployment"},{"id":"DEP-009","title":"No graceful shutdown handling","severity":"medium","requires":["stage == production","has_background_jobs == true"],"tier":1,"check":"no SIGTERM handler draining in-flight work","category":"deployment"},{"id":"DEP-010","title":"Dependencies unpinned","severity":"medium","requires":["stage in [pre-launch, production]"],"tier":1,"check":"no lockfile committed, or ranges in a deployed manifest","category":"deployment"},{"id":"DEP-011","title":"Known-vulnerable dependencies","severity":"high","requires":[],"tier":1,"check":"audit surfaces advisories at high or critical","category":"deployment"},{"id":"DEP-012","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"DEP-013","title":"Third-party script loaded without integrity check","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"external <script> without SRI","category":"deployment"},{"id":"DEP-014","title":"Source maps published in production","severity":"medium","requires":["surface in [web-app, web-site]","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-015","title":"Debug mode or verbose errors enabled in production","severity":"high","requires":["stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-016","title":"No CI check gating merges","severity":"low","requires":["has_ci == true","business_model in [b2b-saas, marketplace]"],"tier":1,"category":"deployment"},{"id":"DEP-017","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-018","tier":2,"severity":"high","requires":["stage == production","stack.database != none"]},{"id":"RU-001","title":"Timestamps stored without timezone","severity":"high","requires":["stack.database != none"],"tier":1,"check":"timestamp column without time zone, or naive datetime written from app code","note":"works perfectly until your second user is in another country.","category":"real-user-readiness"},{"id":"RU-002","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"RU-003","title":"Money stored as float","severity":"critical","requires":["handles_payments != none or serves_currency == multi"],"tier":1,"check":"float/double/real column holding an amount","note":"rounding error in currency is a correctness bug, not a style issue.","category":"real-user-readiness"},{"id":"RU-004","title":"Currency not stored alongside amount","severity":"high","requires":["serves_currency == multi"],"tier":1,"category":"real-user-readiness"},{"id":"RU-005","tier":2,"severity":"low","requires":["data_sensitivity != none","audience_locale == multi"]},{"id":"RU-006","title":"Text input not accepting the full Unicode range","severity":"high","requires":["stack.database != none"],"tier":1,"note":"corpus — an emoji crashed a production app because the column was 3-byte utf8.","category":"real-user-readiness"},{"id":"RU-007","title":"No mobile viewport handling","severity":"high","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"RU-009","tier":2,"severity":"medium","requires":["has_accounts == true"]},{"id":"RU-010","title":"No support or contact channel","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"category":"real-user-readiness"},{"id":"RU-011","tier":2,"severity":"high","requires":["sends_email == true","stage == production"]},{"id":"RU-012","tier":2,"severity":"medium","requires":["sends_email == true","stage == production"]},{"id":"RU-013","title":"Marketing email without an unsubscribe path","severity":"high","requires":["sends_email == true","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-014","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RU-015","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"RU-016","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RU-017","tier":2,"severity":"high","requires":["has_accounts == true","stack.auth == custom"]},{"id":"RU-018","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"RU-019","title":"Session lifetime unbounded","severity":"medium","requires":["has_accounts == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-020","tier":2,"severity":"low","requires":["has_accounts == true","business_model in [b2c, b2b-saas]"]},{"id":"TEN-001","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","stack.database in [postgres, supabase]"]},{"id":"TEN-002","title":"No composite index on (tenant_id, ...) for hot queries","severity":"medium","requires":["tenancy == multi-tenant-shared-db"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-003","title":"Invitation token does not expire","severity":"high","requires":["has_invitations == true"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-004","tier":2,"severity":"critical","requires":["has_invitations == true","has_roles == true"]},{"id":"TEN-005","tier":2,"severity":"critical","requires":["has_roles == true"]},{"id":"TEN-006","tier":2,"severity":"critical","requires":["has_roles == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-007","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_accounts == true"]},{"id":"TEN-008","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","has_file_uploads == true"]},{"id":"TEN-009","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-010","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","has_subscriptions == true"]},{"id":"TEN-011","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","calls_llm == true"]},{"id":"TEN-012","tier":2,"severity":"high","requires":["has_admin_panel == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-013","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-014","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_webhooks_in == true"]},{"id":"UP-001","title":"File type validated by extension or client MIME only","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-002","title":"No upload size limit","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-003","title":"Upload path built from a user-supplied filename","severity":"critical","requires":["has_file_uploads == true"],"tier":1,"check":"filename concatenated into a storage path without normalisation","category":"file-uploads"},{"id":"UP-004","tier":2,"severity":"high","requires":["has_file_uploads == true","surface in [web-app, web-site]"]},{"id":"UP-005","title":"SVG accepted without sanitisation","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-006","tier":2,"severity":"medium","requires":["has_file_uploads == true","tenancy == multi-tenant-shared-db"]},{"id":"UP-007","title":"Signed URLs with excessive lifetime","severity":"medium","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-008","title":"EXIF metadata not stripped from images","severity":"medium","requires":["has_file_uploads == true","data_sensitivity != none"],"tier":1,"note":"photo uploads routinely carry GPS coordinates.","category":"file-uploads"},{"id":"UP-009","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-010","tier":2,"severity":"medium","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"UP-011","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-012","tier":2,"severity":"high","requires":["has_file_uploads == true"]},{"id":"API-001","title":"Mass assignment — request body spread into a model","severity":"critical","requires":["stack.database != none"],"tier":1,"check":"request body passed wholesale to create/update","note":"this is how a user sets their own is_admin.","category":"api-design"},{"id":"API-002","title":"Internal error details returned to the client","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-003","title":"Stack traces reachable in production responses","severity":"high","requires":["is_public == true","stage == production"],"tier":1,"category":"api-design"},{"id":"API-004","title":"No maximum page size","severity":"medium","requires":["is_public == true"],"tier":1,"check":"limit parameter accepted without an upper bound","category":"api-design"},{"id":"API-005","title":"Sort or filter parameter interpolated into a query","severity":"critical","requires":["stack.database != none"],"tier":1,"category":"api-design"},{"id":"API-006","title":"State-changing operation exposed over GET","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-007","tier":2,"severity":"high","requires":["has_accounts == true","surface in [web-app]"]},{"id":"API-008","tier":2,"severity":"low","requires":["surface == api-only"]},{"id":"API-009","tier":2,"severity":"low","requires":["has_accounts == true"]},{"id":"API-010","title":"No request timeout on outbound calls","severity":"medium","requires":["stage == production"],"tier":1,"category":"api-design"},{"id":"API-011","tier":2,"severity":"medium","requires":["surface == api-only","stage == production"]},{"id":"API-012","title":"Security headers missing","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"no CSP, X-Content-Type-Options, Referrer-Policy, frame ancestors","category":"api-design"},{"id":"API-013","title":"Open redirect","severity":"high","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"check":"redirect target read from a query parameter without an allowlist","category":"api-design"},{"id":"API-014","tier":2,"severity":"critical","requires":["is_public == true"],"has_static_approximation":true,"title":"SSRF — outbound request to a user-supplied URL"},{"id":"NEXT-001","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-002","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-003","tier":2,"severity":"high","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-004","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-005","tier":2,"severity":"high","requires":["stack.framework == next"]},{"id":"NEXT-006","title":"Secret imported into a module reachable from the client graph","severity":"critical","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-007","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-008","tier":2,"severity":"medium","requires":["stack.framework == next"]},{"id":"NEXT-009","title":"Image optimizer allows arbitrary remote hosts","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"remotePatterns with a wildcard hostname","category":"framework-next"},{"id":"NEXT-010","title":"Route handler lacks runtime/duration config for long work","severity":"medium","requires":["stack.framework == next","calls_llm == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-011","title":"Error boundary absent","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"no error.tsx / global-error.tsx","category":"framework-next"},{"id":"NEXT-012","title":"Loading UI absent on data routes","severity":"low","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-013","title":"Cookies set without secure attributes","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-014","title":"Redirect after login uses an unvalidated next parameter","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"SUP-001","title":"Storage bucket public by default","severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"],"tier":1,"note":"mirrors the RLS problem — permissive default, silent exposure.","category":"platform-supabase"},{"id":"SUP-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"]},{"id":"SUP-003","tier":2,"severity":"critical","requires":["stack.database == supabase","has_realtime == true"]},{"id":"SUP-004","tier":2,"severity":"critical","requires":["stack.database == supabase"]},{"id":"SUP-005","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"]},{"id":"SUP-006","title":"Postgres function marked SECURITY DEFINER without a search_path","severity":"high","requires":["stack.database in [supabase, postgres]"],"tier":1,"category":"platform-supabase"},{"id":"SUP-007","tier":2,"severity":"high","requires":["stack.database == supabase"]},{"id":"SUP-008","tier":2,"severity":"low","requires":["stack.auth == supabase-auth","stage == production"]},{"id":"SUP-009","tier":2,"severity":"critical","requires":["stack.database in [supabase, postgres]","has_accounts == true"]},{"id":"SUP-010","tier":2,"severity":"high","requires":["stack.database == supabase","has_webhooks_in == true"]},{"id":"SUP-011","title":"Connection string uses the direct port under serverless","severity":"high","requires":["stack.database == supabase","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"use the pooler; direct connections exhaust under serverless fan-out.","category":"platform-supabase"},{"id":"SUP-012","tier":2,"severity":"high","requires":["stack.database == supabase","stage == production"]},{"id":"LIFE-001","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-002","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-003","tier":2,"severity":"high","requires":["data_sensitivity != none"]},{"id":"LIFE-004","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-005","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-006","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-007","tier":2,"severity":"critical","requires":["data_sensitivity != none"]},{"id":"LIFE-008","tier":2,"severity":"critical","requires":["stage == production","data_sensitivity != none"]},{"id":"LIFE-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-010","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-011","tier":2,"severity":"medium","requires":["business_model == b2b-saas","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-012","tier":2,"severity":"critical","requires":["data_sensitivity == children"]},{"id":"MOB-001","title":"Secret embedded in the app binary","severity":"critical","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"note":"a shipped binary is a public file. anything in it is published.","category":"mobile"},{"id":"MOB-002","title":"Token stored outside the platform secure store","severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_accounts == true"],"tier":1,"check":"credential in UserDefaults / SharedPreferences rather than Keychain / Keystore","category":"mobile"},{"id":"MOB-003","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"MOB-004","title":"Permissions requested without a usage description","severity":"high","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"category":"mobile"},{"id":"MOB-005","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-006","tier":2,"severity":"medium","requires":["surface == mobile-ios","has_accounts == true"]},{"id":"MOB-007","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_subscriptions == true"]},{"id":"MOB-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","stage == production"]},{"id":"MOB-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-010","tier":2,"severity":"low","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"INC-001","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"INC-002","tier":2,"severity":"high","requires":["stage == production","calls_llm == true"]},{"id":"INC-003","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, b2c]"]},{"id":"INC-004","title":"No security contact or disclosure path","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"origin":"gap","check":"no security.txt, no security contact","category":"incident-readiness"},{"id":"INC-005","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"INC-006","tier":2,"severity":"critical","requires":["calls_llm == true","stage == production"]},{"id":"INC-007","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"INC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"INC-009","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"INC-010","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"DJ-001","title":"DEBUG is on in a deployed setting","severity":"critical","requires":["stack.framework == django","stage in [pre-launch, production]"],"tier":1,"check":"DEBUG = True in a settings module not named local/dev","note":"Django's debug page prints settings, installed apps, the SQL that ran and a full traceback to whoever triggers the error. Django's own docs call deploying with it on a security problem.","fix":"DEBUG = False in production, and set ALLOWED_HOSTS.","category":"framework-django"},{"id":"DJ-002","title":"SECRET_KEY written into source","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SECRET_KEY assigned a literal rather than read from the environment","note":"SECRET_KEY signs sessions, password-reset tokens and CSRF tokens. Anyone holding it can forge a session for any user.","fix":"Read it from an environment variable and rotate the committed value.","category":"framework-django"},{"id":"DJ-003","title":"ALLOWED_HOSTS accepts any host","severity":"high","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"ALLOWED_HOSTS contains '*'","note":"enables host-header poisoning, which turns password-reset emails into links pointing at an attacker's domain.","category":"framework-django"},{"id":"DJ-004","title":"CSRF middleware removed","severity":"critical","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"MIDDLEWARE lacks django.middleware.csrf.CsrfViewMiddleware","note":"it ships enabled. Its absence means someone deliberately removed it.","category":"framework-django"},{"id":"DJ-005","tier":2,"severity":"critical","requires":["stack.framework == django"]},{"id":"DJ-006","title":"Raw SQL built by interpolation","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":".raw() or .extra() containing an f-string, % or .format()","fix":"Pass parameters: .raw('SELECT … WHERE id = %s', [id])","category":"framework-django"},{"id":"DJ-007","title":"HTTPS not enforced","severity":"high","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_SSL_REDIRECT absent or False","category":"framework-django"},{"id":"DJ-008","title":"Session and CSRF cookies not marked secure","severity":"high","requires":["stack.framework == django","has_accounts == true","stage == production"],"tier":1,"check":"SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE missing or False","note":"without these the cookies travel over plain HTTP on the first request.","category":"framework-django"},{"id":"DJ-009","title":"HSTS not configured","severity":"medium","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_HSTS_SECONDS is 0 or unset","category":"framework-django"},{"id":"DJ-010","title":"ModelForm or serializer exposes every field","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"fields = '__all__' on a ModelForm or ModelSerializer","note":"Django's own mass-assignment shape. Every column becomes writable from the request, including is_staff, is_superuser and any balance you keep on the model.","fix":"List the fields you actually accept.","category":"framework-django"},{"id":"DJ-011","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-012","title":"Password validators removed","severity":"medium","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"AUTH_PASSWORD_VALIDATORS is an empty list","category":"framework-django"},{"id":"DJ-013","title":"Clickjacking protection disabled","severity":"medium","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"XFrameOptionsMiddleware absent, or X_FRAME_OPTIONS set to ALLOWALL","category":"framework-django"},{"id":"DJ-014","title":"Sessions serialised with pickle","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SESSION_SERIALIZER set to PickleSerializer","note":"combined with a leaked SECRET_KEY this is remote code execution, not just session forgery.","category":"framework-django"},{"id":"DJ-015","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-016","title":"DEBUG toolbar or dev-only app installed in production","severity":"high","requires":["stack.framework == django","stage == production"],"tier":1,"check":"debug_toolbar, django_extensions or silk present in production INSTALLED_APPS","category":"framework-django"},{"id":"DJ-017","tier":2,"severity":"medium","requires":["stack.framework == django","has_file_uploads == true","stage == production"]},{"id":"DJ-018","tier":2,"severity":"high","requires":["stack.framework == django","has_accounts == true","is_public == true"]},{"id":"RB-001","title":"secret_key_base committed to the repository","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"a literal secret_key_base in config/secrets.yml, credentials.yml or an initializer","note":"signs every session cookie. With it, anyone mints a session for any user — and Rails historically deserialised session data, so it has meant RCE.","fix":"Use encrypted credentials or ENV, and rotate the exposed key.","category":"framework-rails"},{"id":"RB-002","title":"config.force_ssl not enabled in production","severity":"high","requires":["stack.framework == rails","stage == production","is_public == true"],"tier":1,"check":"config.force_ssl absent or false in config/environments/production.rb","note":"one line, and it enables the redirect, secure cookies and HSTS together.","category":"framework-rails"},{"id":"RB-003","title":"CSRF protection disabled or skipped","severity":"critical","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"skip_before_action :verify_authenticity_token, or protect_from_forgery with: :null_session on a state-changing controller","category":"framework-rails"},{"id":"RB-004","title":"html_safe or raw applied to user content","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":".html_safe or raw() on a value derived from params, or <%== in an ERB template","note":"ERB escapes by default. These are the ways to turn that off.","category":"framework-rails"},{"id":"RB-005","title":"SQL built by string interpolation","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"where(\"… #{…}\"), find_by_sql or order() containing interpolation","fix":"where('email = ?', email) — the placeholder form.","category":"framework-rails"},{"id":"RB-006","title":"Strong parameters bypassed with permit!","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"params.permit! or params.require(:x).permit!","note":"permit! whitelists everything the caller sent. Rails added strong parameters precisely because of the GitHub mass-assignment incident.","category":"framework-rails"},{"id":"RB-007","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-008","title":"Unsafe deserialisation of user input","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"Marshal.load, YAML.load or Oj.load applied to request data","note":"each of these instantiates arbitrary Ruby objects. Use YAML.safe_load.","category":"framework-rails"},{"id":"RB-009","title":"File served from a user-supplied path","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"send_file or render file: built from params","note":"../../config/master.key is a valid filename as far as the filesystem cares.","category":"framework-rails"},{"id":"RB-010","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-011","title":"Sensitive parameters not filtered from logs","severity":"high","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"config.filter_parameters omits password, token or secret","note":"otherwise plaintext passwords are written into production logs on every sign-in, and logs are backed up, shipped and searched.","category":"framework-rails"},{"id":"RB-012","title":"Detailed exception pages enabled in production","severity":"high","requires":["stack.framework == rails","stage == production"],"tier":1,"check":"config.consider_all_requests_local = true in production.rb","note":"turns every 500 into a stack trace with source and local variables.","category":"framework-rails"},{"id":"RB-013","tier":2,"severity":"high","requires":["stack.framework == rails"]},{"id":"RB-014","title":"Devise configured without lockable or timeout","severity":"medium","requires":["stack.framework == rails","stack.auth == devise","is_public == true"],"tier":1,"check":"devise model lacks :lockable, or Devise.timeout_in is unset","note":"Devise ships neither brute-force lockout nor session expiry enabled.","category":"framework-rails"},{"id":"RB-015","tier":2,"severity":"medium","requires":["stack.framework == rails","has_background_jobs == true"]}]}
1
+ {"version":"0.1","count":290,"rules":[{"id":"SEC-001","title":".env committed to version control","severity":"critical","requires":[],"tier":1,"check":"git ls-files matches ^\\.env(\\.|$) and not .env.example","fix":"git rm --cached .env; add to .gitignore; ROTATE every key in it","note":"rotation is the part everyone skips. history retains the file.","category":"secrets"},{"id":"SEC-002","title":".env absent from .gitignore","severity":"high","requires":[],"tier":1,"check":".gitignore lacks a pattern matching .env","category":"secrets"},{"id":"SEC-003","title":"Hardcoded API key / token / password in source","severity":"critical","requires":[],"tier":1,"check":"entropy + provider prefix scan (sk-, sk-ant-, AKIA, ghp_, xoxb-, eyJ...) outside .env","category":"secrets"},{"id":"SEC-009","title":"A live key is still in your git history","severity":"critical","requires":[],"tier":1,"check":"vendor-prefixed live keys (sk_live_, rk_live_, whsec_, sk-ant-, sk-proj-, ghp_, gho_, glpat-, SG.) found in any blob in .git, read directly without shelling out. Deliberately NOT private keys or database URLs: a test fixture and a real one are byte-identical, and including them fired on 15 of 68 real repositories with every hit a fixture.","category":"secrets"},{"id":"SEC-004","title":"Server-only secret exposed to the client bundle","severity":"critical","requires":["surface in [web-app, web-site]"],"tier":1,"check":"NEXT_PUBLIC_/VITE_/REACT_APP_ prefix on a name matching (KEY|SECRET|TOKEN|PASSWORD), or a secret literal reachable from a client component","note":"the single most common vibe-coder leak. the prefix makes it PUBLIC, and the name reads like it is protected.","category":"secrets"},{"id":"SEC-005","title":"Supabase service_role key used outside a server context","severity":"critical","requires":["stack.database == supabase"],"tier":1,"check":"service_role key referenced in a client component, edge config, or NEXT_PUBLIC_ var","note":"service_role bypasses RLS entirely. in the browser it is a full database handover.","category":"secrets"},{"id":"SEC-006","title":"Paid-API key called directly from the browser","severity":"critical","requires":["calls_llm == true"],"tier":1,"check":"LLM SDK instantiated in client-side code, or provider endpoint called from browser","fix":"proxy through your own server route; never ship the provider key","category":"secrets"},{"id":"SEC-007","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"SEC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"AUTH-001","title":"Session token stored in localStorage","severity":"high","requires":["has_accounts == true","surface in [web-app, web-site]"],"tier":1,"check":"localStorage.setItem / getItem with a token-like key","fix":"httpOnly + Secure + SameSite cookie","note":"localStorage is readable by any XSS. a cookie with httpOnly is not.","category":"authentication"},{"id":"AUTH-002","title":"Role or permission read from client-controlled storage","severity":"critical","requires":["has_accounts == true"],"tier":1,"check":"role/isAdmin/plan/tier read from localStorage, sessionStorage, cookie, or JWT claim without server-side re-verification","note":"the corpus demo is literally 'open Application storage, change Member to Admin, refresh'.","category":"authentication"},{"id":"AUTH-003","title":"No brute-force limit on login","severity":"high","requires":["has_accounts == true"],"tier":1,"check":"login route has no attempt counter / lockout / rate limit","fix":"max 5 attempts per identifier per 15 min, plus IP-level cap","category":"authentication"},{"id":"AUTH-004","title":"Password reset endpoint unthrottled","severity":"high","requires":["has_accounts == true","sends_email == true"],"tier":1,"check":"password-reset route lacks rate limiting","note":"corpus example — 67,000 requests at 2am against one reset endpoint.","category":"authentication"},{"id":"AUTH-005","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AUTH-006","tier":2,"severity":"medium","requires":["has_admin_panel == true","stage == production"]},{"id":"AUTH-007","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"AUTH-008","title":"Password stored without a modern KDF","severity":"critical","requires":["stack.auth == custom"],"tier":1,"check":"password hashing absent, or md5/sha1/sha256 without bcrypt/argon2/scrypt","category":"authentication"},{"id":"AUTH-009","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production","is_public == true"]},{"id":"AUTH-010","tier":2,"severity":"high","requires":["stack.host in [vps, aws, gcp]"]},{"id":"AUTHZ-001","title":"Supabase table with RLS disabled","severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"tier":1,"check":"table in migrations without ENABLE ROW LEVEL SECURITY","note":"corpus calls this the single most common vulnerability found across startups AND enterprises. RLS is OFF by default for raw-SQL tables.","category":"authorization"},{"id":"AUTHZ-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"has_static_approximation":true,"title":"RLS enabled but policy is permissive"},{"id":"AUTHZ-003","tier":2,"severity":"critical","requires":["has_accounts == true"],"has_static_approximation":true,"title":"IDOR — object accessed by ID without an ownership check"},{"id":"AUTHZ-004","tier":2,"severity":"critical","requires":["has_admin_panel == true"],"has_static_approximation":true,"title":"Admin authorization enforced only in the UI"},{"id":"AUTHZ-005","tier":2,"severity":"critical","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"Usage limits stored in a row the user can modify"},{"id":"AUTHZ-006","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"],"has_static_approximation":true,"title":"Missing tenant scope on a domain query"},{"id":"AUTHZ-007","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"AUTHZ-008","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RATE-001","title":"No rate limiting on any endpoint","severity":"high","requires":["is_public == true"],"tier":1,"check":"no rate-limit middleware, no gateway policy, no per-route limiter","category":"rate-limiting"},{"id":"RATE-002","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"RATE-003","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"No spend ceiling on paid-API endpoints"},{"id":"RATE-004","tier":2,"severity":"high","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"No per-user quota on AI features"},{"id":"RATE-005","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RATE-006","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"RATE-007","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AI-001","tier":2,"severity":"critical","requires":["calls_llm == true","llm_input_from_user == true"],"has_static_approximation":true,"title":"User input reaches the system-prompt position"},{"id":"AI-002","tier":2,"severity":"high","requires":["calls_llm == true","llm_input_from_user == true"]},{"id":"AI-003","title":"No max_tokens ceiling on completions","severity":"high","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"provider call omits max_tokens, or sets it above the plan's economics","note":"unbounded output is unbounded cost, per request.","category":"ai-safety"},{"id":"AI-004","title":"No model allowlist","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"model id derived from request input, or no constant/allowlist","note":"a client-chosen model is a client-chosen price.","category":"ai-safety"},{"id":"AI-005","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AI-006","tier":2,"severity":"critical","requires":["calls_llm == true","llm_tools_enabled == true"]},{"id":"AI-007","title":"Model output rendered as HTML without sanitisation","severity":"critical","requires":["calls_llm == true","surface in [web-app, web-site]"],"tier":1,"origin":"gap","check":"completion piped to dangerouslySetInnerHTML / v-html / innerHTML","note":"injection in, XSS out.","category":"ai-safety"},{"id":"AI-008","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"Model output used in a privileged operation without validation"},{"id":"AI-009","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AI-010","tier":2,"severity":"high","requires":["calls_llm == true","data_sensitivity != none"]},{"id":"AI-011","title":"No retry/backoff on provider 429s","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","category":"ai-safety"},{"id":"AI-012","tier":2,"severity":"high","requires":["calls_llm == true","stack.host in [vercel, netlify]"]},{"id":"AI-013","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2b-saas, b2c, marketplace]"]},{"id":"PAY-001","title":"Webhook signature not verified","severity":"critical","requires":["handles_payments != none","has_webhooks_in == true"],"tier":1,"check":"webhook route parses the body without constructEvent / signature check","note":"an unverified webhook endpoint is an unauthenticated 'mark this order paid' API.","category":"payments"},{"id":"PAY-002","tier":2,"severity":"high","requires":["handles_payments in [stripe, paddle, lemonsqueezy]"]},{"id":"PAY-003","tier":2,"severity":"high","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-004","tier":2,"severity":"medium","requires":["handles_payments != none"]},{"id":"PAY-005","tier":2,"severity":"high","requires":["has_subscriptions == true"]},{"id":"PAY-006","title":"Price or amount accepted from the client","severity":"critical","requires":["handles_payments != none"],"tier":1,"check":"amount/price/currency read from the request body when creating a charge","category":"payments"},{"id":"PAY-007","tier":2,"severity":"medium","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-008","tier":2,"severity":"high","requires":["surface in [mobile-ios]","has_subscriptions == true"]},{"id":"DATA-001","title":"Unbounded list query — no pagination","severity":"high","requires":["stack.database != none"],"tier":1,"check":"list endpoint without limit/offset or cursor","note":"corpus — fine at 50 users, loads 50,000 rows at 500.","category":"data-and-scale"},{"id":"DATA-002","tier":2,"severity":"high","requires":["stack.database != none"]},{"id":"DATA-003","title":"Missing index on a filtered or joined column","severity":"high","requires":["stack.database in [postgres, supabase, mysql, planetscale]"],"tier":1,"check":"WHERE / JOIN / ORDER BY column with no index in migrations","note":"50ms at 100 rows, 30s at 100k. the code never changed.","category":"data-and-scale"},{"id":"DATA-004","title":"No connection pooling","severity":"high","requires":["stack.database in [postgres, supabase, mysql]","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"serverless multiplies connections per invocation; the DB runs out first.","category":"data-and-scale"},{"id":"DATA-005","title":"SELECT * on wide or hot tables","severity":"medium","requires":["stack.database != none"],"tier":1,"category":"data-and-scale"},{"id":"DATA-006","tier":2,"severity":"critical","requires":[]},{"id":"DATA-007","title":"Raw SQL built by string concatenation","severity":"critical","requires":["stack.orm == raw-sql"],"tier":1,"check":"query string interpolating a request value","category":"data-and-scale"},{"id":"DATA-008","title":"No payload size limit","severity":"medium","requires":["is_public == true"],"tier":1,"check":"body parser without a size cap","category":"data-and-scale"},{"id":"DATA-009","title":"Text columns not 4-byte-safe","severity":"medium","requires":["stack.database in [mysql, planetscale]"],"tier":1,"check":"utf8 rather than utf8mb4 on user-facing text columns","note":"corpus failure — a user typed an emoji and the app crashed. 3-byte utf8 cannot hold a 4-byte codepoint.","category":"data-and-scale"},{"id":"DATA-010","tier":2,"severity":"medium","requires":["stage == pre-launch","expected_scale != hobby"]},{"id":"DATA-011","tier":2,"severity":"medium","requires":["sends_email == true or has_file_uploads == true"]},{"id":"DATA-012","tier":2,"severity":"critical","requires":["stage == production","stack.database != none"]},{"id":"DATA-013","tier":2,"severity":"critical","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"DATA-014","title":"User uploads stored on ephemeral disk","severity":"critical","requires":["has_file_uploads == true","stack.host in [vercel, netlify, railway, fly, render]"],"tier":1,"check":"writes to local fs on a platform with an ephemeral filesystem","note":"matches CRMini — files vanish on every redeploy.","origin":"gap","category":"data-and-scale"},{"id":"DATA-015","title":"User input concatenated into a SQL query","severity":"critical","tier":1,"check":"a request value interpolated into a SQL string instead of passed as a parameter","note":"the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.","origin":"gap","category":"data-and-scale"},{"id":"INF-001","tier":2,"severity":"medium","requires":["stage == production","expected_scale in [under-100k, over-100k]","stack.host in [vps, aws, gcp]"]},{"id":"INF-002","title":"No health check or auto-restart","severity":"medium","requires":["stage == production","stack.host in [vps, aws, gcp]"],"tier":1,"category":"infrastructure"},{"id":"INF-003","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-004","tier":2,"severity":"low","requires":["stage == production","expected_scale != hobby"]},{"id":"INF-005","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-006","title":"HTTPS not enforced","severity":"high","requires":["is_public == true"],"tier":1,"check":"no HSTS, no http->https redirect","category":"infrastructure"},{"id":"INF-007","title":"Permissive CORS","severity":"high","requires":["surface in [web-app, api-only]"],"tier":1,"check":"Access-Control-Allow-Origin: * on credentialed routes","origin":"gap","category":"infrastructure"},{"id":"INF-008","tier":2,"severity":"critical","requires":["stack.database != none","stack.host in [vps, aws, gcp]"]},{"id":"INF-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"INF-010","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"INF-011","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, marketplace]"]},{"id":"LEG-001","title":"No privacy policy while collecting personal data","severity":"high","requires":["data_sensitivity != none","is_public == true"],"tier":1,"note":"corpus is emphatic — a single contact form collecting an email is already personal data.","category":"legal"},{"id":"LEG-002","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LEG-003","title":"No terms of service","severity":"medium","requires":["is_public == true","has_accounts == true"],"tier":1,"category":"legal"},{"id":"LEG-004","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk, ca]"]},{"id":"LEG-005","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, ru, kz]"]},{"id":"LEG-006","title":"Accessibility baseline unmet","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"missing alt text, insufficient contrast, no heading hierarchy, unlabelled controls","note":"corpus flags active litigation risk in US and AU.","category":"legal"},{"id":"LEG-007","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]","business_model != hobby"]},{"id":"LEG-008","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"LEG-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [ru, kz, other]"]},{"id":"LEG-010","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"OBS-001","title":"No error monitoring","severity":"high","requires":["stage == production"],"tier":1,"origin":"gap","category":"observability"},{"id":"OBS-002","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"OBS-003","title":"No product analytics","severity":"low","requires":["stage in [pre-launch, production]","business_model != internal-tool"],"tier":1,"category":"observability"},{"id":"OBS-004","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"UX-001","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-002","tier":2,"severity":"high","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-003","tier":2,"severity":"low","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-004","tier":2,"severity":"low","requires":["stage in [pre-launch, production]","business_model in [b2c, b2b-saas]"]},{"id":"UX-005","title":"No social preview image","severity":"low","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"category":"ux-readiness"},{"id":"UX-006","title":"No sitemap.xml / not indexable","severity":"low","requires":["is_public == true","surface == web-site"],"tier":1,"category":"ux-readiness"},{"id":"AIOP-001","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-002","title":"Model id pinned to an alias that can silently change","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","note":"floating aliases shift behaviour and price under you with no deploy.","category":"ai-operations"},{"id":"AIOP-003","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-004","tier":2,"severity":"low","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-005","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-006","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-007","tier":2,"severity":"critical","requires":["has_rag == true"]},{"id":"AIOP-008","tier":2,"severity":"critical","requires":["has_rag == true","tenancy == multi-tenant-shared-db"]},{"id":"AIOP-009","tier":2,"severity":"medium","requires":["has_rag == true"]},{"id":"AIOP-010","title":"Agent loop has no iteration ceiling","severity":"critical","requires":["llm_tools_enabled == true"],"tier":1,"origin":"gap","note":"an unbounded loop is an unbounded invoice.","category":"ai-operations"},{"id":"AIOP-011","tier":2,"severity":"high","requires":["llm_tools_enabled == true"]},{"id":"AIOP-012","tier":2,"severity":"critical","requires":["llm_tools_enabled == true"]},{"id":"AIOP-013","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-014","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-015","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2c, b2b-saas]"]},{"id":"AIOP-016","title":"User-facing AI errors leak provider internals","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"AIOP-017","tier":2,"severity":"medium","requires":["calls_llm == true","is_public == true","business_model == b2c"]},{"id":"AIOP-018","tier":2,"severity":"critical","requires":["calls_llm == true","is_public == true"],"has_static_approximation":true,"title":"Free tier allows unauthenticated AI calls"},{"id":"AIOP-019","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AIOP-020","title":"Temperature or sampling unset for a deterministic task","severity":"low","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"DEP-001","title":"Secrets injected at build time instead of runtime","severity":"high","requires":["stage in [pre-launch, production]"],"tier":1,"note":"build-time inlining bakes the value into the artifact; rotation needs a rebuild.","category":"deployment"},{"id":"DEP-002","title":"Secrets present in CI logs or config","severity":"critical","requires":["has_ci == true"],"tier":1,"check":"plaintext credential in workflow yaml, or echoed to build output","category":"deployment"},{"id":"DEP-003","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-004","tier":2,"severity":"high","requires":["has_migrations == true","stage == production"]},{"id":"DEP-005","title":"Migrations are not reversible","severity":"medium","requires":["has_migrations == true","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-006","tier":2,"severity":"critical","requires":["has_migrations == true","stage == production"]},{"id":"DEP-007","tier":2,"severity":"high","requires":["stage == production"]},{"id":"DEP-008","title":"No health endpoint","severity":"medium","requires":["stage == production","surface in [web-app, api-only]"],"tier":1,"category":"deployment"},{"id":"DEP-009","title":"No graceful shutdown handling","severity":"medium","requires":["stage == production","has_background_jobs == true"],"tier":1,"check":"no SIGTERM handler draining in-flight work","category":"deployment"},{"id":"DEP-010","title":"Dependencies unpinned","severity":"medium","requires":["stage in [pre-launch, production]"],"tier":1,"check":"no lockfile committed, or ranges in a deployed manifest","category":"deployment"},{"id":"DEP-011","title":"Known-vulnerable dependencies","severity":"high","requires":[],"tier":1,"check":"audit surfaces advisories at high or critical","category":"deployment"},{"id":"DEP-012","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"DEP-013","title":"Third-party script loaded without integrity check","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"external <script> without SRI","category":"deployment"},{"id":"DEP-014","title":"Source maps published in production","severity":"medium","requires":["surface in [web-app, web-site]","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-015","title":"Debug mode or verbose errors enabled in production","severity":"high","requires":["stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-016","title":"No CI check gating merges","severity":"low","requires":["has_ci == true","business_model in [b2b-saas, marketplace]"],"tier":1,"category":"deployment"},{"id":"DEP-017","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-018","tier":2,"severity":"high","requires":["stage == production","stack.database != none"]},{"id":"RU-001","title":"Timestamps stored without timezone","severity":"high","requires":["stack.database != none"],"tier":1,"check":"timestamp column without time zone, or naive datetime written from app code","note":"works perfectly until your second user is in another country.","category":"real-user-readiness"},{"id":"RU-002","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"RU-003","title":"Money stored as float","severity":"critical","requires":["handles_payments != none or serves_currency == multi"],"tier":1,"check":"float/double/real column holding an amount","note":"rounding error in currency is a correctness bug, not a style issue.","category":"real-user-readiness"},{"id":"RU-004","title":"Currency not stored alongside amount","severity":"high","requires":["serves_currency == multi"],"tier":1,"category":"real-user-readiness"},{"id":"RU-005","tier":2,"severity":"low","requires":["data_sensitivity != none","audience_locale == multi"]},{"id":"RU-006","title":"Text input not accepting the full Unicode range","severity":"high","requires":["stack.database != none"],"tier":1,"note":"corpus — an emoji crashed a production app because the column was 3-byte utf8.","category":"real-user-readiness"},{"id":"RU-007","title":"No mobile viewport handling","severity":"high","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"RU-009","tier":2,"severity":"medium","requires":["has_accounts == true"]},{"id":"RU-010","title":"No support or contact channel","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"category":"real-user-readiness"},{"id":"RU-011","tier":2,"severity":"high","requires":["sends_email == true","stage == production"]},{"id":"RU-012","tier":2,"severity":"medium","requires":["sends_email == true","stage == production"]},{"id":"RU-013","title":"Marketing email without an unsubscribe path","severity":"high","requires":["sends_email == true","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-014","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RU-015","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"RU-016","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RU-017","tier":2,"severity":"high","requires":["has_accounts == true","stack.auth == custom"]},{"id":"RU-018","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"RU-019","title":"Session lifetime unbounded","severity":"medium","requires":["has_accounts == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-020","tier":2,"severity":"low","requires":["has_accounts == true","business_model in [b2c, b2b-saas]"]},{"id":"TEN-001","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","stack.database in [postgres, supabase]"]},{"id":"TEN-002","title":"No composite index on (tenant_id, ...) for hot queries","severity":"medium","requires":["tenancy == multi-tenant-shared-db"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-003","title":"Invitation token does not expire","severity":"high","requires":["has_invitations == true"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-004","tier":2,"severity":"critical","requires":["has_invitations == true","has_roles == true"]},{"id":"TEN-005","tier":2,"severity":"critical","requires":["has_roles == true"]},{"id":"TEN-006","tier":2,"severity":"critical","requires":["has_roles == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-007","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_accounts == true"]},{"id":"TEN-008","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","has_file_uploads == true"]},{"id":"TEN-009","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-010","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","has_subscriptions == true"]},{"id":"TEN-011","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","calls_llm == true"]},{"id":"TEN-012","tier":2,"severity":"high","requires":["has_admin_panel == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-013","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-014","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_webhooks_in == true"]},{"id":"UP-001","title":"File type validated by extension or client MIME only","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-002","title":"No upload size limit","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-003","title":"Upload path built from a user-supplied filename","severity":"critical","requires":["has_file_uploads == true"],"tier":1,"check":"filename concatenated into a storage path without normalisation","category":"file-uploads"},{"id":"UP-004","tier":2,"severity":"high","requires":["has_file_uploads == true","surface in [web-app, web-site]"]},{"id":"UP-005","title":"SVG accepted without sanitisation","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-006","tier":2,"severity":"medium","requires":["has_file_uploads == true","tenancy == multi-tenant-shared-db"]},{"id":"UP-007","title":"Signed URLs with excessive lifetime","severity":"medium","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-008","title":"EXIF metadata not stripped from images","severity":"medium","requires":["has_file_uploads == true","data_sensitivity != none"],"tier":1,"note":"photo uploads routinely carry GPS coordinates.","category":"file-uploads"},{"id":"UP-009","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-010","tier":2,"severity":"medium","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"UP-011","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-012","tier":2,"severity":"high","requires":["has_file_uploads == true"]},{"id":"API-001","title":"Mass assignment — request body spread into a model","severity":"critical","requires":["stack.database != none"],"tier":1,"check":"request body passed wholesale to create/update","note":"this is how a user sets their own is_admin.","category":"api-design"},{"id":"API-002","title":"Internal error details returned to the client","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-003","title":"Stack traces reachable in production responses","severity":"high","requires":["is_public == true","stage == production"],"tier":1,"category":"api-design"},{"id":"API-004","title":"No maximum page size","severity":"medium","requires":["is_public == true"],"tier":1,"check":"limit parameter accepted without an upper bound","category":"api-design"},{"id":"API-005","title":"Sort or filter parameter interpolated into a query","severity":"critical","requires":["stack.database != none"],"tier":1,"category":"api-design"},{"id":"API-006","title":"State-changing operation exposed over GET","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-007","tier":2,"severity":"high","requires":["has_accounts == true","surface in [web-app]"]},{"id":"API-008","tier":2,"severity":"low","requires":["surface == api-only"]},{"id":"API-009","tier":2,"severity":"low","requires":["has_accounts == true"]},{"id":"API-010","title":"No request timeout on outbound calls","severity":"medium","requires":["stage == production"],"tier":1,"category":"api-design"},{"id":"API-011","tier":2,"severity":"medium","requires":["surface == api-only","stage == production"]},{"id":"API-012","title":"Security headers missing","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"no CSP, X-Content-Type-Options, Referrer-Policy, frame ancestors","category":"api-design"},{"id":"API-013","title":"Open redirect","severity":"high","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"check":"redirect target read from a query parameter without an allowlist","category":"api-design"},{"id":"API-014","tier":2,"severity":"critical","requires":["is_public == true"],"has_static_approximation":true,"title":"SSRF — outbound request to a user-supplied URL"},{"id":"NEXT-001","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-002","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-003","tier":2,"severity":"high","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-004","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-005","tier":2,"severity":"high","requires":["stack.framework == next"]},{"id":"NEXT-006","title":"Secret imported into a module reachable from the client graph","severity":"critical","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-007","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-008","tier":2,"severity":"medium","requires":["stack.framework == next"]},{"id":"NEXT-009","title":"Image optimizer allows arbitrary remote hosts","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"remotePatterns with a wildcard hostname","category":"framework-next"},{"id":"NEXT-010","title":"Route handler lacks runtime/duration config for long work","severity":"medium","requires":["stack.framework == next","calls_llm == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-011","title":"Error boundary absent","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"no error.tsx / global-error.tsx","category":"framework-next"},{"id":"NEXT-012","title":"Loading UI absent on data routes","severity":"low","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-013","title":"Cookies set without secure attributes","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-014","title":"Redirect after login uses an unvalidated next parameter","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"SUP-001","title":"Storage bucket public by default","severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"],"tier":1,"note":"mirrors the RLS problem — permissive default, silent exposure.","category":"platform-supabase"},{"id":"SUP-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"]},{"id":"SUP-003","tier":2,"severity":"critical","requires":["stack.database == supabase","has_realtime == true"]},{"id":"SUP-004","tier":2,"severity":"critical","requires":["stack.database == supabase"]},{"id":"SUP-005","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"]},{"id":"SUP-006","title":"Postgres function marked SECURITY DEFINER without a search_path","severity":"high","requires":["stack.database in [supabase, postgres]"],"tier":1,"category":"platform-supabase"},{"id":"SUP-007","tier":2,"severity":"high","requires":["stack.database == supabase"]},{"id":"SUP-008","tier":2,"severity":"low","requires":["stack.auth == supabase-auth","stage == production"]},{"id":"SUP-009","tier":2,"severity":"critical","requires":["stack.database in [supabase, postgres]","has_accounts == true"]},{"id":"SUP-010","tier":2,"severity":"high","requires":["stack.database == supabase","has_webhooks_in == true"]},{"id":"SUP-011","title":"Connection string uses the direct port under serverless","severity":"high","requires":["stack.database == supabase","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"use the pooler; direct connections exhaust under serverless fan-out.","category":"platform-supabase"},{"id":"SUP-012","tier":2,"severity":"high","requires":["stack.database == supabase","stage == production"]},{"id":"LIFE-001","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-002","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-003","tier":2,"severity":"high","requires":["data_sensitivity != none"]},{"id":"LIFE-004","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-005","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-006","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-007","tier":2,"severity":"critical","requires":["data_sensitivity != none"]},{"id":"LIFE-008","tier":2,"severity":"critical","requires":["stage == production","data_sensitivity != none"]},{"id":"LIFE-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-010","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-011","tier":2,"severity":"medium","requires":["business_model == b2b-saas","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-012","tier":2,"severity":"critical","requires":["data_sensitivity == children"]},{"id":"MOB-001","title":"Secret embedded in the app binary","severity":"critical","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"note":"a shipped binary is a public file. anything in it is published.","category":"mobile"},{"id":"MOB-002","title":"Token stored outside the platform secure store","severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_accounts == true"],"tier":1,"check":"credential in UserDefaults / SharedPreferences rather than Keychain / Keystore","category":"mobile"},{"id":"MOB-003","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"MOB-004","title":"Permissions requested without a usage description","severity":"high","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"category":"mobile"},{"id":"MOB-005","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-006","tier":2,"severity":"medium","requires":["surface == mobile-ios","has_accounts == true"]},{"id":"MOB-007","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_subscriptions == true"]},{"id":"MOB-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","stage == production"]},{"id":"MOB-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-010","tier":2,"severity":"low","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"INC-001","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"INC-002","tier":2,"severity":"high","requires":["stage == production","calls_llm == true"]},{"id":"INC-003","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, b2c]"]},{"id":"INC-004","title":"No security contact or disclosure path","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"origin":"gap","check":"no security.txt, no security contact","category":"incident-readiness"},{"id":"INC-005","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"INC-006","tier":2,"severity":"critical","requires":["calls_llm == true","stage == production"]},{"id":"INC-007","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"INC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"INC-009","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"INC-010","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"DJ-001","title":"DEBUG is on in a deployed setting","severity":"critical","requires":["stack.framework == django","stage in [pre-launch, production]"],"tier":1,"check":"DEBUG = True in a settings module not named local/dev","note":"Django's debug page prints settings, installed apps, the SQL that ran and a full traceback to whoever triggers the error. Django's own docs call deploying with it on a security problem.","fix":"DEBUG = False in production, and set ALLOWED_HOSTS.","category":"framework-django"},{"id":"DJ-002","title":"SECRET_KEY written into source","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SECRET_KEY assigned a literal rather than read from the environment","note":"SECRET_KEY signs sessions, password-reset tokens and CSRF tokens. Anyone holding it can forge a session for any user.","fix":"Read it from an environment variable and rotate the committed value.","category":"framework-django"},{"id":"DJ-003","title":"ALLOWED_HOSTS accepts any host","severity":"high","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"ALLOWED_HOSTS contains '*'","note":"enables host-header poisoning, which turns password-reset emails into links pointing at an attacker's domain.","category":"framework-django"},{"id":"DJ-004","title":"CSRF middleware removed","severity":"critical","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"MIDDLEWARE lacks django.middleware.csrf.CsrfViewMiddleware","note":"it ships enabled. Its absence means someone deliberately removed it.","category":"framework-django"},{"id":"DJ-005","tier":2,"severity":"critical","requires":["stack.framework == django"]},{"id":"DJ-006","title":"Raw SQL built by interpolation","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":".raw() or .extra() containing an f-string, % or .format()","fix":"Pass parameters: .raw('SELECT … WHERE id = %s', [id])","category":"framework-django"},{"id":"DJ-007","title":"HTTPS not enforced","severity":"high","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_SSL_REDIRECT absent or False","category":"framework-django"},{"id":"DJ-008","title":"Session and CSRF cookies not marked secure","severity":"high","requires":["stack.framework == django","has_accounts == true","stage == production"],"tier":1,"check":"SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE missing or False","note":"without these the cookies travel over plain HTTP on the first request.","category":"framework-django"},{"id":"DJ-009","title":"HSTS not configured","severity":"medium","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_HSTS_SECONDS is 0 or unset","category":"framework-django"},{"id":"DJ-010","title":"ModelForm or serializer exposes every field","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"fields = '__all__' on a ModelForm or ModelSerializer","note":"Django's own mass-assignment shape. Every column becomes writable from the request, including is_staff, is_superuser and any balance you keep on the model.","fix":"List the fields you actually accept.","category":"framework-django"},{"id":"DJ-011","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-012","title":"Password validators removed","severity":"medium","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"AUTH_PASSWORD_VALIDATORS is an empty list","category":"framework-django"},{"id":"DJ-013","title":"Clickjacking protection disabled","severity":"medium","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"XFrameOptionsMiddleware absent, or X_FRAME_OPTIONS set to ALLOWALL","category":"framework-django"},{"id":"DJ-014","title":"Sessions serialised with pickle","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SESSION_SERIALIZER set to PickleSerializer","note":"combined with a leaked SECRET_KEY this is remote code execution, not just session forgery.","category":"framework-django"},{"id":"DJ-015","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-016","title":"DEBUG toolbar or dev-only app installed in production","severity":"high","requires":["stack.framework == django","stage == production"],"tier":1,"check":"debug_toolbar, django_extensions or silk present in production INSTALLED_APPS","category":"framework-django"},{"id":"DJ-017","tier":2,"severity":"medium","requires":["stack.framework == django","has_file_uploads == true","stage == production"]},{"id":"DJ-018","tier":2,"severity":"high","requires":["stack.framework == django","has_accounts == true","is_public == true"]},{"id":"RB-001","title":"secret_key_base committed to the repository","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"a literal secret_key_base in config/secrets.yml, credentials.yml or an initializer","note":"signs every session cookie. With it, anyone mints a session for any user — and Rails historically deserialised session data, so it has meant RCE.","fix":"Use encrypted credentials or ENV, and rotate the exposed key.","category":"framework-rails"},{"id":"RB-002","title":"config.force_ssl not enabled in production","severity":"high","requires":["stack.framework == rails","stage == production","is_public == true"],"tier":1,"check":"config.force_ssl absent or false in config/environments/production.rb","note":"one line, and it enables the redirect, secure cookies and HSTS together.","category":"framework-rails"},{"id":"RB-003","title":"CSRF protection disabled or skipped","severity":"critical","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"skip_before_action :verify_authenticity_token, or protect_from_forgery with: :null_session on a state-changing controller","category":"framework-rails"},{"id":"RB-004","title":"html_safe or raw applied to user content","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":".html_safe or raw() on a value derived from params, or <%== in an ERB template","note":"ERB escapes by default. These are the ways to turn that off.","category":"framework-rails"},{"id":"RB-005","title":"SQL built by string interpolation","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"where(\"… #{…}\"), find_by_sql or order() containing interpolation","fix":"where('email = ?', email) — the placeholder form.","category":"framework-rails"},{"id":"RB-006","title":"Strong parameters bypassed with permit!","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"params.permit! or params.require(:x).permit!","note":"permit! whitelists everything the caller sent. Rails added strong parameters precisely because of the GitHub mass-assignment incident.","category":"framework-rails"},{"id":"RB-007","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-008","title":"Unsafe deserialisation of user input","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"Marshal.load, YAML.load or Oj.load applied to request data","note":"each of these instantiates arbitrary Ruby objects. Use YAML.safe_load.","category":"framework-rails"},{"id":"RB-009","title":"File served from a user-supplied path","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"send_file or render file: built from params","note":"../../config/master.key is a valid filename as far as the filesystem cares.","category":"framework-rails"},{"id":"RB-010","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-011","title":"Sensitive parameters not filtered from logs","severity":"high","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"config.filter_parameters omits password, token or secret","note":"otherwise plaintext passwords are written into production logs on every sign-in, and logs are backed up, shipped and searched.","category":"framework-rails"},{"id":"RB-012","title":"Detailed exception pages enabled in production","severity":"high","requires":["stack.framework == rails","stage == production"],"tier":1,"check":"config.consider_all_requests_local = true in production.rb","note":"turns every 500 into a stack trace with source and local variables.","category":"framework-rails"},{"id":"RB-013","tier":2,"severity":"high","requires":["stack.framework == rails"]},{"id":"RB-014","title":"Devise configured without lockable or timeout","severity":"medium","requires":["stack.framework == rails","stack.auth == devise","is_public == true"],"tier":1,"check":"devise model lacks :lockable, or Devise.timeout_in is unset","note":"Devise ships neither brute-force lockout nor session expiry enabled.","category":"framework-rails"},{"id":"RB-015","tier":2,"severity":"medium","requires":["stack.framework == rails","has_background_jobs == true"]}]}
package/src/workspace.mjs CHANGED
Binary file