launchprep 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rules.json ADDED
@@ -0,0 +1,3651 @@
1
+ {
2
+ "version": "0.1",
3
+ "count": 288,
4
+ "rules": [
5
+ {
6
+ "id": "SEC-001",
7
+ "title": ".env committed to version control",
8
+ "severity": "critical",
9
+ "requires": [],
10
+ "tier": 1,
11
+ "check": "git ls-files matches ^\\.env(\\.|$) and not .env.example",
12
+ "fix": "git rm --cached .env; add to .gitignore; ROTATE every key in it",
13
+ "note": "rotation is the part everyone skips. history retains the file.",
14
+ "sources": [
15
+ "DXmVjvwjpgQ",
16
+ "DYsfrtjJ608",
17
+ "DYiqd6pJ3d8"
18
+ ],
19
+ "category": "secrets"
20
+ },
21
+ {
22
+ "id": "SEC-002",
23
+ "title": ".env absent from .gitignore",
24
+ "severity": "high",
25
+ "requires": [],
26
+ "tier": 1,
27
+ "check": ".gitignore lacks a pattern matching .env",
28
+ "sources": [
29
+ "DYsfrtjJ608"
30
+ ],
31
+ "category": "secrets"
32
+ },
33
+ {
34
+ "id": "SEC-003",
35
+ "title": "Hardcoded API key / token / password in source",
36
+ "severity": "critical",
37
+ "requires": [],
38
+ "tier": 1,
39
+ "check": "entropy + provider prefix scan (sk-, sk-ant-, AKIA, ghp_, xoxb-, eyJ...) outside .env",
40
+ "sources": [
41
+ "DWNKkeHkTLF",
42
+ "DZE5kabhdnI"
43
+ ],
44
+ "category": "secrets"
45
+ },
46
+ {
47
+ "id": "SEC-004",
48
+ "title": "Server-only secret exposed to the client bundle",
49
+ "severity": "critical",
50
+ "requires": [
51
+ "surface in [web-app, web-site]"
52
+ ],
53
+ "tier": 1,
54
+ "check": "NEXT_PUBLIC_/VITE_/REACT_APP_ prefix on a name matching (KEY|SECRET|TOKEN|PASSWORD), or a secret literal reachable from a client component",
55
+ "note": "the single most common vibe-coder leak. the prefix makes it PUBLIC, and the name reads like it is protected.",
56
+ "sources": [
57
+ "DWNKkeHkTLF",
58
+ "DXmVjvwjpgQ"
59
+ ],
60
+ "category": "secrets"
61
+ },
62
+ {
63
+ "id": "SEC-005",
64
+ "title": "Supabase service_role key used outside a server context",
65
+ "severity": "critical",
66
+ "requires": [
67
+ "stack.database == supabase"
68
+ ],
69
+ "tier": 1,
70
+ "check": "service_role key referenced in a client component, edge config, or NEXT_PUBLIC_ var",
71
+ "note": "service_role bypasses RLS entirely. in the browser it is a full database handover.",
72
+ "sources": [
73
+ "DXvKgfFJF7a"
74
+ ],
75
+ "category": "secrets"
76
+ },
77
+ {
78
+ "id": "SEC-006",
79
+ "title": "Paid-API key called directly from the browser",
80
+ "severity": "critical",
81
+ "requires": [
82
+ "calls_llm == true"
83
+ ],
84
+ "tier": 1,
85
+ "check": "LLM SDK instantiated in client-side code, or provider endpoint called from browser",
86
+ "fix": "proxy through your own server route; never ship the provider key",
87
+ "sources": [
88
+ "DZ7qlTboUvg"
89
+ ],
90
+ "category": "secrets"
91
+ },
92
+ {
93
+ "id": "SEC-007",
94
+ "title": "No key-rotation path",
95
+ "severity": "medium",
96
+ "requires": [
97
+ "stage == production"
98
+ ],
99
+ "tier": 2,
100
+ "check": "keys are read from a single hardcoded env var with no versioning or rotation runbook",
101
+ "note": "corpus says 'rotate immediately' but nobody ships a way TO rotate.",
102
+ "sources": [
103
+ "DYsfrtjJ608"
104
+ ],
105
+ "category": "secrets"
106
+ },
107
+ {
108
+ "id": "SEC-008",
109
+ "title": "Long-lived credential where a short-lived one is available",
110
+ "severity": "medium",
111
+ "requires": [
112
+ "stage == production"
113
+ ],
114
+ "tier": 2,
115
+ "check": "static API key used against a provider that supports STS / short-lived tokens",
116
+ "sources": [
117
+ "DZE5kabhdnI"
118
+ ],
119
+ "category": "secrets"
120
+ },
121
+ {
122
+ "id": "AUTH-001",
123
+ "title": "Session token stored in localStorage",
124
+ "severity": "high",
125
+ "requires": [
126
+ "has_accounts == true",
127
+ "surface in [web-app, web-site]"
128
+ ],
129
+ "tier": 1,
130
+ "check": "localStorage.setItem / getItem with a token-like key",
131
+ "fix": "httpOnly + Secure + SameSite cookie",
132
+ "note": "localStorage is readable by any XSS. a cookie with httpOnly is not.",
133
+ "sources": [
134
+ "DZU1RKoT5Nc"
135
+ ],
136
+ "category": "authentication"
137
+ },
138
+ {
139
+ "id": "AUTH-002",
140
+ "title": "Role or permission read from client-controlled storage",
141
+ "severity": "critical",
142
+ "requires": [
143
+ "has_accounts == true"
144
+ ],
145
+ "tier": 1,
146
+ "check": "role/isAdmin/plan/tier read from localStorage, sessionStorage, cookie, or JWT claim without server-side re-verification",
147
+ "note": "the corpus demo is literally 'open Application storage, change Member to Admin, refresh'.",
148
+ "sources": [
149
+ "DZsXW-HonCi",
150
+ "DZU1RKoT5Nc"
151
+ ],
152
+ "category": "authentication"
153
+ },
154
+ {
155
+ "id": "AUTH-003",
156
+ "title": "No brute-force limit on login",
157
+ "severity": "high",
158
+ "requires": [
159
+ "has_accounts == true"
160
+ ],
161
+ "tier": 1,
162
+ "check": "login route has no attempt counter / lockout / rate limit",
163
+ "fix": "max 5 attempts per identifier per 15 min, plus IP-level cap",
164
+ "sources": [
165
+ "DWNKkeHkTLF",
166
+ "DXrtdLeDIfy"
167
+ ],
168
+ "category": "authentication"
169
+ },
170
+ {
171
+ "id": "AUTH-004",
172
+ "title": "Password reset endpoint unthrottled",
173
+ "severity": "high",
174
+ "requires": [
175
+ "has_accounts == true",
176
+ "sends_email == true"
177
+ ],
178
+ "tier": 1,
179
+ "check": "password-reset route lacks rate limiting",
180
+ "note": "corpus example \u2014 67,000 requests at 2am against one reset endpoint.",
181
+ "sources": [
182
+ "DakvEI-xDZ4"
183
+ ],
184
+ "category": "authentication"
185
+ },
186
+ {
187
+ "id": "AUTH-005",
188
+ "title": "No email verification \u2014 anyone can register as anyone",
189
+ "severity": "medium",
190
+ "requires": [
191
+ "has_accounts == true",
192
+ "is_public == true"
193
+ ],
194
+ "tier": 2,
195
+ "sources": [
196
+ "DZU1RKoT5Nc"
197
+ ],
198
+ "category": "authentication"
199
+ },
200
+ {
201
+ "id": "AUTH-006",
202
+ "title": "No 2FA option for privileged accounts",
203
+ "severity": "medium",
204
+ "requires": [
205
+ "has_admin_panel == true",
206
+ "stage == production"
207
+ ],
208
+ "tier": 2,
209
+ "sources": [
210
+ "DZU1RKoT5Nc",
211
+ "DXrtdLeDIfy"
212
+ ],
213
+ "category": "authentication"
214
+ },
215
+ {
216
+ "id": "AUTH-007",
217
+ "title": "Session not invalidated on logout",
218
+ "severity": "high",
219
+ "requires": [
220
+ "has_accounts == true"
221
+ ],
222
+ "tier": 2,
223
+ "check": "logout clears client state only; token remains valid server-side",
224
+ "note": "corpus test \u2014 'log out, paste the old URL back, are you still in?'",
225
+ "sources": [
226
+ "DY-mNQ_PGRh"
227
+ ],
228
+ "category": "authentication"
229
+ },
230
+ {
231
+ "id": "AUTH-008",
232
+ "title": "Password stored without a modern KDF",
233
+ "severity": "critical",
234
+ "requires": [
235
+ "stack.auth == custom"
236
+ ],
237
+ "tier": 1,
238
+ "check": "password hashing absent, or md5/sha1/sha256 without bcrypt/argon2/scrypt",
239
+ "sources": [
240
+ "DWKYy3tE-Vq"
241
+ ],
242
+ "category": "authentication"
243
+ },
244
+ {
245
+ "id": "AUTH-009",
246
+ "title": "No credential-stuffing defence",
247
+ "severity": "medium",
248
+ "requires": [
249
+ "has_accounts == true",
250
+ "stage == production",
251
+ "is_public == true"
252
+ ],
253
+ "tier": 2,
254
+ "check": "no breached-password check, no anomalous-login detection",
255
+ "sources": [
256
+ "DXrtdLeDIfy"
257
+ ],
258
+ "category": "authentication"
259
+ },
260
+ {
261
+ "id": "AUTH-010",
262
+ "title": "SSH exposed to the internet without a VPN or bastion",
263
+ "severity": "high",
264
+ "requires": [
265
+ "stack.host in [vps, aws, gcp]"
266
+ ],
267
+ "tier": 2,
268
+ "fix": "Tailscale / WireGuard / bastion; close port 22 publicly",
269
+ "sources": [
270
+ "DaITYf0IdHs"
271
+ ],
272
+ "category": "authentication"
273
+ },
274
+ {
275
+ "id": "AUTHZ-001",
276
+ "title": "Supabase table with RLS disabled",
277
+ "severity": "critical",
278
+ "requires": [
279
+ "stack.database == supabase",
280
+ "has_accounts == true"
281
+ ],
282
+ "tier": 1,
283
+ "check": "table in migrations without ENABLE ROW LEVEL SECURITY",
284
+ "note": "corpus calls this the single most common vulnerability found across startups AND enterprises. RLS is OFF by default for raw-SQL tables.",
285
+ "sources": [
286
+ "DXvKgfFJF7a",
287
+ "DYsfrtjJ608",
288
+ "DXrtdLeDIfy"
289
+ ],
290
+ "category": "authorization"
291
+ },
292
+ {
293
+ "id": "AUTHZ-002",
294
+ "title": "RLS enabled but policy is permissive",
295
+ "severity": "critical",
296
+ "requires": [
297
+ "stack.database == supabase",
298
+ "has_accounts == true"
299
+ ],
300
+ "tier": 2,
301
+ "has_static_approximation": true,
302
+ "check": "policy USING (true) or lacking an auth.uid() ownership predicate",
303
+ "note": "reads as protected in the dashboard, is not. worse than RLS being off, because it produces false confidence.",
304
+ "sources": [
305
+ "DXvKgfFJF7a"
306
+ ],
307
+ "category": "authorization"
308
+ },
309
+ {
310
+ "id": "AUTHZ-003",
311
+ "title": "IDOR \u2014 object accessed by ID without an ownership check",
312
+ "severity": "critical",
313
+ "requires": [
314
+ "has_accounts == true"
315
+ ],
316
+ "tier": 2,
317
+ "has_static_approximation": true,
318
+ "check": "route reads a path/query ID and queries by it without filtering on the session user",
319
+ "note": "corpus test \u2014 'paste another user's URL into your browser, what happens?'",
320
+ "sources": [
321
+ "DY-mNQ_PGRh",
322
+ "DWKYy3tE-Vq",
323
+ "DXkMgEJERJg"
324
+ ],
325
+ "category": "authorization"
326
+ },
327
+ {
328
+ "id": "AUTHZ-004",
329
+ "title": "Admin authorization enforced only in the UI",
330
+ "severity": "critical",
331
+ "requires": [
332
+ "has_admin_panel == true"
333
+ ],
334
+ "tier": 2,
335
+ "has_static_approximation": true,
336
+ "check": "admin route/component guarded client-side with no server-side role check",
337
+ "sources": [
338
+ "DaITYf0IdHs",
339
+ "DZU1RKoT5Nc"
340
+ ],
341
+ "category": "authorization"
342
+ },
343
+ {
344
+ "id": "AUTHZ-005",
345
+ "title": "Usage limits stored in a row the user can modify",
346
+ "severity": "critical",
347
+ "requires": [
348
+ "calls_llm == true",
349
+ "has_accounts == true"
350
+ ],
351
+ "tier": 2,
352
+ "has_static_approximation": true,
353
+ "check": "quota/credit/token-limit column on a table the user has write access to",
354
+ "note": "THE canonical AI-app breach in this corpus \u2014 a user cleared their own AI usage limit, and could have cleared everyone's. $10k exposure.",
355
+ "sources": [
356
+ "DXvKgfFJF7a",
357
+ "DZ-AORszRRS"
358
+ ],
359
+ "category": "authorization"
360
+ },
361
+ {
362
+ "id": "AUTHZ-006",
363
+ "title": "Missing tenant scope on a domain query",
364
+ "severity": "critical",
365
+ "requires": [
366
+ "tenancy == multi-tenant-shared-db"
367
+ ],
368
+ "tier": 2,
369
+ "has_static_approximation": true,
370
+ "check": "query against a tenant-scoped table without a tenant_id/org_id predicate",
371
+ "gate_note": "SKIPPED for single-user apps \u2014 this is the rule that would tell WhatsApp it is missing Stories.",
372
+ "sources": [
373
+ "DXvKgfFJF7a"
374
+ ],
375
+ "category": "authorization"
376
+ },
377
+ {
378
+ "id": "AUTHZ-007",
379
+ "title": "Tenant id taken from the request instead of the session",
380
+ "severity": "critical",
381
+ "requires": [
382
+ "tenancy == multi-tenant-shared-db"
383
+ ],
384
+ "tier": 2,
385
+ "check": "org_id/tenant_id read from body, query, or header rather than the verified session",
386
+ "sources": [],
387
+ "category": "authorization"
388
+ },
389
+ {
390
+ "id": "AUTHZ-008",
391
+ "title": "Sensitive columns colocated with user-writable data",
392
+ "severity": "high",
393
+ "requires": [
394
+ "has_accounts == true"
395
+ ],
396
+ "tier": 2,
397
+ "check": "credits/role/limits/is_admin on the same table the user can update",
398
+ "note": "corpus \u2014 'he put the users in the same table as token management, so as he's counting, people can see it'",
399
+ "sources": [
400
+ "DZ-AORszRRS"
401
+ ],
402
+ "category": "authorization"
403
+ },
404
+ {
405
+ "id": "RATE-001",
406
+ "title": "No rate limiting on any endpoint",
407
+ "severity": "high",
408
+ "requires": [
409
+ "is_public == true"
410
+ ],
411
+ "tier": 1,
412
+ "check": "no rate-limit middleware, no gateway policy, no per-route limiter",
413
+ "sources": [
414
+ "DWNKkeHkTLF",
415
+ "DXrtdLeDIfy",
416
+ "DaFxeS7tBNf"
417
+ ],
418
+ "category": "rate-limiting"
419
+ },
420
+ {
421
+ "id": "RATE-002",
422
+ "title": "Unauthenticated endpoint limited only per-user",
423
+ "severity": "medium",
424
+ "requires": [
425
+ "is_public == true"
426
+ ],
427
+ "tier": 2,
428
+ "check": "rate limiter keys on user id for routes reachable before login",
429
+ "note": "corpus correction from a security professional \u2014 on a public endpoint the caller has no identity yet, so user-based limiting does nothing. key on IP + fingerprint + global ceiling.",
430
+ "sources": [
431
+ "DZE5kabhdnI"
432
+ ],
433
+ "category": "rate-limiting"
434
+ },
435
+ {
436
+ "id": "RATE-003",
437
+ "title": "No spend ceiling on paid-API endpoints",
438
+ "severity": "critical",
439
+ "requires": [
440
+ "calls_llm == true"
441
+ ],
442
+ "tier": 2,
443
+ "has_static_approximation": true,
444
+ "check": "no global daily/monthly cost cap enforced before calling the provider",
445
+ "note": "corpus scenarios \u2014 $50,000 Anthropic bill, $20k AWS invoice, $4,000 overnight. a per-request rate limit does NOT bound total spend.",
446
+ "sources": [
447
+ "DY4xvIAAo-m",
448
+ "DZ7qlTboUvg",
449
+ "DYsfrtjJ608"
450
+ ],
451
+ "category": "rate-limiting"
452
+ },
453
+ {
454
+ "id": "RATE-004",
455
+ "title": "No per-user quota on AI features",
456
+ "severity": "high",
457
+ "requires": [
458
+ "calls_llm == true",
459
+ "has_accounts == true"
460
+ ],
461
+ "tier": 2,
462
+ "has_static_approximation": true,
463
+ "check": "no per-user token/request budget enforced server-side",
464
+ "sources": [
465
+ "DXvKgfFJF7a",
466
+ "DYsfrtjJ608"
467
+ ],
468
+ "category": "rate-limiting"
469
+ },
470
+ {
471
+ "id": "RATE-005",
472
+ "title": "Outbound email unthrottled",
473
+ "severity": "high",
474
+ "requires": [
475
+ "sends_email == true"
476
+ ],
477
+ "tier": 2,
478
+ "check": "transactional email send has no per-user or global rate limit",
479
+ "note": "corpus \u2014 a live attack creating fake users to spam a GDPR-request email endpoint. the author survived only because email was rate limited.",
480
+ "sources": [
481
+ "DZmTu7Kgr0W"
482
+ ],
483
+ "category": "rate-limiting"
484
+ },
485
+ {
486
+ "id": "RATE-006",
487
+ "title": "No IP ban / blocklist mechanism",
488
+ "severity": "medium",
489
+ "requires": [
490
+ "is_public == true",
491
+ "stage == production"
492
+ ],
493
+ "tier": 2,
494
+ "note": "rate limiting slows an attacker; banning stops one. corpus author was mid-attack and had no way to block the source.",
495
+ "sources": [
496
+ "DZmTu7Kgr0W"
497
+ ],
498
+ "category": "rate-limiting"
499
+ },
500
+ {
501
+ "id": "RATE-007",
502
+ "title": "No bot protection on signup",
503
+ "severity": "medium",
504
+ "requires": [
505
+ "has_accounts == true",
506
+ "is_public == true"
507
+ ],
508
+ "tier": 2,
509
+ "check": "no captcha / turnstile / proof-of-work on registration",
510
+ "sources": [
511
+ "DZmTu7Kgr0W",
512
+ "DWKYy3tE-Vq"
513
+ ],
514
+ "category": "rate-limiting"
515
+ },
516
+ {
517
+ "id": "AI-001",
518
+ "title": "User input reaches the system-prompt position",
519
+ "severity": "critical",
520
+ "requires": [
521
+ "calls_llm == true",
522
+ "llm_input_from_user == true"
523
+ ],
524
+ "tier": 2,
525
+ "has_static_approximation": true,
526
+ "check": "request-derived string concatenated into the system role",
527
+ "fix": "user content goes in the user turn, wrapped in explicit delimiters, never interpolated into system instructions",
528
+ "sources": [
529
+ "DYsfrtjJ608",
530
+ "DaITYf0IdHs"
531
+ ],
532
+ "category": "ai-safety"
533
+ },
534
+ {
535
+ "id": "AI-002",
536
+ "title": "No delimiting of untrusted content",
537
+ "severity": "high",
538
+ "requires": [
539
+ "calls_llm == true",
540
+ "llm_input_from_user == true"
541
+ ],
542
+ "tier": 2,
543
+ "check": "user or retrieved content injected into a prompt with no delimiter or instruction-hierarchy framing",
544
+ "sources": [
545
+ "DYsfrtjJ608"
546
+ ],
547
+ "category": "ai-safety"
548
+ },
549
+ {
550
+ "id": "AI-003",
551
+ "title": "No max_tokens ceiling on completions",
552
+ "severity": "high",
553
+ "requires": [
554
+ "calls_llm == true"
555
+ ],
556
+ "tier": 1,
557
+ "origin": "gap",
558
+ "check": "provider call omits max_tokens, or sets it above the plan's economics",
559
+ "note": "unbounded output is unbounded cost, per request.",
560
+ "category": "ai-safety"
561
+ },
562
+ {
563
+ "id": "AI-004",
564
+ "title": "No model allowlist",
565
+ "severity": "medium",
566
+ "requires": [
567
+ "calls_llm == true"
568
+ ],
569
+ "tier": 1,
570
+ "origin": "gap",
571
+ "check": "model id derived from request input, or no constant/allowlist",
572
+ "note": "a client-chosen model is a client-chosen price.",
573
+ "category": "ai-safety"
574
+ },
575
+ {
576
+ "id": "AI-005",
577
+ "title": "No provider-side budget alert configured",
578
+ "severity": "high",
579
+ "requires": [
580
+ "calls_llm == true",
581
+ "stage == production"
582
+ ],
583
+ "tier": 2,
584
+ "origin": "gap",
585
+ "note": "the last line of defence when application limits fail. every major provider offers it; almost nobody turns it on.",
586
+ "category": "ai-safety"
587
+ },
588
+ {
589
+ "id": "AI-006",
590
+ "title": "LLM tool/function scope exceeds least privilege",
591
+ "severity": "critical",
592
+ "requires": [
593
+ "calls_llm == true",
594
+ "llm_tools_enabled == true"
595
+ ],
596
+ "tier": 2,
597
+ "origin": "gap",
598
+ "check": "model-callable tools expose destructive or cross-tenant operations without a confirmation gate",
599
+ "note": "prompt injection plus a delete tool is remote code execution with extra steps.",
600
+ "sources": [
601
+ "DaITYf0IdHs"
602
+ ],
603
+ "category": "ai-safety"
604
+ },
605
+ {
606
+ "id": "AI-007",
607
+ "title": "Model output rendered as HTML without sanitisation",
608
+ "severity": "critical",
609
+ "requires": [
610
+ "calls_llm == true",
611
+ "surface in [web-app, web-site]"
612
+ ],
613
+ "tier": 1,
614
+ "origin": "gap",
615
+ "check": "completion piped to dangerouslySetInnerHTML / v-html / innerHTML",
616
+ "note": "injection in, XSS out.",
617
+ "category": "ai-safety"
618
+ },
619
+ {
620
+ "id": "AI-008",
621
+ "title": "Model output used in a privileged operation without validation",
622
+ "severity": "critical",
623
+ "requires": [
624
+ "calls_llm == true"
625
+ ],
626
+ "tier": 2,
627
+ "has_static_approximation": true,
628
+ "origin": "gap",
629
+ "check": "completion flows into a SQL string, shell command, file path, fetch URL, or eval",
630
+ "category": "ai-safety"
631
+ },
632
+ {
633
+ "id": "AI-009",
634
+ "title": "No streaming timeout / runaway-generation guard",
635
+ "severity": "medium",
636
+ "requires": [
637
+ "calls_llm == true"
638
+ ],
639
+ "tier": 2,
640
+ "origin": "gap",
641
+ "check": "streamed completion with no abort signal or wall-clock bound",
642
+ "category": "ai-safety"
643
+ },
644
+ {
645
+ "id": "AI-010",
646
+ "title": "Prompts and completions logged with user PII",
647
+ "severity": "high",
648
+ "requires": [
649
+ "calls_llm == true",
650
+ "data_sensitivity != none"
651
+ ],
652
+ "tier": 2,
653
+ "origin": "gap",
654
+ "note": "prompt logs are the most-forgotten PII store in an AI app, and they are exactly what a GDPR deletion request has to reach.",
655
+ "category": "ai-safety"
656
+ },
657
+ {
658
+ "id": "AI-011",
659
+ "title": "No retry/backoff on provider 429s",
660
+ "severity": "medium",
661
+ "requires": [
662
+ "calls_llm == true",
663
+ "stage == production"
664
+ ],
665
+ "tier": 1,
666
+ "origin": "gap",
667
+ "category": "ai-safety"
668
+ },
669
+ {
670
+ "id": "AI-012",
671
+ "title": "Serverless host used for long-running agent work",
672
+ "severity": "high",
673
+ "requires": [
674
+ "calls_llm == true",
675
+ "stack.host in [vercel, netlify]"
676
+ ],
677
+ "tier": 2,
678
+ "check": "agent loop or multi-minute generation on a function with a hard timeout",
679
+ "note": "corpus makes this claim loudly. it is correct for long-running agents and wrong for simple completions \u2014 gate on actual expected duration, and warn rather than fail.",
680
+ "sources": [
681
+ "DYfCwY4NUFm"
682
+ ],
683
+ "category": "ai-safety"
684
+ },
685
+ {
686
+ "id": "AI-013",
687
+ "title": "No cost attribution per user or tenant",
688
+ "severity": "medium",
689
+ "requires": [
690
+ "calls_llm == true",
691
+ "business_model in [b2b-saas, b2c, marketplace]"
692
+ ],
693
+ "tier": 2,
694
+ "origin": "gap",
695
+ "note": "without it you cannot tell abuse from success, or price the product.",
696
+ "category": "ai-safety"
697
+ },
698
+ {
699
+ "id": "PAY-001",
700
+ "title": "Webhook signature not verified",
701
+ "severity": "critical",
702
+ "requires": [
703
+ "handles_payments != none",
704
+ "has_webhooks_in == true"
705
+ ],
706
+ "tier": 1,
707
+ "check": "webhook route parses the body without constructEvent / signature check",
708
+ "note": "an unverified webhook endpoint is an unauthenticated 'mark this order paid' API.",
709
+ "sources": [
710
+ "DY1g56bk9_o"
711
+ ],
712
+ "category": "payments"
713
+ },
714
+ {
715
+ "id": "PAY-002",
716
+ "title": "No idempotency key on charge creation",
717
+ "severity": "high",
718
+ "requires": [
719
+ "handles_payments in [stripe, paddle, lemonsqueezy]"
720
+ ],
721
+ "tier": 2,
722
+ "check": "payment intent created without a client-generated idempotency key",
723
+ "note": "corpus \u2014 a Wi-Fi hiccup plus a retry equals a duplicate charge.",
724
+ "sources": [
725
+ "DY1g56bk9_o"
726
+ ],
727
+ "category": "payments"
728
+ },
729
+ {
730
+ "id": "PAY-003",
731
+ "title": "Webhook events not deduplicated by event id",
732
+ "severity": "high",
733
+ "requires": [
734
+ "handles_payments != none",
735
+ "has_webhooks_in == true"
736
+ ],
737
+ "tier": 2,
738
+ "check": "no processed-event table / unique constraint on provider event id",
739
+ "sources": [
740
+ "DY1g56bk9_o"
741
+ ],
742
+ "category": "payments"
743
+ },
744
+ {
745
+ "id": "PAY-004",
746
+ "title": "Payment state overwritten in place instead of appended",
747
+ "severity": "medium",
748
+ "requires": [
749
+ "handles_payments != none"
750
+ ],
751
+ "tier": 2,
752
+ "check": "single mutable status column updated per event, no ledger table",
753
+ "note": "corpus \u2014 'the moment a regulator or finance person asks what happened on this payment, you've got nothing.' also breaks on out-of-order events.",
754
+ "sources": [
755
+ "DY1g56bk9_o"
756
+ ],
757
+ "category": "payments"
758
+ },
759
+ {
760
+ "id": "PAY-005",
761
+ "title": "Entitlement derived from an API poll rather than webhook state",
762
+ "severity": "high",
763
+ "requires": [
764
+ "has_subscriptions == true"
765
+ ],
766
+ "tier": 2,
767
+ "check": "subscription status read by calling the provider API at request time, or updated only on the checkout return path",
768
+ "note": "corpus \u2014 pull model guarantees your DB and the provider drift, and users get charged the wrong amount.",
769
+ "sources": [
770
+ "DYTwl74My3S"
771
+ ],
772
+ "category": "payments"
773
+ },
774
+ {
775
+ "id": "PAY-006",
776
+ "title": "Price or amount accepted from the client",
777
+ "severity": "critical",
778
+ "requires": [
779
+ "handles_payments != none"
780
+ ],
781
+ "tier": 1,
782
+ "check": "amount/price/currency read from the request body when creating a charge",
783
+ "category": "payments"
784
+ },
785
+ {
786
+ "id": "PAY-007",
787
+ "title": "No handling for out-of-order webhook delivery",
788
+ "severity": "medium",
789
+ "requires": [
790
+ "handles_payments != none",
791
+ "has_webhooks_in == true"
792
+ ],
793
+ "tier": 2,
794
+ "check": "state machine assumes chronological arrival",
795
+ "sources": [
796
+ "DY1g56bk9_o"
797
+ ],
798
+ "category": "payments"
799
+ },
800
+ {
801
+ "id": "PAY-008",
802
+ "title": "App Store subscription prerequisites incomplete",
803
+ "severity": "high",
804
+ "requires": [
805
+ "surface in [mobile-ios]",
806
+ "has_subscriptions == true"
807
+ ],
808
+ "tier": 2,
809
+ "check": "paid apps agreement unsigned, terms of use absent from description, subscriptions not approved",
810
+ "note": "corpus \u2014 four consecutive rejections over exactly these.",
811
+ "sources": [
812
+ "DXez-ZWkRgl"
813
+ ],
814
+ "category": "payments"
815
+ },
816
+ {
817
+ "id": "DATA-001",
818
+ "title": "Unbounded list query \u2014 no pagination",
819
+ "severity": "high",
820
+ "requires": [
821
+ "stack.database != none"
822
+ ],
823
+ "tier": 1,
824
+ "check": "list endpoint without limit/offset or cursor",
825
+ "note": "corpus \u2014 fine at 50 users, loads 50,000 rows at 500.",
826
+ "sources": [
827
+ "DaDPJM4x1qM"
828
+ ],
829
+ "category": "data-and-scale"
830
+ },
831
+ {
832
+ "id": "DATA-002",
833
+ "title": "N+1 query pattern",
834
+ "severity": "high",
835
+ "requires": [
836
+ "stack.database != none"
837
+ ],
838
+ "tier": 2,
839
+ "check": "query inside a loop over a prior result set",
840
+ "sources": [
841
+ "DaDPJM4x1qM"
842
+ ],
843
+ "category": "data-and-scale"
844
+ },
845
+ {
846
+ "id": "DATA-003",
847
+ "title": "Missing index on a filtered or joined column",
848
+ "severity": "high",
849
+ "requires": [
850
+ "stack.database in [postgres, supabase, mysql, planetscale]"
851
+ ],
852
+ "tier": 1,
853
+ "check": "WHERE / JOIN / ORDER BY column with no index in migrations",
854
+ "note": "50ms at 100 rows, 30s at 100k. the code never changed.",
855
+ "sources": [
856
+ "DaDPJM4x1qM",
857
+ "DXwtOTDA-gx"
858
+ ],
859
+ "category": "data-and-scale"
860
+ },
861
+ {
862
+ "id": "DATA-004",
863
+ "title": "No connection pooling",
864
+ "severity": "high",
865
+ "requires": [
866
+ "stack.database in [postgres, supabase, mysql]",
867
+ "stack.host in [vercel, netlify, cloudflare]"
868
+ ],
869
+ "tier": 1,
870
+ "note": "serverless multiplies connections per invocation; the DB runs out first.",
871
+ "sources": [
872
+ "DaDPJM4x1qM"
873
+ ],
874
+ "category": "data-and-scale"
875
+ },
876
+ {
877
+ "id": "DATA-005",
878
+ "title": "SELECT * on wide or hot tables",
879
+ "severity": "medium",
880
+ "requires": [
881
+ "stack.database != none"
882
+ ],
883
+ "tier": 1,
884
+ "sources": [
885
+ "DaDPJM4x1qM"
886
+ ],
887
+ "category": "data-and-scale"
888
+ },
889
+ {
890
+ "id": "DATA-006",
891
+ "title": "No server-side input validation",
892
+ "severity": "critical",
893
+ "requires": [],
894
+ "tier": 2,
895
+ "check": "route handler consumes request body without a schema validator",
896
+ "note": "client-side validation is a UX feature, not a control.",
897
+ "sources": [
898
+ "DXkMgEJERJg",
899
+ "DWNKkeHkTLF"
900
+ ],
901
+ "category": "data-and-scale"
902
+ },
903
+ {
904
+ "id": "DATA-007",
905
+ "title": "Raw SQL built by string concatenation",
906
+ "severity": "critical",
907
+ "requires": [
908
+ "stack.orm == raw-sql"
909
+ ],
910
+ "tier": 1,
911
+ "check": "query string interpolating a request value",
912
+ "sources": [
913
+ "DY7TIvBI68X"
914
+ ],
915
+ "category": "data-and-scale"
916
+ },
917
+ {
918
+ "id": "DATA-008",
919
+ "title": "No payload size limit",
920
+ "severity": "medium",
921
+ "requires": [
922
+ "is_public == true"
923
+ ],
924
+ "tier": 1,
925
+ "check": "body parser without a size cap",
926
+ "sources": [
927
+ "DWNKkeHkTLF"
928
+ ],
929
+ "category": "data-and-scale"
930
+ },
931
+ {
932
+ "id": "DATA-009",
933
+ "title": "Text columns not 4-byte-safe",
934
+ "severity": "medium",
935
+ "requires": [
936
+ "stack.database in [mysql, planetscale]"
937
+ ],
938
+ "tier": 1,
939
+ "check": "utf8 rather than utf8mb4 on user-facing text columns",
940
+ "note": "corpus failure \u2014 a user typed an emoji and the app crashed. 3-byte utf8 cannot hold a 4-byte codepoint.",
941
+ "sources": [
942
+ "DZvckc0vkKr"
943
+ ],
944
+ "category": "data-and-scale"
945
+ },
946
+ {
947
+ "id": "DATA-010",
948
+ "title": "No load test before launch",
949
+ "severity": "medium",
950
+ "requires": [
951
+ "stage == pre-launch",
952
+ "expected_scale != hobby"
953
+ ],
954
+ "tier": 2,
955
+ "sources": [
956
+ "DXwtOTDA-gx"
957
+ ],
958
+ "category": "data-and-scale"
959
+ },
960
+ {
961
+ "id": "DATA-011",
962
+ "title": "Heavy work performed synchronously in the request path",
963
+ "severity": "medium",
964
+ "requires": [
965
+ "sends_email == true or has_file_uploads == true"
966
+ ],
967
+ "tier": 2,
968
+ "check": "email send / report generation / image processing inline in a handler",
969
+ "sources": [
970
+ "DXwtOTDA-gx"
971
+ ],
972
+ "category": "data-and-scale"
973
+ },
974
+ {
975
+ "id": "DATA-012",
976
+ "title": "No backup or restore procedure",
977
+ "severity": "critical",
978
+ "requires": [
979
+ "stage == production",
980
+ "stack.database != none"
981
+ ],
982
+ "tier": 2,
983
+ "origin": "gap",
984
+ "note": "absent from all 73 reels. the highest-consequence omission in the corpus.",
985
+ "category": "data-and-scale"
986
+ },
987
+ {
988
+ "id": "DATA-013",
989
+ "title": "Uploaded files served without authorization",
990
+ "severity": "critical",
991
+ "requires": [
992
+ "has_file_uploads == true",
993
+ "has_accounts == true"
994
+ ],
995
+ "tier": 2,
996
+ "check": "storage bucket public, or file URLs unguessable-but-unauthenticated",
997
+ "note": "matches the user's own CRMini finding \u2014 files reachable without login.",
998
+ "origin": "gap",
999
+ "category": "data-and-scale"
1000
+ },
1001
+ {
1002
+ "id": "DATA-014",
1003
+ "title": "User uploads stored on ephemeral disk",
1004
+ "severity": "critical",
1005
+ "requires": [
1006
+ "has_file_uploads == true",
1007
+ "stack.host in [vercel, netlify, railway, fly, render]"
1008
+ ],
1009
+ "tier": 1,
1010
+ "check": "writes to local fs on a platform with an ephemeral filesystem",
1011
+ "note": "matches CRMini \u2014 files vanish on every redeploy.",
1012
+ "origin": "gap",
1013
+ "category": "data-and-scale"
1014
+ },
1015
+ {
1016
+ "id": "INF-001",
1017
+ "title": "Single instance with no redundancy",
1018
+ "severity": "medium",
1019
+ "requires": [
1020
+ "stage == production",
1021
+ "expected_scale in [under-100k, over-100k]",
1022
+ "stack.host in [vps, aws, gcp]"
1023
+ ],
1024
+ "tier": 2,
1025
+ "gate_note": "SKIPPED below 1k users. The source reel is explicit \u2014 'for a side project Kubernetes is definitely overkill.' Firing this on a hobby app is the WhatsApp-Stories failure.",
1026
+ "sources": [
1027
+ "DYyNL2XBVJ2",
1028
+ "DYXjWK0jRVQ"
1029
+ ],
1030
+ "category": "infrastructure"
1031
+ },
1032
+ {
1033
+ "id": "INF-002",
1034
+ "title": "No health check or auto-restart",
1035
+ "severity": "medium",
1036
+ "requires": [
1037
+ "stage == production",
1038
+ "stack.host in [vps, aws, gcp]"
1039
+ ],
1040
+ "tier": 1,
1041
+ "sources": [
1042
+ "DYyNL2XBVJ2"
1043
+ ],
1044
+ "category": "infrastructure"
1045
+ },
1046
+ {
1047
+ "id": "INF-003",
1048
+ "title": "No horizontal scaling path",
1049
+ "severity": "low",
1050
+ "requires": [
1051
+ "expected_scale == over-100k"
1052
+ ],
1053
+ "tier": 2,
1054
+ "sources": [
1055
+ "DYyNL2XBVJ2",
1056
+ "DYXjWK0jRVQ"
1057
+ ],
1058
+ "category": "infrastructure"
1059
+ },
1060
+ {
1061
+ "id": "INF-004",
1062
+ "title": "Deploy causes downtime",
1063
+ "severity": "low",
1064
+ "requires": [
1065
+ "stage == production",
1066
+ "expected_scale != hobby"
1067
+ ],
1068
+ "tier": 2,
1069
+ "sources": [
1070
+ "DYyNL2XBVJ2"
1071
+ ],
1072
+ "category": "infrastructure"
1073
+ },
1074
+ {
1075
+ "id": "INF-005",
1076
+ "title": "No API gateway / single ingress with shared policy",
1077
+ "severity": "low",
1078
+ "requires": [
1079
+ "expected_scale == over-100k"
1080
+ ],
1081
+ "tier": 2,
1082
+ "note": "correct at many services; pure ceremony for one Next.js app.",
1083
+ "sources": [
1084
+ "DaFxeS7tBNf"
1085
+ ],
1086
+ "category": "infrastructure"
1087
+ },
1088
+ {
1089
+ "id": "INF-006",
1090
+ "title": "HTTPS not enforced",
1091
+ "severity": "high",
1092
+ "requires": [
1093
+ "is_public == true"
1094
+ ],
1095
+ "tier": 1,
1096
+ "check": "no HSTS, no http->https redirect",
1097
+ "sources": [
1098
+ "DWKYy3tE-Vq"
1099
+ ],
1100
+ "category": "infrastructure"
1101
+ },
1102
+ {
1103
+ "id": "INF-007",
1104
+ "title": "Permissive CORS",
1105
+ "severity": "high",
1106
+ "requires": [
1107
+ "surface in [web-app, api-only]"
1108
+ ],
1109
+ "tier": 1,
1110
+ "check": "Access-Control-Allow-Origin: * on credentialed routes",
1111
+ "origin": "gap",
1112
+ "category": "infrastructure"
1113
+ },
1114
+ {
1115
+ "id": "INF-008",
1116
+ "title": "Database reachable from the public internet",
1117
+ "severity": "critical",
1118
+ "requires": [
1119
+ "stack.database != none",
1120
+ "stack.host in [vps, aws, gcp]"
1121
+ ],
1122
+ "tier": 2,
1123
+ "sources": [
1124
+ "DWKYy3tE-Vq"
1125
+ ],
1126
+ "category": "infrastructure"
1127
+ },
1128
+ {
1129
+ "id": "INF-009",
1130
+ "title": "Release build config diverges from debug",
1131
+ "severity": "high",
1132
+ "requires": [
1133
+ "surface in [mobile-ios, mobile-android]"
1134
+ ],
1135
+ "tier": 2,
1136
+ "note": "corpus \u2014 shipped app rejected because sign-in only worked in debug.",
1137
+ "sources": [
1138
+ "DXez-ZWkRgl"
1139
+ ],
1140
+ "category": "infrastructure"
1141
+ },
1142
+ {
1143
+ "id": "INF-010",
1144
+ "title": "No DNS / domain-hijack protection",
1145
+ "severity": "medium",
1146
+ "requires": [
1147
+ "is_public == true",
1148
+ "stage == production"
1149
+ ],
1150
+ "tier": 2,
1151
+ "check": "registrar lock off, no DNSSEC, no monitoring of NS/A record changes",
1152
+ "note": "corpus case \u2014 hijacked domain cloaked redirects to Googlebot only, silently transferring the site's authority for months.",
1153
+ "sources": [
1154
+ "DUrp9swjYA5"
1155
+ ],
1156
+ "category": "infrastructure"
1157
+ },
1158
+ {
1159
+ "id": "INF-011",
1160
+ "title": "No staging environment",
1161
+ "severity": "low",
1162
+ "requires": [
1163
+ "stage == production",
1164
+ "business_model in [b2b-saas, marketplace]"
1165
+ ],
1166
+ "tier": 2,
1167
+ "origin": "gap",
1168
+ "category": "infrastructure"
1169
+ },
1170
+ {
1171
+ "id": "LEG-001",
1172
+ "title": "No privacy policy while collecting personal data",
1173
+ "severity": "high",
1174
+ "requires": [
1175
+ "data_sensitivity != none",
1176
+ "is_public == true"
1177
+ ],
1178
+ "tier": 1,
1179
+ "note": "corpus is emphatic \u2014 a single contact form collecting an email is already personal data.",
1180
+ "sources": [
1181
+ "DY8VOOYBjFx",
1182
+ "DYwx1b8TDAc",
1183
+ "DaFyRMSMj7E"
1184
+ ],
1185
+ "category": "legal"
1186
+ },
1187
+ {
1188
+ "id": "LEG-002",
1189
+ "title": "Cookie consent missing or non-compliant",
1190
+ "severity": "high",
1191
+ "requires": [
1192
+ "collects_analytics == true",
1193
+ "jurisdictions includes any of [eu, uk]"
1194
+ ],
1195
+ "tier": 2,
1196
+ "check": "no consent UI, or analytics fires before consent, or no genuine reject option",
1197
+ "note": "corpus \u2014 'a basic we-use-cookies popup isn't enough, you must let people choose and only track after yes.'",
1198
+ "sources": [
1199
+ "DY8VOOYBjFx"
1200
+ ],
1201
+ "category": "legal"
1202
+ },
1203
+ {
1204
+ "id": "LEG-003",
1205
+ "title": "No terms of service",
1206
+ "severity": "medium",
1207
+ "requires": [
1208
+ "is_public == true",
1209
+ "has_accounts == true"
1210
+ ],
1211
+ "tier": 1,
1212
+ "sources": [
1213
+ "DYwx1b8TDAc"
1214
+ ],
1215
+ "category": "legal"
1216
+ },
1217
+ {
1218
+ "id": "LEG-004",
1219
+ "title": "No data deletion path",
1220
+ "severity": "high",
1221
+ "requires": [
1222
+ "data_sensitivity != none",
1223
+ "jurisdictions includes any of [eu, uk, ca]"
1224
+ ],
1225
+ "tier": 2,
1226
+ "note": "must reach prompt/completion logs too \u2014 see AI-010.",
1227
+ "sources": [
1228
+ "DY8VOOYBjFx"
1229
+ ],
1230
+ "category": "legal"
1231
+ },
1232
+ {
1233
+ "id": "LEG-005",
1234
+ "title": "Consent checkbox absent on collection forms",
1235
+ "severity": "medium",
1236
+ "requires": [
1237
+ "data_sensitivity != none",
1238
+ "jurisdictions includes any of [eu, ru, kz]"
1239
+ ],
1240
+ "tier": 2,
1241
+ "sources": [
1242
+ "DaFyRMSMj7E"
1243
+ ],
1244
+ "category": "legal"
1245
+ },
1246
+ {
1247
+ "id": "LEG-006",
1248
+ "title": "Accessibility baseline unmet",
1249
+ "severity": "medium",
1250
+ "requires": [
1251
+ "surface in [web-app, web-site]",
1252
+ "is_public == true"
1253
+ ],
1254
+ "tier": 1,
1255
+ "check": "missing alt text, insufficient contrast, no heading hierarchy, unlabelled controls",
1256
+ "note": "corpus flags active litigation risk in US and AU.",
1257
+ "sources": [
1258
+ "DY8VOOYBjFx"
1259
+ ],
1260
+ "category": "legal"
1261
+ },
1262
+ {
1263
+ "id": "LEG-007",
1264
+ "title": "App name not checked against existing trademarks",
1265
+ "severity": "medium",
1266
+ "requires": [
1267
+ "stage in [pre-launch, production]",
1268
+ "business_model != hobby"
1269
+ ],
1270
+ "tier": 2,
1271
+ "sources": [
1272
+ "DYwx1b8TDAc",
1273
+ "DZ5R87lTGMS"
1274
+ ],
1275
+ "category": "legal"
1276
+ },
1277
+ {
1278
+ "id": "LEG-008",
1279
+ "title": "Data collection not declared for store submission",
1280
+ "severity": "high",
1281
+ "requires": [
1282
+ "surface in [mobile-ios, mobile-android]"
1283
+ ],
1284
+ "tier": 2,
1285
+ "sources": [
1286
+ "DYwx1b8TDAc"
1287
+ ],
1288
+ "category": "legal"
1289
+ },
1290
+ {
1291
+ "id": "LEG-009",
1292
+ "title": "Local data-protection registration not addressed",
1293
+ "severity": "medium",
1294
+ "requires": [
1295
+ "data_sensitivity != none",
1296
+ "jurisdictions includes any of [ru, kz, other]"
1297
+ ],
1298
+ "tier": 2,
1299
+ "note": "corpus documents RU fines from \u20bd30k-500k for a missing policy alone. Non-EU jurisdictions each carry their own registration and localisation duties \u2014 do not assume GDPR compliance covers them.",
1300
+ "sources": [
1301
+ "DaFyRMSMj7E"
1302
+ ],
1303
+ "category": "legal"
1304
+ },
1305
+ {
1306
+ "id": "LEG-010",
1307
+ "title": "Third-party assets used without a license",
1308
+ "severity": "medium",
1309
+ "requires": [
1310
+ "is_public == true"
1311
+ ],
1312
+ "tier": 2,
1313
+ "check": "fonts, images, icons with no license record",
1314
+ "sources": [
1315
+ "DaFyRMSMj7E"
1316
+ ],
1317
+ "category": "legal"
1318
+ },
1319
+ {
1320
+ "id": "OBS-001",
1321
+ "title": "No error monitoring",
1322
+ "severity": "high",
1323
+ "requires": [
1324
+ "stage == production"
1325
+ ],
1326
+ "tier": 1,
1327
+ "origin": "gap",
1328
+ "category": "observability"
1329
+ },
1330
+ {
1331
+ "id": "OBS-002",
1332
+ "title": "Security-relevant events not logged",
1333
+ "severity": "medium",
1334
+ "requires": [
1335
+ "has_accounts == true",
1336
+ "stage == production"
1337
+ ],
1338
+ "tier": 2,
1339
+ "check": "no log of failed logins, authz denials, or rate-limit trips",
1340
+ "note": "without these you cannot tell you are being attacked. corpus author only discovered a live attack by accident.",
1341
+ "sources": [
1342
+ "DWKYy3tE-Vq",
1343
+ "DZmTu7Kgr0W"
1344
+ ],
1345
+ "category": "observability"
1346
+ },
1347
+ {
1348
+ "id": "OBS-003",
1349
+ "title": "No product analytics",
1350
+ "severity": "low",
1351
+ "requires": [
1352
+ "stage in [pre-launch, production]",
1353
+ "business_model != internal-tool"
1354
+ ],
1355
+ "tier": 1,
1356
+ "sources": [
1357
+ "DXedxTZjrBy",
1358
+ "DZzunydsqWq"
1359
+ ],
1360
+ "category": "observability"
1361
+ },
1362
+ {
1363
+ "id": "OBS-004",
1364
+ "title": "No uptime monitoring or alerting",
1365
+ "severity": "medium",
1366
+ "requires": [
1367
+ "stage == production"
1368
+ ],
1369
+ "tier": 2,
1370
+ "note": "corpus \u2014 app down over six hours overnight, nobody watching.",
1371
+ "sources": [
1372
+ "DYyNL2XBVJ2"
1373
+ ],
1374
+ "category": "observability"
1375
+ },
1376
+ {
1377
+ "id": "UX-001",
1378
+ "title": "No loading state",
1379
+ "severity": "medium",
1380
+ "requires": [
1381
+ "surface in [web-app, mobile-ios, mobile-android]"
1382
+ ],
1383
+ "tier": 2,
1384
+ "sources": [
1385
+ "DYc6sGNMuow"
1386
+ ],
1387
+ "category": "ux-readiness"
1388
+ },
1389
+ {
1390
+ "id": "UX-002",
1391
+ "title": "No error state \u2014 raw stack trace or blank page on failure",
1392
+ "severity": "high",
1393
+ "requires": [
1394
+ "surface in [web-app, mobile-ios, mobile-android]"
1395
+ ],
1396
+ "tier": 2,
1397
+ "note": "a stack trace in the browser is both a UX failure and an information leak.",
1398
+ "sources": [
1399
+ "DYc6sGNMuow"
1400
+ ],
1401
+ "category": "ux-readiness"
1402
+ },
1403
+ {
1404
+ "id": "UX-003",
1405
+ "title": "No empty state",
1406
+ "severity": "low",
1407
+ "requires": [
1408
+ "surface in [web-app, mobile-ios, mobile-android]"
1409
+ ],
1410
+ "tier": 2,
1411
+ "sources": [
1412
+ "DYc6sGNMuow"
1413
+ ],
1414
+ "category": "ux-readiness"
1415
+ },
1416
+ {
1417
+ "id": "UX-004",
1418
+ "title": "No onboarding path to first value",
1419
+ "severity": "low",
1420
+ "requires": [
1421
+ "stage in [pre-launch, production]",
1422
+ "business_model in [b2c, b2b-saas]"
1423
+ ],
1424
+ "tier": 2,
1425
+ "sources": [
1426
+ "DXedxTZjrBy",
1427
+ "DZzunydsqWq"
1428
+ ],
1429
+ "category": "ux-readiness"
1430
+ },
1431
+ {
1432
+ "id": "UX-005",
1433
+ "title": "No social preview image",
1434
+ "severity": "low",
1435
+ "requires": [
1436
+ "is_public == true",
1437
+ "surface in [web-app, web-site]"
1438
+ ],
1439
+ "tier": 1,
1440
+ "sources": [
1441
+ "DZzunydsqWq"
1442
+ ],
1443
+ "category": "ux-readiness"
1444
+ },
1445
+ {
1446
+ "id": "UX-006",
1447
+ "title": "No sitemap.xml / not indexable",
1448
+ "severity": "low",
1449
+ "requires": [
1450
+ "is_public == true",
1451
+ "surface == web-site"
1452
+ ],
1453
+ "tier": 1,
1454
+ "sources": [
1455
+ "DZzunydsqWq",
1456
+ "DZ5Rw-RsmpP"
1457
+ ],
1458
+ "category": "ux-readiness"
1459
+ },
1460
+ {
1461
+ "id": "AIOP-001",
1462
+ "title": "No fallback when the model provider is down",
1463
+ "severity": "high",
1464
+ "requires": [
1465
+ "calls_llm == true",
1466
+ "stage == production"
1467
+ ],
1468
+ "tier": 2,
1469
+ "origin": "gap",
1470
+ "note": "a single-provider dependency makes their outage your outage.",
1471
+ "category": "ai-operations"
1472
+ },
1473
+ {
1474
+ "id": "AIOP-002",
1475
+ "title": "Model id pinned to an alias that can silently change",
1476
+ "severity": "medium",
1477
+ "requires": [
1478
+ "calls_llm == true",
1479
+ "stage == production"
1480
+ ],
1481
+ "tier": 1,
1482
+ "origin": "gap",
1483
+ "note": "floating aliases shift behaviour and price under you with no deploy.",
1484
+ "category": "ai-operations"
1485
+ },
1486
+ {
1487
+ "id": "AIOP-003",
1488
+ "title": "No handling for model deprecation",
1489
+ "severity": "medium",
1490
+ "requires": [
1491
+ "calls_llm == true",
1492
+ "stage == production"
1493
+ ],
1494
+ "tier": 2,
1495
+ "origin": "gap",
1496
+ "category": "ai-operations"
1497
+ },
1498
+ {
1499
+ "id": "AIOP-004",
1500
+ "title": "Prompt caching unused on a repeated system prompt",
1501
+ "severity": "low",
1502
+ "requires": [
1503
+ "calls_llm == true",
1504
+ "stage == production"
1505
+ ],
1506
+ "tier": 2,
1507
+ "origin": "gap",
1508
+ "note": "a pure cost rule \u2014 large static prefixes re-billed on every call.",
1509
+ "category": "ai-operations"
1510
+ },
1511
+ {
1512
+ "id": "AIOP-005",
1513
+ "title": "Context window overflow unhandled",
1514
+ "severity": "high",
1515
+ "requires": [
1516
+ "calls_llm == true"
1517
+ ],
1518
+ "tier": 2,
1519
+ "origin": "gap",
1520
+ "check": "conversation or document appended to a prompt with no token accounting",
1521
+ "category": "ai-operations"
1522
+ },
1523
+ {
1524
+ "id": "AIOP-006",
1525
+ "title": "No output validation against an expected schema",
1526
+ "severity": "high",
1527
+ "requires": [
1528
+ "calls_llm == true"
1529
+ ],
1530
+ "tier": 2,
1531
+ "origin": "gap",
1532
+ "check": "completion parsed as JSON without schema validation or a retry path",
1533
+ "category": "ai-operations"
1534
+ },
1535
+ {
1536
+ "id": "AIOP-007",
1537
+ "title": "Retrieved documents not trust-separated from instructions",
1538
+ "severity": "critical",
1539
+ "requires": [
1540
+ "has_rag == true"
1541
+ ],
1542
+ "tier": 2,
1543
+ "origin": "gap",
1544
+ "note": "indirect prompt injection \u2014 the attacker writes the document, not the message.",
1545
+ "category": "ai-operations"
1546
+ },
1547
+ {
1548
+ "id": "AIOP-008",
1549
+ "title": "Vector store not tenant-scoped",
1550
+ "severity": "critical",
1551
+ "requires": [
1552
+ "has_rag == true",
1553
+ "tenancy == multi-tenant-shared-db"
1554
+ ],
1555
+ "tier": 2,
1556
+ "origin": "gap",
1557
+ "note": "embeddings leak across tenants exactly as rows do, and nobody puts RLS on them.",
1558
+ "category": "ai-operations"
1559
+ },
1560
+ {
1561
+ "id": "AIOP-009",
1562
+ "title": "Embedding model changed without reindexing",
1563
+ "severity": "medium",
1564
+ "requires": [
1565
+ "has_rag == true"
1566
+ ],
1567
+ "tier": 2,
1568
+ "origin": "gap",
1569
+ "category": "ai-operations"
1570
+ },
1571
+ {
1572
+ "id": "AIOP-010",
1573
+ "title": "Agent loop has no iteration ceiling",
1574
+ "severity": "critical",
1575
+ "requires": [
1576
+ "llm_tools_enabled == true"
1577
+ ],
1578
+ "tier": 1,
1579
+ "origin": "gap",
1580
+ "note": "an unbounded loop is an unbounded invoice.",
1581
+ "category": "ai-operations"
1582
+ },
1583
+ {
1584
+ "id": "AIOP-011",
1585
+ "title": "Agent tool results not size-bounded",
1586
+ "severity": "high",
1587
+ "requires": [
1588
+ "llm_tools_enabled == true"
1589
+ ],
1590
+ "tier": 2,
1591
+ "origin": "gap",
1592
+ "category": "ai-operations"
1593
+ },
1594
+ {
1595
+ "id": "AIOP-012",
1596
+ "title": "No human confirmation on destructive agent tools",
1597
+ "severity": "critical",
1598
+ "requires": [
1599
+ "llm_tools_enabled == true"
1600
+ ],
1601
+ "tier": 2,
1602
+ "origin": "gap",
1603
+ "category": "ai-operations"
1604
+ },
1605
+ {
1606
+ "id": "AIOP-013",
1607
+ "title": "No eval set for prompt changes",
1608
+ "severity": "medium",
1609
+ "requires": [
1610
+ "calls_llm == true",
1611
+ "stage == production"
1612
+ ],
1613
+ "tier": 2,
1614
+ "origin": "gap",
1615
+ "note": "without evals a prompt edit is an untested deploy to your core logic.",
1616
+ "category": "ai-operations"
1617
+ },
1618
+ {
1619
+ "id": "AIOP-014",
1620
+ "title": "No AI usage dashboard for the operator",
1621
+ "severity": "medium",
1622
+ "requires": [
1623
+ "calls_llm == true",
1624
+ "stage == production"
1625
+ ],
1626
+ "tier": 2,
1627
+ "origin": "gap",
1628
+ "category": "ai-operations"
1629
+ },
1630
+ {
1631
+ "id": "AIOP-015",
1632
+ "title": "AI features not degradable under cost pressure",
1633
+ "severity": "medium",
1634
+ "requires": [
1635
+ "calls_llm == true",
1636
+ "business_model in [b2c, b2b-saas]"
1637
+ ],
1638
+ "tier": 2,
1639
+ "origin": "gap",
1640
+ "note": "when the budget cap trips, the product should degrade, not 500.",
1641
+ "category": "ai-operations"
1642
+ },
1643
+ {
1644
+ "id": "AIOP-016",
1645
+ "title": "User-facing AI errors leak provider internals",
1646
+ "severity": "medium",
1647
+ "requires": [
1648
+ "calls_llm == true"
1649
+ ],
1650
+ "tier": 1,
1651
+ "origin": "gap",
1652
+ "category": "ai-operations"
1653
+ },
1654
+ {
1655
+ "id": "AIOP-017",
1656
+ "title": "No content moderation on model output",
1657
+ "severity": "medium",
1658
+ "requires": [
1659
+ "calls_llm == true",
1660
+ "is_public == true",
1661
+ "business_model == b2c"
1662
+ ],
1663
+ "tier": 2,
1664
+ "origin": "gap",
1665
+ "category": "ai-operations"
1666
+ },
1667
+ {
1668
+ "id": "AIOP-018",
1669
+ "title": "Free tier allows unauthenticated AI calls",
1670
+ "severity": "critical",
1671
+ "requires": [
1672
+ "calls_llm == true",
1673
+ "is_public == true"
1674
+ ],
1675
+ "tier": 2,
1676
+ "has_static_approximation": true,
1677
+ "origin": "gap",
1678
+ "note": "an unauthenticated LLM endpoint is a public API on your credit card.",
1679
+ "category": "ai-operations"
1680
+ },
1681
+ {
1682
+ "id": "AIOP-019",
1683
+ "title": "Streaming responses not cancelled on client disconnect",
1684
+ "severity": "medium",
1685
+ "requires": [
1686
+ "calls_llm == true"
1687
+ ],
1688
+ "tier": 2,
1689
+ "origin": "gap",
1690
+ "note": "you keep paying for tokens nobody will read.",
1691
+ "category": "ai-operations"
1692
+ },
1693
+ {
1694
+ "id": "AIOP-020",
1695
+ "title": "Temperature or sampling unset for a deterministic task",
1696
+ "severity": "low",
1697
+ "requires": [
1698
+ "calls_llm == true"
1699
+ ],
1700
+ "tier": 1,
1701
+ "origin": "gap",
1702
+ "category": "ai-operations"
1703
+ },
1704
+ {
1705
+ "id": "DEP-001",
1706
+ "title": "Secrets injected at build time instead of runtime",
1707
+ "severity": "high",
1708
+ "requires": [
1709
+ "stage in [pre-launch, production]"
1710
+ ],
1711
+ "tier": 1,
1712
+ "note": "build-time inlining bakes the value into the artifact; rotation needs a rebuild.",
1713
+ "category": "deployment"
1714
+ },
1715
+ {
1716
+ "id": "DEP-002",
1717
+ "title": "Secrets present in CI logs or config",
1718
+ "severity": "critical",
1719
+ "requires": [
1720
+ "has_ci == true"
1721
+ ],
1722
+ "tier": 1,
1723
+ "check": "plaintext credential in workflow yaml, or echoed to build output",
1724
+ "category": "deployment"
1725
+ },
1726
+ {
1727
+ "id": "DEP-003",
1728
+ "title": "No environment parity between local and production",
1729
+ "severity": "medium",
1730
+ "requires": [
1731
+ "stage in [pre-launch, production]"
1732
+ ],
1733
+ "tier": 2,
1734
+ "check": "env vars referenced in code with no documented required set",
1735
+ "note": "corpus case \u2014 an iOS release build was missing config the debug build had, and the app shipped broken.",
1736
+ "sources": [
1737
+ "DXez-ZWkRgl"
1738
+ ],
1739
+ "category": "deployment"
1740
+ },
1741
+ {
1742
+ "id": "DEP-004",
1743
+ "title": "Migrations not run as part of deploy",
1744
+ "severity": "high",
1745
+ "requires": [
1746
+ "has_migrations == true",
1747
+ "stage == production"
1748
+ ],
1749
+ "tier": 2,
1750
+ "category": "deployment"
1751
+ },
1752
+ {
1753
+ "id": "DEP-005",
1754
+ "title": "Migrations are not reversible",
1755
+ "severity": "medium",
1756
+ "requires": [
1757
+ "has_migrations == true",
1758
+ "stage == production"
1759
+ ],
1760
+ "tier": 1,
1761
+ "category": "deployment"
1762
+ },
1763
+ {
1764
+ "id": "DEP-006",
1765
+ "title": "Destructive migration without a backfill plan",
1766
+ "severity": "critical",
1767
+ "requires": [
1768
+ "has_migrations == true",
1769
+ "stage == production"
1770
+ ],
1771
+ "tier": 2,
1772
+ "check": "DROP COLUMN / DROP TABLE / NOT NULL added in a single step",
1773
+ "category": "deployment"
1774
+ },
1775
+ {
1776
+ "id": "DEP-007",
1777
+ "title": "No rollback path",
1778
+ "severity": "high",
1779
+ "requires": [
1780
+ "stage == production"
1781
+ ],
1782
+ "tier": 2,
1783
+ "category": "deployment"
1784
+ },
1785
+ {
1786
+ "id": "DEP-008",
1787
+ "title": "No health endpoint",
1788
+ "severity": "medium",
1789
+ "requires": [
1790
+ "stage == production",
1791
+ "surface in [web-app, api-only]"
1792
+ ],
1793
+ "tier": 1,
1794
+ "category": "deployment"
1795
+ },
1796
+ {
1797
+ "id": "DEP-009",
1798
+ "title": "No graceful shutdown handling",
1799
+ "severity": "medium",
1800
+ "requires": [
1801
+ "stage == production",
1802
+ "has_background_jobs == true"
1803
+ ],
1804
+ "tier": 1,
1805
+ "check": "no SIGTERM handler draining in-flight work",
1806
+ "category": "deployment"
1807
+ },
1808
+ {
1809
+ "id": "DEP-010",
1810
+ "title": "Dependencies unpinned",
1811
+ "severity": "medium",
1812
+ "requires": [
1813
+ "stage in [pre-launch, production]"
1814
+ ],
1815
+ "tier": 1,
1816
+ "check": "no lockfile committed, or ranges in a deployed manifest",
1817
+ "category": "deployment"
1818
+ },
1819
+ {
1820
+ "id": "DEP-011",
1821
+ "title": "Known-vulnerable dependencies",
1822
+ "severity": "high",
1823
+ "requires": [],
1824
+ "tier": 1,
1825
+ "check": "audit surfaces advisories at high or critical",
1826
+ "category": "deployment"
1827
+ },
1828
+ {
1829
+ "id": "DEP-012",
1830
+ "title": "Abandoned dependency in a security-relevant path",
1831
+ "severity": "medium",
1832
+ "requires": [
1833
+ "stage == production"
1834
+ ],
1835
+ "tier": 2,
1836
+ "category": "deployment"
1837
+ },
1838
+ {
1839
+ "id": "DEP-013",
1840
+ "title": "Third-party script loaded without integrity check",
1841
+ "severity": "medium",
1842
+ "requires": [
1843
+ "surface in [web-app, web-site]",
1844
+ "is_public == true"
1845
+ ],
1846
+ "tier": 1,
1847
+ "check": "external <script> without SRI",
1848
+ "category": "deployment"
1849
+ },
1850
+ {
1851
+ "id": "DEP-014",
1852
+ "title": "Source maps published in production",
1853
+ "severity": "medium",
1854
+ "requires": [
1855
+ "surface in [web-app, web-site]",
1856
+ "stage == production"
1857
+ ],
1858
+ "tier": 1,
1859
+ "category": "deployment"
1860
+ },
1861
+ {
1862
+ "id": "DEP-015",
1863
+ "title": "Debug mode or verbose errors enabled in production",
1864
+ "severity": "high",
1865
+ "requires": [
1866
+ "stage == production"
1867
+ ],
1868
+ "tier": 1,
1869
+ "category": "deployment"
1870
+ },
1871
+ {
1872
+ "id": "DEP-016",
1873
+ "title": "No CI check gating merges",
1874
+ "severity": "low",
1875
+ "requires": [
1876
+ "has_ci == true",
1877
+ "business_model in [b2b-saas, marketplace]"
1878
+ ],
1879
+ "tier": 1,
1880
+ "category": "deployment"
1881
+ },
1882
+ {
1883
+ "id": "DEP-017",
1884
+ "title": "Build not reproducible from a clean checkout",
1885
+ "severity": "medium",
1886
+ "requires": [
1887
+ "stage in [pre-launch, production]"
1888
+ ],
1889
+ "tier": 2,
1890
+ "check": "build depends on untracked local files or manual steps",
1891
+ "category": "deployment"
1892
+ },
1893
+ {
1894
+ "id": "DEP-018",
1895
+ "title": "No documented restore drill",
1896
+ "severity": "high",
1897
+ "requires": [
1898
+ "stage == production",
1899
+ "stack.database != none"
1900
+ ],
1901
+ "tier": 2,
1902
+ "note": "an untested backup is a belief, not a backup.",
1903
+ "category": "deployment"
1904
+ },
1905
+ {
1906
+ "id": "RU-001",
1907
+ "title": "Timestamps stored without timezone",
1908
+ "severity": "high",
1909
+ "requires": [
1910
+ "stack.database != none"
1911
+ ],
1912
+ "tier": 1,
1913
+ "check": "timestamp column without time zone, or naive datetime written from app code",
1914
+ "note": "works perfectly until your second user is in another country.",
1915
+ "category": "real-user-readiness"
1916
+ },
1917
+ {
1918
+ "id": "RU-002",
1919
+ "title": "Dates rendered in server locale rather than the viewer's",
1920
+ "severity": "medium",
1921
+ "requires": [
1922
+ "surface in [web-app, mobile-ios, mobile-android]"
1923
+ ],
1924
+ "tier": 2,
1925
+ "category": "real-user-readiness"
1926
+ },
1927
+ {
1928
+ "id": "RU-003",
1929
+ "title": "Money stored as float",
1930
+ "severity": "critical",
1931
+ "requires": [
1932
+ "handles_payments != none or serves_currency == multi"
1933
+ ],
1934
+ "tier": 1,
1935
+ "check": "float/double/real column holding an amount",
1936
+ "note": "rounding error in currency is a correctness bug, not a style issue.",
1937
+ "category": "real-user-readiness"
1938
+ },
1939
+ {
1940
+ "id": "RU-004",
1941
+ "title": "Currency not stored alongside amount",
1942
+ "severity": "high",
1943
+ "requires": [
1944
+ "serves_currency == multi"
1945
+ ],
1946
+ "tier": 1,
1947
+ "category": "real-user-readiness"
1948
+ },
1949
+ {
1950
+ "id": "RU-005",
1951
+ "title": "Name and address fields assume a Western format",
1952
+ "severity": "low",
1953
+ "requires": [
1954
+ "data_sensitivity != none",
1955
+ "audience_locale == multi"
1956
+ ],
1957
+ "tier": 2,
1958
+ "category": "real-user-readiness"
1959
+ },
1960
+ {
1961
+ "id": "RU-006",
1962
+ "title": "Text input not accepting the full Unicode range",
1963
+ "severity": "high",
1964
+ "requires": [
1965
+ "stack.database != none"
1966
+ ],
1967
+ "tier": 1,
1968
+ "note": "corpus \u2014 an emoji crashed a production app because the column was 3-byte utf8.",
1969
+ "sources": [
1970
+ "DZvckc0vkKr"
1971
+ ],
1972
+ "category": "real-user-readiness"
1973
+ },
1974
+ {
1975
+ "id": "RU-007",
1976
+ "title": "No mobile viewport handling",
1977
+ "severity": "high",
1978
+ "requires": [
1979
+ "surface in [web-app, web-site]",
1980
+ "is_public == true"
1981
+ ],
1982
+ "tier": 1,
1983
+ "category": "real-user-readiness"
1984
+ },
1985
+ {
1986
+ "id": "RU-008",
1987
+ "title": "No offline or flaky-network handling",
1988
+ "severity": "medium",
1989
+ "requires": [
1990
+ "surface in [mobile-ios, mobile-android]"
1991
+ ],
1992
+ "tier": 2,
1993
+ "category": "real-user-readiness"
1994
+ },
1995
+ {
1996
+ "id": "RU-009",
1997
+ "title": "Destructive action without confirmation or undo",
1998
+ "severity": "medium",
1999
+ "requires": [
2000
+ "has_accounts == true"
2001
+ ],
2002
+ "tier": 2,
2003
+ "category": "real-user-readiness"
2004
+ },
2005
+ {
2006
+ "id": "RU-010",
2007
+ "title": "No support or contact channel",
2008
+ "severity": "medium",
2009
+ "requires": [
2010
+ "is_public == true",
2011
+ "stage == production"
2012
+ ],
2013
+ "tier": 1,
2014
+ "category": "real-user-readiness"
2015
+ },
2016
+ {
2017
+ "id": "RU-011",
2018
+ "title": "Transactional email lacks SPF/DKIM/DMARC",
2019
+ "severity": "high",
2020
+ "requires": [
2021
+ "sends_email == true",
2022
+ "stage == production"
2023
+ ],
2024
+ "tier": 2,
2025
+ "note": "without these your password resets land in spam and the account is unrecoverable.",
2026
+ "category": "real-user-readiness"
2027
+ },
2028
+ {
2029
+ "id": "RU-012",
2030
+ "title": "No bounce or complaint handling",
2031
+ "severity": "medium",
2032
+ "requires": [
2033
+ "sends_email == true",
2034
+ "stage == production"
2035
+ ],
2036
+ "tier": 2,
2037
+ "category": "real-user-readiness"
2038
+ },
2039
+ {
2040
+ "id": "RU-013",
2041
+ "title": "Marketing email without an unsubscribe path",
2042
+ "severity": "high",
2043
+ "requires": [
2044
+ "sends_email == true",
2045
+ "is_public == true"
2046
+ ],
2047
+ "tier": 1,
2048
+ "category": "real-user-readiness"
2049
+ },
2050
+ {
2051
+ "id": "RU-014",
2052
+ "title": "User-supplied content in an email template unescaped",
2053
+ "severity": "high",
2054
+ "requires": [
2055
+ "sends_email == true"
2056
+ ],
2057
+ "tier": 2,
2058
+ "category": "real-user-readiness"
2059
+ },
2060
+ {
2061
+ "id": "RU-015",
2062
+ "title": "No account deletion path",
2063
+ "severity": "high",
2064
+ "requires": [
2065
+ "has_accounts == true",
2066
+ "stage == production"
2067
+ ],
2068
+ "tier": 2,
2069
+ "category": "real-user-readiness"
2070
+ },
2071
+ {
2072
+ "id": "RU-016",
2073
+ "title": "Email change not verified on both addresses",
2074
+ "severity": "high",
2075
+ "requires": [
2076
+ "has_accounts == true"
2077
+ ],
2078
+ "tier": 2,
2079
+ "note": "a classic account-takeover route.",
2080
+ "category": "real-user-readiness"
2081
+ },
2082
+ {
2083
+ "id": "RU-017",
2084
+ "title": "Password change does not invalidate other sessions",
2085
+ "severity": "high",
2086
+ "requires": [
2087
+ "has_accounts == true",
2088
+ "stack.auth == custom"
2089
+ ],
2090
+ "tier": 2,
2091
+ "category": "real-user-readiness"
2092
+ },
2093
+ {
2094
+ "id": "RU-018",
2095
+ "title": "No rate limit on account enumeration surfaces",
2096
+ "severity": "medium",
2097
+ "requires": [
2098
+ "has_accounts == true",
2099
+ "is_public == true"
2100
+ ],
2101
+ "tier": 2,
2102
+ "check": "login/reset/signup responses distinguish existing from non-existing accounts",
2103
+ "category": "real-user-readiness"
2104
+ },
2105
+ {
2106
+ "id": "RU-019",
2107
+ "title": "Session lifetime unbounded",
2108
+ "severity": "medium",
2109
+ "requires": [
2110
+ "has_accounts == true"
2111
+ ],
2112
+ "tier": 1,
2113
+ "category": "real-user-readiness"
2114
+ },
2115
+ {
2116
+ "id": "RU-020",
2117
+ "title": "No first-run empty experience",
2118
+ "severity": "low",
2119
+ "requires": [
2120
+ "has_accounts == true",
2121
+ "business_model in [b2c, b2b-saas]"
2122
+ ],
2123
+ "tier": 2,
2124
+ "category": "real-user-readiness"
2125
+ },
2126
+ {
2127
+ "id": "TEN-001",
2128
+ "title": "Tenant scope enforced in application code only",
2129
+ "severity": "critical",
2130
+ "requires": [
2131
+ "tenancy == multi-tenant-shared-db",
2132
+ "stack.database in [postgres, supabase]"
2133
+ ],
2134
+ "tier": 2,
2135
+ "note": "one forgotten WHERE clause is a cross-tenant breach. push it into RLS.",
2136
+ "category": "multi-tenancy"
2137
+ },
2138
+ {
2139
+ "id": "TEN-002",
2140
+ "title": "No composite index on (tenant_id, ...) for hot queries",
2141
+ "severity": "medium",
2142
+ "requires": [
2143
+ "tenancy == multi-tenant-shared-db"
2144
+ ],
2145
+ "tier": 1,
2146
+ "category": "multi-tenancy"
2147
+ },
2148
+ {
2149
+ "id": "TEN-003",
2150
+ "title": "Invitation token does not expire",
2151
+ "severity": "high",
2152
+ "requires": [
2153
+ "has_invitations == true"
2154
+ ],
2155
+ "tier": 1,
2156
+ "category": "multi-tenancy"
2157
+ },
2158
+ {
2159
+ "id": "TEN-004",
2160
+ "title": "Invitation accepts a role chosen by the invitee",
2161
+ "severity": "critical",
2162
+ "requires": [
2163
+ "has_invitations == true",
2164
+ "has_roles == true"
2165
+ ],
2166
+ "tier": 2,
2167
+ "category": "multi-tenancy"
2168
+ },
2169
+ {
2170
+ "id": "TEN-005",
2171
+ "title": "Role change not re-verified server-side",
2172
+ "severity": "critical",
2173
+ "requires": [
2174
+ "has_roles == true"
2175
+ ],
2176
+ "tier": 2,
2177
+ "category": "multi-tenancy"
2178
+ },
2179
+ {
2180
+ "id": "TEN-006",
2181
+ "title": "Member can escalate to owner",
2182
+ "severity": "critical",
2183
+ "requires": [
2184
+ "has_roles == true",
2185
+ "tenancy == multi-tenant-shared-db"
2186
+ ],
2187
+ "tier": 2,
2188
+ "category": "multi-tenancy"
2189
+ },
2190
+ {
2191
+ "id": "TEN-007",
2192
+ "title": "Removed member retains an active session",
2193
+ "severity": "high",
2194
+ "requires": [
2195
+ "tenancy == multi-tenant-shared-db",
2196
+ "has_accounts == true"
2197
+ ],
2198
+ "tier": 2,
2199
+ "category": "multi-tenancy"
2200
+ },
2201
+ {
2202
+ "id": "TEN-008",
2203
+ "title": "Cross-tenant file access via storage path",
2204
+ "severity": "critical",
2205
+ "requires": [
2206
+ "tenancy == multi-tenant-shared-db",
2207
+ "has_file_uploads == true"
2208
+ ],
2209
+ "tier": 2,
2210
+ "check": "object path is guessable and bucket policy is not tenant-scoped",
2211
+ "category": "multi-tenancy"
2212
+ },
2213
+ {
2214
+ "id": "TEN-009",
2215
+ "title": "Tenant deletion leaves orphaned data",
2216
+ "severity": "high",
2217
+ "requires": [
2218
+ "tenancy == multi-tenant-shared-db"
2219
+ ],
2220
+ "tier": 2,
2221
+ "category": "multi-tenancy"
2222
+ },
2223
+ {
2224
+ "id": "TEN-010",
2225
+ "title": "Seat count not enforced server-side",
2226
+ "severity": "medium",
2227
+ "requires": [
2228
+ "tenancy == multi-tenant-shared-db",
2229
+ "has_subscriptions == true"
2230
+ ],
2231
+ "tier": 2,
2232
+ "category": "multi-tenancy"
2233
+ },
2234
+ {
2235
+ "id": "TEN-011",
2236
+ "title": "Usage metering not attributed per tenant",
2237
+ "severity": "medium",
2238
+ "requires": [
2239
+ "tenancy == multi-tenant-shared-db",
2240
+ "calls_llm == true"
2241
+ ],
2242
+ "tier": 2,
2243
+ "category": "multi-tenancy"
2244
+ },
2245
+ {
2246
+ "id": "TEN-012",
2247
+ "title": "Admin impersonation without an audit record",
2248
+ "severity": "high",
2249
+ "requires": [
2250
+ "has_admin_panel == true",
2251
+ "tenancy == multi-tenant-shared-db"
2252
+ ],
2253
+ "tier": 2,
2254
+ "category": "multi-tenancy"
2255
+ },
2256
+ {
2257
+ "id": "TEN-013",
2258
+ "title": "Search or aggregate query crosses tenant boundary",
2259
+ "severity": "critical",
2260
+ "requires": [
2261
+ "tenancy == multi-tenant-shared-db"
2262
+ ],
2263
+ "tier": 2,
2264
+ "note": "list endpoints get scoped; COUNT and search frequently do not.",
2265
+ "category": "multi-tenancy"
2266
+ },
2267
+ {
2268
+ "id": "TEN-014",
2269
+ "title": "Webhook or callback not tenant-scoped",
2270
+ "severity": "high",
2271
+ "requires": [
2272
+ "tenancy == multi-tenant-shared-db",
2273
+ "has_webhooks_in == true"
2274
+ ],
2275
+ "tier": 2,
2276
+ "category": "multi-tenancy"
2277
+ },
2278
+ {
2279
+ "id": "UP-001",
2280
+ "title": "File type validated by extension or client MIME only",
2281
+ "severity": "high",
2282
+ "requires": [
2283
+ "has_file_uploads == true"
2284
+ ],
2285
+ "tier": 1,
2286
+ "category": "file-uploads"
2287
+ },
2288
+ {
2289
+ "id": "UP-002",
2290
+ "title": "No upload size limit",
2291
+ "severity": "high",
2292
+ "requires": [
2293
+ "has_file_uploads == true"
2294
+ ],
2295
+ "tier": 1,
2296
+ "category": "file-uploads"
2297
+ },
2298
+ {
2299
+ "id": "UP-003",
2300
+ "title": "Upload path built from a user-supplied filename",
2301
+ "severity": "critical",
2302
+ "requires": [
2303
+ "has_file_uploads == true"
2304
+ ],
2305
+ "tier": 1,
2306
+ "check": "filename concatenated into a storage path without normalisation",
2307
+ "category": "file-uploads"
2308
+ },
2309
+ {
2310
+ "id": "UP-004",
2311
+ "title": "Uploaded files served from the app origin",
2312
+ "severity": "high",
2313
+ "requires": [
2314
+ "has_file_uploads == true",
2315
+ "surface in [web-app, web-site]"
2316
+ ],
2317
+ "tier": 2,
2318
+ "note": "a stored HTML or SVG becomes same-origin XSS.",
2319
+ "category": "file-uploads"
2320
+ },
2321
+ {
2322
+ "id": "UP-005",
2323
+ "title": "SVG accepted without sanitisation",
2324
+ "severity": "high",
2325
+ "requires": [
2326
+ "has_file_uploads == true"
2327
+ ],
2328
+ "tier": 1,
2329
+ "category": "file-uploads"
2330
+ },
2331
+ {
2332
+ "id": "UP-006",
2333
+ "title": "No malware scanning on shared files",
2334
+ "severity": "medium",
2335
+ "requires": [
2336
+ "has_file_uploads == true",
2337
+ "tenancy == multi-tenant-shared-db"
2338
+ ],
2339
+ "tier": 2,
2340
+ "category": "file-uploads"
2341
+ },
2342
+ {
2343
+ "id": "UP-007",
2344
+ "title": "Signed URLs with excessive lifetime",
2345
+ "severity": "medium",
2346
+ "requires": [
2347
+ "has_file_uploads == true"
2348
+ ],
2349
+ "tier": 1,
2350
+ "category": "file-uploads"
2351
+ },
2352
+ {
2353
+ "id": "UP-008",
2354
+ "title": "EXIF metadata not stripped from images",
2355
+ "severity": "medium",
2356
+ "requires": [
2357
+ "has_file_uploads == true",
2358
+ "data_sensitivity != none"
2359
+ ],
2360
+ "tier": 1,
2361
+ "note": "photo uploads routinely carry GPS coordinates.",
2362
+ "category": "file-uploads"
2363
+ },
2364
+ {
2365
+ "id": "UP-009",
2366
+ "title": "Image processing performed inline",
2367
+ "severity": "medium",
2368
+ "requires": [
2369
+ "has_file_uploads == true"
2370
+ ],
2371
+ "tier": 2,
2372
+ "category": "file-uploads"
2373
+ },
2374
+ {
2375
+ "id": "UP-010",
2376
+ "title": "No storage quota per user or tenant",
2377
+ "severity": "medium",
2378
+ "requires": [
2379
+ "has_file_uploads == true",
2380
+ "has_accounts == true"
2381
+ ],
2382
+ "tier": 2,
2383
+ "category": "file-uploads"
2384
+ },
2385
+ {
2386
+ "id": "UP-011",
2387
+ "title": "Deleted records leave files in storage",
2388
+ "severity": "medium",
2389
+ "requires": [
2390
+ "has_file_uploads == true"
2391
+ ],
2392
+ "tier": 2,
2393
+ "category": "file-uploads"
2394
+ },
2395
+ {
2396
+ "id": "UP-012",
2397
+ "title": "Zip or archive expanded without bounds",
2398
+ "severity": "high",
2399
+ "requires": [
2400
+ "has_file_uploads == true"
2401
+ ],
2402
+ "tier": 2,
2403
+ "check": "archive extraction without entry count, size, or path checks",
2404
+ "category": "file-uploads"
2405
+ },
2406
+ {
2407
+ "id": "API-001",
2408
+ "title": "Mass assignment \u2014 request body spread into a model",
2409
+ "severity": "critical",
2410
+ "requires": [
2411
+ "stack.database != none"
2412
+ ],
2413
+ "tier": 1,
2414
+ "check": "request body passed wholesale to create/update",
2415
+ "note": "this is how a user sets their own is_admin.",
2416
+ "category": "api-design"
2417
+ },
2418
+ {
2419
+ "id": "API-002",
2420
+ "title": "Internal error details returned to the client",
2421
+ "severity": "high",
2422
+ "requires": [
2423
+ "is_public == true"
2424
+ ],
2425
+ "tier": 1,
2426
+ "category": "api-design"
2427
+ },
2428
+ {
2429
+ "id": "API-003",
2430
+ "title": "Stack traces reachable in production responses",
2431
+ "severity": "high",
2432
+ "requires": [
2433
+ "is_public == true",
2434
+ "stage == production"
2435
+ ],
2436
+ "tier": 1,
2437
+ "category": "api-design"
2438
+ },
2439
+ {
2440
+ "id": "API-004",
2441
+ "title": "No maximum page size",
2442
+ "severity": "medium",
2443
+ "requires": [
2444
+ "is_public == true"
2445
+ ],
2446
+ "tier": 1,
2447
+ "check": "limit parameter accepted without an upper bound",
2448
+ "category": "api-design"
2449
+ },
2450
+ {
2451
+ "id": "API-005",
2452
+ "title": "Sort or filter parameter interpolated into a query",
2453
+ "severity": "critical",
2454
+ "requires": [
2455
+ "stack.database != none"
2456
+ ],
2457
+ "tier": 1,
2458
+ "category": "api-design"
2459
+ },
2460
+ {
2461
+ "id": "API-006",
2462
+ "title": "State-changing operation exposed over GET",
2463
+ "severity": "high",
2464
+ "requires": [
2465
+ "is_public == true"
2466
+ ],
2467
+ "tier": 1,
2468
+ "category": "api-design"
2469
+ },
2470
+ {
2471
+ "id": "API-007",
2472
+ "title": "No CSRF protection on cookie-authenticated mutations",
2473
+ "severity": "high",
2474
+ "requires": [
2475
+ "has_accounts == true",
2476
+ "surface in [web-app]"
2477
+ ],
2478
+ "tier": 2,
2479
+ "category": "api-design"
2480
+ },
2481
+ {
2482
+ "id": "API-008",
2483
+ "title": "No API versioning",
2484
+ "severity": "low",
2485
+ "requires": [
2486
+ "surface == api-only"
2487
+ ],
2488
+ "tier": 2,
2489
+ "category": "api-design"
2490
+ },
2491
+ {
2492
+ "id": "API-009",
2493
+ "title": "Verbose 404 vs 403 distinction leaks existence",
2494
+ "severity": "low",
2495
+ "requires": [
2496
+ "has_accounts == true"
2497
+ ],
2498
+ "tier": 2,
2499
+ "category": "api-design"
2500
+ },
2501
+ {
2502
+ "id": "API-010",
2503
+ "title": "No request timeout on outbound calls",
2504
+ "severity": "medium",
2505
+ "requires": [
2506
+ "stage == production"
2507
+ ],
2508
+ "tier": 1,
2509
+ "category": "api-design"
2510
+ },
2511
+ {
2512
+ "id": "API-011",
2513
+ "title": "No idempotency on retryable mutations",
2514
+ "severity": "medium",
2515
+ "requires": [
2516
+ "surface == api-only",
2517
+ "stage == production"
2518
+ ],
2519
+ "tier": 2,
2520
+ "category": "api-design"
2521
+ },
2522
+ {
2523
+ "id": "API-012",
2524
+ "title": "Security headers missing",
2525
+ "severity": "medium",
2526
+ "requires": [
2527
+ "surface in [web-app, web-site]",
2528
+ "is_public == true"
2529
+ ],
2530
+ "tier": 1,
2531
+ "check": "no CSP, X-Content-Type-Options, Referrer-Policy, frame ancestors",
2532
+ "category": "api-design"
2533
+ },
2534
+ {
2535
+ "id": "API-013",
2536
+ "title": "Open redirect",
2537
+ "severity": "high",
2538
+ "requires": [
2539
+ "is_public == true",
2540
+ "surface in [web-app, web-site]"
2541
+ ],
2542
+ "tier": 1,
2543
+ "check": "redirect target read from a query parameter without an allowlist",
2544
+ "category": "api-design"
2545
+ },
2546
+ {
2547
+ "id": "API-014",
2548
+ "title": "SSRF \u2014 outbound request to a user-supplied URL",
2549
+ "severity": "critical",
2550
+ "requires": [
2551
+ "is_public == true"
2552
+ ],
2553
+ "tier": 2,
2554
+ "has_static_approximation": true,
2555
+ "check": "fetch to a request-derived host without an allowlist or IP filter",
2556
+ "category": "api-design"
2557
+ },
2558
+ {
2559
+ "id": "NEXT-001",
2560
+ "title": "Server Action without an authorization check",
2561
+ "severity": "critical",
2562
+ "requires": [
2563
+ "stack.framework == next",
2564
+ "has_accounts == true"
2565
+ ],
2566
+ "tier": 2,
2567
+ "note": "a Server Action is a public POST endpoint. Being unexported from a page does not protect it.",
2568
+ "category": "framework-next"
2569
+ },
2570
+ {
2571
+ "id": "NEXT-002",
2572
+ "title": "Middleware matcher does not cover protected routes",
2573
+ "severity": "critical",
2574
+ "requires": [
2575
+ "stack.framework == next",
2576
+ "has_accounts == true"
2577
+ ],
2578
+ "tier": 2,
2579
+ "check": "auth enforced in middleware whose matcher misses API or nested routes",
2580
+ "category": "framework-next"
2581
+ },
2582
+ {
2583
+ "id": "NEXT-003",
2584
+ "title": "Auth enforced only in middleware",
2585
+ "severity": "high",
2586
+ "requires": [
2587
+ "stack.framework == next",
2588
+ "has_accounts == true"
2589
+ ],
2590
+ "tier": 2,
2591
+ "note": "defence in depth \u2014 the route handler must check too.",
2592
+ "category": "framework-next"
2593
+ },
2594
+ {
2595
+ "id": "NEXT-004",
2596
+ "title": "Route handler missing a session check",
2597
+ "severity": "critical",
2598
+ "requires": [
2599
+ "stack.framework == next",
2600
+ "has_accounts == true"
2601
+ ],
2602
+ "tier": 2,
2603
+ "category": "framework-next"
2604
+ },
2605
+ {
2606
+ "id": "NEXT-005",
2607
+ "title": "Server-only data passed into a Client Component",
2608
+ "severity": "high",
2609
+ "requires": [
2610
+ "stack.framework == next"
2611
+ ],
2612
+ "tier": 2,
2613
+ "check": "full record with sensitive columns serialised across the RSC boundary",
2614
+ "note": "props to a Client Component are shipped to the browser in full.",
2615
+ "category": "framework-next"
2616
+ },
2617
+ {
2618
+ "id": "NEXT-006",
2619
+ "title": "Secret imported into a module reachable from the client graph",
2620
+ "severity": "critical",
2621
+ "requires": [
2622
+ "stack.framework == next"
2623
+ ],
2624
+ "tier": 1,
2625
+ "category": "framework-next"
2626
+ },
2627
+ {
2628
+ "id": "NEXT-007",
2629
+ "title": "Cached route serves per-user data",
2630
+ "severity": "critical",
2631
+ "requires": [
2632
+ "stack.framework == next",
2633
+ "has_accounts == true"
2634
+ ],
2635
+ "tier": 2,
2636
+ "check": "force-static or revalidate on a route whose output varies per session",
2637
+ "note": "one user's dashboard cached and served to another.",
2638
+ "category": "framework-next"
2639
+ },
2640
+ {
2641
+ "id": "NEXT-008",
2642
+ "title": "generateStaticParams over user data",
2643
+ "severity": "medium",
2644
+ "requires": [
2645
+ "stack.framework == next"
2646
+ ],
2647
+ "tier": 2,
2648
+ "category": "framework-next"
2649
+ },
2650
+ {
2651
+ "id": "NEXT-009",
2652
+ "title": "Image optimizer allows arbitrary remote hosts",
2653
+ "severity": "medium",
2654
+ "requires": [
2655
+ "stack.framework == next"
2656
+ ],
2657
+ "tier": 1,
2658
+ "check": "remotePatterns with a wildcard hostname",
2659
+ "category": "framework-next"
2660
+ },
2661
+ {
2662
+ "id": "NEXT-010",
2663
+ "title": "Route handler lacks runtime/duration config for long work",
2664
+ "severity": "medium",
2665
+ "requires": [
2666
+ "stack.framework == next",
2667
+ "calls_llm == true"
2668
+ ],
2669
+ "tier": 1,
2670
+ "category": "framework-next"
2671
+ },
2672
+ {
2673
+ "id": "NEXT-011",
2674
+ "title": "Error boundary absent",
2675
+ "severity": "medium",
2676
+ "requires": [
2677
+ "stack.framework == next"
2678
+ ],
2679
+ "tier": 1,
2680
+ "check": "no error.tsx / global-error.tsx",
2681
+ "category": "framework-next"
2682
+ },
2683
+ {
2684
+ "id": "NEXT-012",
2685
+ "title": "Loading UI absent on data routes",
2686
+ "severity": "low",
2687
+ "requires": [
2688
+ "stack.framework == next"
2689
+ ],
2690
+ "tier": 1,
2691
+ "sources": [
2692
+ "DYc6sGNMuow"
2693
+ ],
2694
+ "category": "framework-next"
2695
+ },
2696
+ {
2697
+ "id": "NEXT-013",
2698
+ "title": "Cookies set without secure attributes",
2699
+ "severity": "high",
2700
+ "requires": [
2701
+ "stack.framework == next",
2702
+ "has_accounts == true"
2703
+ ],
2704
+ "tier": 1,
2705
+ "category": "framework-next"
2706
+ },
2707
+ {
2708
+ "id": "NEXT-014",
2709
+ "title": "Redirect after login uses an unvalidated next parameter",
2710
+ "severity": "high",
2711
+ "requires": [
2712
+ "stack.framework == next",
2713
+ "has_accounts == true"
2714
+ ],
2715
+ "tier": 1,
2716
+ "category": "framework-next"
2717
+ },
2718
+ {
2719
+ "id": "SUP-001",
2720
+ "title": "Storage bucket public by default",
2721
+ "severity": "critical",
2722
+ "requires": [
2723
+ "stack.database == supabase",
2724
+ "has_file_uploads == true"
2725
+ ],
2726
+ "tier": 1,
2727
+ "note": "mirrors the RLS problem \u2014 permissive default, silent exposure.",
2728
+ "category": "platform-supabase"
2729
+ },
2730
+ {
2731
+ "id": "SUP-002",
2732
+ "title": "Storage policies absent",
2733
+ "severity": "critical",
2734
+ "requires": [
2735
+ "stack.database == supabase",
2736
+ "has_file_uploads == true"
2737
+ ],
2738
+ "tier": 2,
2739
+ "category": "platform-supabase"
2740
+ },
2741
+ {
2742
+ "id": "SUP-003",
2743
+ "title": "Realtime channel without an authorization policy",
2744
+ "severity": "critical",
2745
+ "requires": [
2746
+ "stack.database == supabase",
2747
+ "has_realtime == true"
2748
+ ],
2749
+ "tier": 2,
2750
+ "note": "realtime subscriptions bypass the assumptions people make about REST-only RLS.",
2751
+ "category": "platform-supabase"
2752
+ },
2753
+ {
2754
+ "id": "SUP-004",
2755
+ "title": "Edge function using the service role for user-scoped work",
2756
+ "severity": "critical",
2757
+ "requires": [
2758
+ "stack.database == supabase"
2759
+ ],
2760
+ "tier": 2,
2761
+ "category": "platform-supabase"
2762
+ },
2763
+ {
2764
+ "id": "SUP-005",
2765
+ "title": "RLS policy references a client-supplied claim",
2766
+ "severity": "critical",
2767
+ "requires": [
2768
+ "stack.database == supabase",
2769
+ "has_accounts == true"
2770
+ ],
2771
+ "tier": 2,
2772
+ "check": "policy reads a JWT claim the user can influence rather than auth.uid()",
2773
+ "category": "platform-supabase"
2774
+ },
2775
+ {
2776
+ "id": "SUP-006",
2777
+ "title": "Postgres function marked SECURITY DEFINER without a search_path",
2778
+ "severity": "high",
2779
+ "requires": [
2780
+ "stack.database in [supabase, postgres]"
2781
+ ],
2782
+ "tier": 1,
2783
+ "category": "platform-supabase"
2784
+ },
2785
+ {
2786
+ "id": "SUP-007",
2787
+ "title": "Anon key granted write access to reference tables",
2788
+ "severity": "high",
2789
+ "requires": [
2790
+ "stack.database == supabase"
2791
+ ],
2792
+ "tier": 2,
2793
+ "category": "platform-supabase"
2794
+ },
2795
+ {
2796
+ "id": "SUP-008",
2797
+ "title": "Auth email templates left at defaults",
2798
+ "severity": "low",
2799
+ "requires": [
2800
+ "stack.auth == supabase-auth",
2801
+ "stage == production"
2802
+ ],
2803
+ "tier": 2,
2804
+ "category": "platform-supabase"
2805
+ },
2806
+ {
2807
+ "id": "SUP-009",
2808
+ "title": "No RLS on a view exposing base-table rows",
2809
+ "severity": "critical",
2810
+ "requires": [
2811
+ "stack.database in [supabase, postgres]",
2812
+ "has_accounts == true"
2813
+ ],
2814
+ "tier": 2,
2815
+ "note": "views are the standard RLS bypass \u2014 the base table is protected, the view is not.",
2816
+ "category": "platform-supabase"
2817
+ },
2818
+ {
2819
+ "id": "SUP-010",
2820
+ "title": "Database webhooks fire without signature verification",
2821
+ "severity": "high",
2822
+ "requires": [
2823
+ "stack.database == supabase",
2824
+ "has_webhooks_in == true"
2825
+ ],
2826
+ "tier": 2,
2827
+ "category": "platform-supabase"
2828
+ },
2829
+ {
2830
+ "id": "SUP-011",
2831
+ "title": "Connection string uses the direct port under serverless",
2832
+ "severity": "high",
2833
+ "requires": [
2834
+ "stack.database == supabase",
2835
+ "stack.host in [vercel, netlify, cloudflare]"
2836
+ ],
2837
+ "tier": 1,
2838
+ "note": "use the pooler; direct connections exhaust under serverless fan-out.",
2839
+ "category": "platform-supabase"
2840
+ },
2841
+ {
2842
+ "id": "SUP-012",
2843
+ "title": "No point-in-time recovery on a production project",
2844
+ "severity": "high",
2845
+ "requires": [
2846
+ "stack.database == supabase",
2847
+ "stage == production"
2848
+ ],
2849
+ "tier": 2,
2850
+ "category": "platform-supabase"
2851
+ },
2852
+ {
2853
+ "id": "LIFE-001",
2854
+ "title": "No data retention policy",
2855
+ "severity": "medium",
2856
+ "requires": [
2857
+ "data_sensitivity != none",
2858
+ "stage == production"
2859
+ ],
2860
+ "tier": 2,
2861
+ "category": "data-lifecycle"
2862
+ },
2863
+ {
2864
+ "id": "LIFE-002",
2865
+ "title": "Deletion is soft-only where hard deletion is required",
2866
+ "severity": "high",
2867
+ "requires": [
2868
+ "data_sensitivity != none",
2869
+ "jurisdictions includes any of [eu, uk]"
2870
+ ],
2871
+ "tier": 2,
2872
+ "note": "a soft-deleted row still holds the personal data an erasure request covers.",
2873
+ "category": "data-lifecycle"
2874
+ },
2875
+ {
2876
+ "id": "LIFE-003",
2877
+ "title": "Deletion does not cascade to derived stores",
2878
+ "severity": "high",
2879
+ "requires": [
2880
+ "data_sensitivity != none"
2881
+ ],
2882
+ "tier": 2,
2883
+ "check": "erasure path misses search index, cache, analytics, backups, prompt logs",
2884
+ "category": "data-lifecycle"
2885
+ },
2886
+ {
2887
+ "id": "LIFE-004",
2888
+ "title": "No data export for the user",
2889
+ "severity": "medium",
2890
+ "requires": [
2891
+ "data_sensitivity != none",
2892
+ "jurisdictions includes any of [eu, uk]"
2893
+ ],
2894
+ "tier": 2,
2895
+ "category": "data-lifecycle"
2896
+ },
2897
+ {
2898
+ "id": "LIFE-005",
2899
+ "title": "Sensitive columns unencrypted at rest beyond disk encryption",
2900
+ "severity": "high",
2901
+ "requires": [
2902
+ "data_sensitivity in [financial, health]"
2903
+ ],
2904
+ "tier": 2,
2905
+ "category": "data-lifecycle"
2906
+ },
2907
+ {
2908
+ "id": "LIFE-006",
2909
+ "title": "No audit trail on sensitive record access",
2910
+ "severity": "high",
2911
+ "requires": [
2912
+ "data_sensitivity in [financial, health]"
2913
+ ],
2914
+ "tier": 2,
2915
+ "category": "data-lifecycle"
2916
+ },
2917
+ {
2918
+ "id": "LIFE-007",
2919
+ "title": "Production data copied to non-production environments",
2920
+ "severity": "critical",
2921
+ "requires": [
2922
+ "data_sensitivity != none"
2923
+ ],
2924
+ "tier": 2,
2925
+ "category": "data-lifecycle"
2926
+ },
2927
+ {
2928
+ "id": "LIFE-008",
2929
+ "title": "Backups unencrypted or unrestricted",
2930
+ "severity": "critical",
2931
+ "requires": [
2932
+ "stage == production",
2933
+ "data_sensitivity != none"
2934
+ ],
2935
+ "tier": 2,
2936
+ "category": "data-lifecycle"
2937
+ },
2938
+ {
2939
+ "id": "LIFE-009",
2940
+ "title": "Logs retain personal data indefinitely",
2941
+ "severity": "medium",
2942
+ "requires": [
2943
+ "data_sensitivity != none",
2944
+ "stage == production"
2945
+ ],
2946
+ "tier": 2,
2947
+ "category": "data-lifecycle"
2948
+ },
2949
+ {
2950
+ "id": "LIFE-010",
2951
+ "title": "Analytics collects identifiers without a lawful basis",
2952
+ "severity": "high",
2953
+ "requires": [
2954
+ "collects_analytics == true",
2955
+ "jurisdictions includes any of [eu, uk]"
2956
+ ],
2957
+ "tier": 2,
2958
+ "category": "data-lifecycle"
2959
+ },
2960
+ {
2961
+ "id": "LIFE-011",
2962
+ "title": "No subprocessor list where one is required",
2963
+ "severity": "medium",
2964
+ "requires": [
2965
+ "business_model == b2b-saas",
2966
+ "jurisdictions includes any of [eu, uk]"
2967
+ ],
2968
+ "tier": 2,
2969
+ "category": "data-lifecycle"
2970
+ },
2971
+ {
2972
+ "id": "LIFE-012",
2973
+ "title": "Children's data collected without age gating",
2974
+ "severity": "critical",
2975
+ "requires": [
2976
+ "data_sensitivity == children"
2977
+ ],
2978
+ "tier": 2,
2979
+ "category": "data-lifecycle"
2980
+ },
2981
+ {
2982
+ "id": "MOB-001",
2983
+ "title": "Secret embedded in the app binary",
2984
+ "severity": "critical",
2985
+ "requires": [
2986
+ "surface in [mobile-ios, mobile-android]"
2987
+ ],
2988
+ "tier": 1,
2989
+ "note": "a shipped binary is a public file. anything in it is published.",
2990
+ "category": "mobile"
2991
+ },
2992
+ {
2993
+ "id": "MOB-002",
2994
+ "title": "Token stored outside the platform secure store",
2995
+ "severity": "high",
2996
+ "requires": [
2997
+ "surface in [mobile-ios, mobile-android]",
2998
+ "has_accounts == true"
2999
+ ],
3000
+ "tier": 1,
3001
+ "check": "credential in UserDefaults / SharedPreferences rather than Keychain / Keystore",
3002
+ "category": "mobile"
3003
+ },
3004
+ {
3005
+ "id": "MOB-003",
3006
+ "title": "No certificate validation or pinning on sensitive calls",
3007
+ "severity": "medium",
3008
+ "requires": [
3009
+ "surface in [mobile-ios, mobile-android]",
3010
+ "data_sensitivity in [financial, health]"
3011
+ ],
3012
+ "tier": 2,
3013
+ "category": "mobile"
3014
+ },
3015
+ {
3016
+ "id": "MOB-004",
3017
+ "title": "Permissions requested without a usage description",
3018
+ "severity": "high",
3019
+ "requires": [
3020
+ "surface in [mobile-ios, mobile-android]"
3021
+ ],
3022
+ "tier": 1,
3023
+ "category": "mobile"
3024
+ },
3025
+ {
3026
+ "id": "MOB-005",
3027
+ "title": "Privacy manifest or data-safety form incomplete",
3028
+ "severity": "high",
3029
+ "requires": [
3030
+ "surface in [mobile-ios, mobile-android]"
3031
+ ],
3032
+ "tier": 2,
3033
+ "sources": [
3034
+ "DYwx1b8TDAc"
3035
+ ],
3036
+ "category": "mobile"
3037
+ },
3038
+ {
3039
+ "id": "MOB-006",
3040
+ "title": "Onboarding requests data the identity provider already returns",
3041
+ "severity": "medium",
3042
+ "requires": [
3043
+ "surface == mobile-ios",
3044
+ "has_accounts == true"
3045
+ ],
3046
+ "tier": 2,
3047
+ "note": "corpus \u2014 rejected twice for asking a Sign in with Apple user for their name.",
3048
+ "sources": [
3049
+ "DXez-ZWkRgl"
3050
+ ],
3051
+ "category": "mobile"
3052
+ },
3053
+ {
3054
+ "id": "MOB-007",
3055
+ "title": "Paid features referenced without declaring the subscription",
3056
+ "severity": "high",
3057
+ "requires": [
3058
+ "surface in [mobile-ios, mobile-android]",
3059
+ "has_subscriptions == true"
3060
+ ],
3061
+ "tier": 2,
3062
+ "sources": [
3063
+ "DXez-ZWkRgl"
3064
+ ],
3065
+ "category": "mobile"
3066
+ },
3067
+ {
3068
+ "id": "MOB-008",
3069
+ "title": "No forced-update path for a broken release",
3070
+ "severity": "medium",
3071
+ "requires": [
3072
+ "surface in [mobile-ios, mobile-android]",
3073
+ "stage == production"
3074
+ ],
3075
+ "tier": 2,
3076
+ "note": "on mobile you cannot hotfix. you can only ask users to update.",
3077
+ "category": "mobile"
3078
+ },
3079
+ {
3080
+ "id": "MOB-009",
3081
+ "title": "Deep link accepted without validation",
3082
+ "severity": "high",
3083
+ "requires": [
3084
+ "surface in [mobile-ios, mobile-android]"
3085
+ ],
3086
+ "tier": 2,
3087
+ "category": "mobile"
3088
+ },
3089
+ {
3090
+ "id": "MOB-010",
3091
+ "title": "Sensitive screens not excluded from the app switcher snapshot",
3092
+ "severity": "low",
3093
+ "requires": [
3094
+ "surface in [mobile-ios, mobile-android]",
3095
+ "data_sensitivity in [financial, health]"
3096
+ ],
3097
+ "tier": 2,
3098
+ "category": "mobile"
3099
+ },
3100
+ {
3101
+ "id": "INC-001",
3102
+ "title": "No way to revoke all sessions",
3103
+ "severity": "high",
3104
+ "requires": [
3105
+ "has_accounts == true",
3106
+ "stage == production"
3107
+ ],
3108
+ "tier": 2,
3109
+ "origin": "gap",
3110
+ "note": "the first move in any breach response, and almost nobody can do it.",
3111
+ "category": "incident-readiness"
3112
+ },
3113
+ {
3114
+ "id": "INC-002",
3115
+ "title": "No kill switch for an abused feature",
3116
+ "severity": "high",
3117
+ "requires": [
3118
+ "stage == production",
3119
+ "calls_llm == true"
3120
+ ],
3121
+ "tier": 2,
3122
+ "origin": "gap",
3123
+ "note": "corpus author was under live attack with no way to turn the feature off.",
3124
+ "sources": [
3125
+ "DZmTu7Kgr0W"
3126
+ ],
3127
+ "category": "incident-readiness"
3128
+ },
3129
+ {
3130
+ "id": "INC-003",
3131
+ "title": "No feature flags for progressive rollout",
3132
+ "severity": "low",
3133
+ "requires": [
3134
+ "stage == production",
3135
+ "business_model in [b2b-saas, b2c]"
3136
+ ],
3137
+ "tier": 2,
3138
+ "origin": "gap",
3139
+ "category": "incident-readiness"
3140
+ },
3141
+ {
3142
+ "id": "INC-004",
3143
+ "title": "No security contact or disclosure path",
3144
+ "severity": "medium",
3145
+ "requires": [
3146
+ "is_public == true",
3147
+ "stage == production"
3148
+ ],
3149
+ "tier": 1,
3150
+ "origin": "gap",
3151
+ "check": "no security.txt, no security contact",
3152
+ "category": "incident-readiness"
3153
+ },
3154
+ {
3155
+ "id": "INC-005",
3156
+ "title": "No breach notification plan",
3157
+ "severity": "high",
3158
+ "requires": [
3159
+ "data_sensitivity != none",
3160
+ "jurisdictions includes any of [eu, uk]"
3161
+ ],
3162
+ "tier": 2,
3163
+ "origin": "gap",
3164
+ "note": "GDPR gives you 72 hours. That clock is not the time to start planning.",
3165
+ "category": "incident-readiness"
3166
+ },
3167
+ {
3168
+ "id": "INC-006",
3169
+ "title": "No alerting on anomalous spend",
3170
+ "severity": "critical",
3171
+ "requires": [
3172
+ "calls_llm == true",
3173
+ "stage == production"
3174
+ ],
3175
+ "tier": 2,
3176
+ "origin": "gap",
3177
+ "note": "every corpus horror story is a bill discovered days late.",
3178
+ "sources": [
3179
+ "DY4xvIAAo-m",
3180
+ "DZ7qlTboUvg"
3181
+ ],
3182
+ "category": "incident-readiness"
3183
+ },
3184
+ {
3185
+ "id": "INC-007",
3186
+ "title": "No alerting on auth failure spikes",
3187
+ "severity": "medium",
3188
+ "requires": [
3189
+ "has_accounts == true",
3190
+ "stage == production"
3191
+ ],
3192
+ "tier": 2,
3193
+ "origin": "gap",
3194
+ "category": "incident-readiness"
3195
+ },
3196
+ {
3197
+ "id": "INC-008",
3198
+ "title": "Logs not retained long enough to investigate",
3199
+ "severity": "medium",
3200
+ "requires": [
3201
+ "stage == production"
3202
+ ],
3203
+ "tier": 2,
3204
+ "origin": "gap",
3205
+ "category": "incident-readiness"
3206
+ },
3207
+ {
3208
+ "id": "INC-009",
3209
+ "title": "No documented on-call or escalation owner",
3210
+ "severity": "low",
3211
+ "requires": [
3212
+ "stage == production",
3213
+ "business_model == b2b-saas"
3214
+ ],
3215
+ "tier": 2,
3216
+ "origin": "gap",
3217
+ "category": "incident-readiness"
3218
+ },
3219
+ {
3220
+ "id": "INC-010",
3221
+ "title": "No status page or outage communication path",
3222
+ "severity": "low",
3223
+ "requires": [
3224
+ "stage == production",
3225
+ "business_model == b2b-saas"
3226
+ ],
3227
+ "tier": 2,
3228
+ "origin": "gap",
3229
+ "category": "incident-readiness"
3230
+ },
3231
+ {
3232
+ "id": "DJ-001",
3233
+ "title": "DEBUG is on in a deployed setting",
3234
+ "severity": "critical",
3235
+ "requires": [
3236
+ "stack.framework == django",
3237
+ "stage in [pre-launch, production]"
3238
+ ],
3239
+ "tier": 1,
3240
+ "check": "DEBUG = True in a settings module not named local/dev",
3241
+ "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.",
3242
+ "fix": "DEBUG = False in production, and set ALLOWED_HOSTS.",
3243
+ "category": "framework-django"
3244
+ },
3245
+ {
3246
+ "id": "DJ-002",
3247
+ "title": "SECRET_KEY written into source",
3248
+ "severity": "critical",
3249
+ "requires": [
3250
+ "stack.framework == django"
3251
+ ],
3252
+ "tier": 1,
3253
+ "check": "SECRET_KEY assigned a literal rather than read from the environment",
3254
+ "note": "SECRET_KEY signs sessions, password-reset tokens and CSRF tokens. Anyone holding it can forge a session for any user.",
3255
+ "fix": "Read it from an environment variable and rotate the committed value.",
3256
+ "category": "framework-django"
3257
+ },
3258
+ {
3259
+ "id": "DJ-003",
3260
+ "title": "ALLOWED_HOSTS accepts any host",
3261
+ "severity": "high",
3262
+ "requires": [
3263
+ "stack.framework == django",
3264
+ "is_public == true"
3265
+ ],
3266
+ "tier": 1,
3267
+ "check": "ALLOWED_HOSTS contains '*'",
3268
+ "note": "enables host-header poisoning, which turns password-reset emails into links pointing at an attacker's domain.",
3269
+ "category": "framework-django"
3270
+ },
3271
+ {
3272
+ "id": "DJ-004",
3273
+ "title": "CSRF middleware removed",
3274
+ "severity": "critical",
3275
+ "requires": [
3276
+ "stack.framework == django",
3277
+ "has_accounts == true"
3278
+ ],
3279
+ "tier": 1,
3280
+ "check": "MIDDLEWARE lacks django.middleware.csrf.CsrfViewMiddleware",
3281
+ "note": "it ships enabled. Its absence means someone deliberately removed it.",
3282
+ "category": "framework-django"
3283
+ },
3284
+ {
3285
+ "id": "DJ-005",
3286
+ "title": "mark_safe or |safe applied to user content",
3287
+ "severity": "critical",
3288
+ "requires": [
3289
+ "stack.framework == django"
3290
+ ],
3291
+ "tier": 2,
3292
+ "check": "mark_safe(), format_html with unescaped input, or |safe in a template on a value that originates from a request",
3293
+ "note": "Django escapes templates by default. These are the two ways to switch that off.",
3294
+ "category": "framework-django"
3295
+ },
3296
+ {
3297
+ "id": "DJ-006",
3298
+ "title": "Raw SQL built by interpolation",
3299
+ "severity": "critical",
3300
+ "requires": [
3301
+ "stack.framework == django"
3302
+ ],
3303
+ "tier": 1,
3304
+ "check": ".raw() or .extra() containing an f-string, % or .format()",
3305
+ "fix": "Pass parameters: .raw('SELECT \u2026 WHERE id = %s', [id])",
3306
+ "category": "framework-django"
3307
+ },
3308
+ {
3309
+ "id": "DJ-007",
3310
+ "title": "HTTPS not enforced",
3311
+ "severity": "high",
3312
+ "requires": [
3313
+ "stack.framework == django",
3314
+ "stage == production",
3315
+ "is_public == true"
3316
+ ],
3317
+ "tier": 1,
3318
+ "check": "SECURE_SSL_REDIRECT absent or False",
3319
+ "category": "framework-django"
3320
+ },
3321
+ {
3322
+ "id": "DJ-008",
3323
+ "title": "Session and CSRF cookies not marked secure",
3324
+ "severity": "high",
3325
+ "requires": [
3326
+ "stack.framework == django",
3327
+ "has_accounts == true",
3328
+ "stage == production"
3329
+ ],
3330
+ "tier": 1,
3331
+ "check": "SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE missing or False",
3332
+ "note": "without these the cookies travel over plain HTTP on the first request.",
3333
+ "category": "framework-django"
3334
+ },
3335
+ {
3336
+ "id": "DJ-009",
3337
+ "title": "HSTS not configured",
3338
+ "severity": "medium",
3339
+ "requires": [
3340
+ "stack.framework == django",
3341
+ "stage == production",
3342
+ "is_public == true"
3343
+ ],
3344
+ "tier": 1,
3345
+ "check": "SECURE_HSTS_SECONDS is 0 or unset",
3346
+ "category": "framework-django"
3347
+ },
3348
+ {
3349
+ "id": "DJ-010",
3350
+ "title": "ModelForm or serializer exposes every field",
3351
+ "severity": "critical",
3352
+ "requires": [
3353
+ "stack.framework == django"
3354
+ ],
3355
+ "tier": 1,
3356
+ "check": "fields = '__all__' on a ModelForm or ModelSerializer",
3357
+ "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.",
3358
+ "fix": "List the fields you actually accept.",
3359
+ "category": "framework-django"
3360
+ },
3361
+ {
3362
+ "id": "DJ-011",
3363
+ "title": "View has no authentication requirement",
3364
+ "severity": "critical",
3365
+ "requires": [
3366
+ "stack.framework == django",
3367
+ "has_accounts == true"
3368
+ ],
3369
+ "tier": 2,
3370
+ "check": "a view that touches the ORM without @login_required, LoginRequiredMixin, or a DRF permission_class",
3371
+ "category": "framework-django"
3372
+ },
3373
+ {
3374
+ "id": "DJ-012",
3375
+ "title": "Password validators removed",
3376
+ "severity": "medium",
3377
+ "requires": [
3378
+ "stack.framework == django",
3379
+ "has_accounts == true"
3380
+ ],
3381
+ "tier": 1,
3382
+ "check": "AUTH_PASSWORD_VALIDATORS is an empty list",
3383
+ "category": "framework-django"
3384
+ },
3385
+ {
3386
+ "id": "DJ-013",
3387
+ "title": "Clickjacking protection disabled",
3388
+ "severity": "medium",
3389
+ "requires": [
3390
+ "stack.framework == django",
3391
+ "is_public == true"
3392
+ ],
3393
+ "tier": 1,
3394
+ "check": "XFrameOptionsMiddleware absent, or X_FRAME_OPTIONS set to ALLOWALL",
3395
+ "category": "framework-django"
3396
+ },
3397
+ {
3398
+ "id": "DJ-014",
3399
+ "title": "Sessions serialised with pickle",
3400
+ "severity": "critical",
3401
+ "requires": [
3402
+ "stack.framework == django"
3403
+ ],
3404
+ "tier": 1,
3405
+ "check": "SESSION_SERIALIZER set to PickleSerializer",
3406
+ "note": "combined with a leaked SECRET_KEY this is remote code execution, not just session forgery.",
3407
+ "category": "framework-django"
3408
+ },
3409
+ {
3410
+ "id": "DJ-015",
3411
+ "title": "Queryset not scoped to the requesting user",
3412
+ "severity": "critical",
3413
+ "requires": [
3414
+ "stack.framework == django",
3415
+ "has_accounts == true"
3416
+ ],
3417
+ "tier": 2,
3418
+ "check": "Model.objects.get/filter keyed on a request parameter with no user or organisation predicate",
3419
+ "note": "Django's version of the change-the-id-in-the-URL bug.",
3420
+ "category": "framework-django"
3421
+ },
3422
+ {
3423
+ "id": "DJ-016",
3424
+ "title": "DEBUG toolbar or dev-only app installed in production",
3425
+ "severity": "high",
3426
+ "requires": [
3427
+ "stack.framework == django",
3428
+ "stage == production"
3429
+ ],
3430
+ "tier": 1,
3431
+ "check": "debug_toolbar, django_extensions or silk present in production INSTALLED_APPS",
3432
+ "category": "framework-django"
3433
+ },
3434
+ {
3435
+ "id": "DJ-017",
3436
+ "title": "Media files served by Django itself",
3437
+ "severity": "medium",
3438
+ "requires": [
3439
+ "stack.framework == django",
3440
+ "has_file_uploads == true",
3441
+ "stage == production"
3442
+ ],
3443
+ "tier": 2,
3444
+ "check": "static() or serve() used for MEDIA_ROOT outside DEBUG",
3445
+ "note": "Django's docs are explicit that this is insecure and inefficient in production.",
3446
+ "category": "framework-django"
3447
+ },
3448
+ {
3449
+ "id": "DJ-018",
3450
+ "title": "No rate limiting on authentication views",
3451
+ "severity": "high",
3452
+ "requires": [
3453
+ "stack.framework == django",
3454
+ "has_accounts == true",
3455
+ "is_public == true"
3456
+ ],
3457
+ "tier": 2,
3458
+ "check": "no django-axes, django-ratelimit or equivalent guarding login",
3459
+ "note": "Django ships no brute-force protection. Nothing is watching by default.",
3460
+ "category": "framework-django"
3461
+ },
3462
+ {
3463
+ "id": "RB-001",
3464
+ "title": "secret_key_base committed to the repository",
3465
+ "severity": "critical",
3466
+ "requires": [
3467
+ "stack.framework == rails"
3468
+ ],
3469
+ "tier": 1,
3470
+ "check": "a literal secret_key_base in config/secrets.yml, credentials.yml or an initializer",
3471
+ "note": "signs every session cookie. With it, anyone mints a session for any user \u2014 and Rails historically deserialised session data, so it has meant RCE.",
3472
+ "fix": "Use encrypted credentials or ENV, and rotate the exposed key.",
3473
+ "category": "framework-rails"
3474
+ },
3475
+ {
3476
+ "id": "RB-002",
3477
+ "title": "config.force_ssl not enabled in production",
3478
+ "severity": "high",
3479
+ "requires": [
3480
+ "stack.framework == rails",
3481
+ "stage == production",
3482
+ "is_public == true"
3483
+ ],
3484
+ "tier": 1,
3485
+ "check": "config.force_ssl absent or false in config/environments/production.rb",
3486
+ "note": "one line, and it enables the redirect, secure cookies and HSTS together.",
3487
+ "category": "framework-rails"
3488
+ },
3489
+ {
3490
+ "id": "RB-003",
3491
+ "title": "CSRF protection disabled or skipped",
3492
+ "severity": "critical",
3493
+ "requires": [
3494
+ "stack.framework == rails",
3495
+ "has_accounts == true"
3496
+ ],
3497
+ "tier": 1,
3498
+ "check": "skip_before_action :verify_authenticity_token, or protect_from_forgery with: :null_session on a state-changing controller",
3499
+ "category": "framework-rails"
3500
+ },
3501
+ {
3502
+ "id": "RB-004",
3503
+ "title": "html_safe or raw applied to user content",
3504
+ "severity": "critical",
3505
+ "requires": [
3506
+ "stack.framework == rails"
3507
+ ],
3508
+ "tier": 1,
3509
+ "check": ".html_safe or raw() on a value derived from params, or <%== in an ERB template",
3510
+ "note": "ERB escapes by default. These are the ways to turn that off.",
3511
+ "category": "framework-rails"
3512
+ },
3513
+ {
3514
+ "id": "RB-005",
3515
+ "title": "SQL built by string interpolation",
3516
+ "severity": "critical",
3517
+ "requires": [
3518
+ "stack.framework == rails"
3519
+ ],
3520
+ "tier": 1,
3521
+ "check": "where(\"\u2026 #{\u2026}\"), find_by_sql or order() containing interpolation",
3522
+ "fix": "where('email = ?', email) \u2014 the placeholder form.",
3523
+ "category": "framework-rails"
3524
+ },
3525
+ {
3526
+ "id": "RB-006",
3527
+ "title": "Strong parameters bypassed with permit!",
3528
+ "severity": "critical",
3529
+ "requires": [
3530
+ "stack.framework == rails"
3531
+ ],
3532
+ "tier": 1,
3533
+ "check": "params.permit! or params.require(:x).permit!",
3534
+ "note": "permit! whitelists everything the caller sent. Rails added strong parameters precisely because of the GitHub mass-assignment incident.",
3535
+ "category": "framework-rails"
3536
+ },
3537
+ {
3538
+ "id": "RB-007",
3539
+ "title": "Record loaded by id with no ownership scope",
3540
+ "severity": "critical",
3541
+ "requires": [
3542
+ "stack.framework == rails",
3543
+ "has_accounts == true"
3544
+ ],
3545
+ "tier": 2,
3546
+ "check": "Model.find(params[:id]) with no current_user or tenant scope in the action",
3547
+ "fix": "current_user.invoices.find(params[:id]) \u2014 scope through the association.",
3548
+ "category": "framework-rails"
3549
+ },
3550
+ {
3551
+ "id": "RB-008",
3552
+ "title": "Unsafe deserialisation of user input",
3553
+ "severity": "critical",
3554
+ "requires": [
3555
+ "stack.framework == rails"
3556
+ ],
3557
+ "tier": 1,
3558
+ "check": "Marshal.load, YAML.load or Oj.load applied to request data",
3559
+ "note": "each of these instantiates arbitrary Ruby objects. Use YAML.safe_load.",
3560
+ "category": "framework-rails"
3561
+ },
3562
+ {
3563
+ "id": "RB-009",
3564
+ "title": "File served from a user-supplied path",
3565
+ "severity": "critical",
3566
+ "requires": [
3567
+ "stack.framework == rails"
3568
+ ],
3569
+ "tier": 1,
3570
+ "check": "send_file or render file: built from params",
3571
+ "note": "../../config/master.key is a valid filename as far as the filesystem cares.",
3572
+ "category": "framework-rails"
3573
+ },
3574
+ {
3575
+ "id": "RB-010",
3576
+ "title": "Controller has no authentication callback",
3577
+ "severity": "critical",
3578
+ "requires": [
3579
+ "stack.framework == rails",
3580
+ "has_accounts == true"
3581
+ ],
3582
+ "tier": 2,
3583
+ "check": "a controller touching models without before_action :authenticate_user! or an equivalent, and not inheriting one",
3584
+ "category": "framework-rails"
3585
+ },
3586
+ {
3587
+ "id": "RB-011",
3588
+ "title": "Sensitive parameters not filtered from logs",
3589
+ "severity": "high",
3590
+ "requires": [
3591
+ "stack.framework == rails",
3592
+ "has_accounts == true"
3593
+ ],
3594
+ "tier": 1,
3595
+ "check": "config.filter_parameters omits password, token or secret",
3596
+ "note": "otherwise plaintext passwords are written into production logs on every sign-in, and logs are backed up, shipped and searched.",
3597
+ "category": "framework-rails"
3598
+ },
3599
+ {
3600
+ "id": "RB-012",
3601
+ "title": "Detailed exception pages enabled in production",
3602
+ "severity": "high",
3603
+ "requires": [
3604
+ "stack.framework == rails",
3605
+ "stage == production"
3606
+ ],
3607
+ "tier": 1,
3608
+ "check": "config.consider_all_requests_local = true in production.rb",
3609
+ "note": "turns every 500 into a stack trace with source and local variables.",
3610
+ "category": "framework-rails"
3611
+ },
3612
+ {
3613
+ "id": "RB-013",
3614
+ "title": "Mass assignment through nested attributes",
3615
+ "severity": "high",
3616
+ "requires": [
3617
+ "stack.framework == rails"
3618
+ ],
3619
+ "tier": 2,
3620
+ "check": "accepts_nested_attributes_for on a model whose permitted params are not equally constrained",
3621
+ "category": "framework-rails"
3622
+ },
3623
+ {
3624
+ "id": "RB-014",
3625
+ "title": "Devise configured without lockable or timeout",
3626
+ "severity": "medium",
3627
+ "requires": [
3628
+ "stack.framework == rails",
3629
+ "stack.auth == devise",
3630
+ "is_public == true"
3631
+ ],
3632
+ "tier": 1,
3633
+ "check": "devise model lacks :lockable, or Devise.timeout_in is unset",
3634
+ "note": "Devise ships neither brute-force lockout nor session expiry enabled.",
3635
+ "category": "framework-rails"
3636
+ },
3637
+ {
3638
+ "id": "RB-015",
3639
+ "title": "Background job takes raw user input as an argument",
3640
+ "severity": "medium",
3641
+ "requires": [
3642
+ "stack.framework == rails",
3643
+ "has_background_jobs == true"
3644
+ ],
3645
+ "tier": 2,
3646
+ "check": "perform_later called with request parameters passed straight through",
3647
+ "note": "job arguments are serialised and often logged, and run outside the request's authorisation context.",
3648
+ "category": "framework-rails"
3649
+ }
3650
+ ]
3651
+ }