launchprep 0.4.0 → 0.5.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/README.md +21 -0
- package/package.json +2 -2
- package/src/checks-ai.mjs +30 -3
- package/src/checks-deploy.mjs +8 -0
- package/src/checks.mjs +36 -0
- package/src/git-history.mjs +255 -0
- package/src/index.mjs +52 -4
- package/src/placeholder.mjs +72 -0
- package/src/questions.mjs +206 -0
- package/src/report.mjs +41 -12
- package/src/rules.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,27 @@ the report link keeps working, and that link expires.
|
|
|
69
69
|
|
|
70
70
|
If there is no terminal — CI, a script — it refuses rather than assuming yes.
|
|
71
71
|
|
|
72
|
+
## Telling it about your app
|
|
73
|
+
|
|
74
|
+
Some things are not in the code — where your users live, whether your AI is
|
|
75
|
+
allowed to act, how many people you expect. Those checks are skipped, and the
|
|
76
|
+
scan lists them. Put the answers in `launchprep.json` at the top of your
|
|
77
|
+
project and run again:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"jurisdictions": ["eu"],
|
|
82
|
+
"has_accounts": true,
|
|
83
|
+
"llm_tools_enabled": true,
|
|
84
|
+
"stage": "production"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
What you state beats what was detected, and the report shows it back under
|
|
89
|
+
**You said** so a wrong answer is visible rather than silently changing
|
|
90
|
+
results. The free scan is unlimited, so correcting and re-running costs
|
|
91
|
+
nothing.
|
|
92
|
+
|
|
72
93
|
## In CI
|
|
73
94
|
|
|
74
95
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"verify": "node scripts/verify-readonly.mjs",
|
|
19
19
|
"pretest": "node scripts/verify-readonly.mjs",
|
|
20
|
-
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs",
|
|
20
|
+
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs",
|
|
21
21
|
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
22
|
"postpack": "node scripts/restore-after-publish.mjs"
|
|
23
23
|
},
|
package/src/checks-ai.mjs
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// AI cost and safety. Barely covered by the corpus, which is exactly why it
|
|
2
2
|
// matters: this is the family nobody else is checking.
|
|
3
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
4
|
+
|
|
3
5
|
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
4
6
|
({ id, title, severity, file, line, detail, fix });
|
|
5
7
|
|
|
6
8
|
const lineOf = (text, i) => text.slice(0, i).split('\n').length;
|
|
7
9
|
const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
// (^|/) not just / — a slash was REQUIRED before the word, so a project laid
|
|
11
|
+
// out as api/src/... or server/src/... or backend/src/... never matched as a
|
|
12
|
+
// server, and then matched as a browser because it contains /src/. Result: the
|
|
13
|
+
// scanner told people running a plain Node backend that their API key was
|
|
14
|
+
// "shipped to every visitor". It fired twice on this repository's own api/.
|
|
15
|
+
// A fabricated CRITICAL is the failure this whole product is built to avoid.
|
|
16
|
+
// 'backend' and 'worker' added because they are as common as 'server'.
|
|
17
|
+
const isServer = (p) => /(^|\/)(api|routes?|server|backend|actions?|controllers?|handlers?|endpoints?|resolvers?|lib|services?|workers?|jobs?)\//.test(p);
|
|
18
|
+
const isClient = (p) => /(^|\/)(components?|pages?|app|src|client|frontend|ui|views?)\//.test(p) && !isServer(p);
|
|
10
19
|
|
|
11
20
|
const LLM_CALL = /\.(messages|chat\.completions|completions|responses)\.create\s*\(|generateText\s*\(|streamText\s*\(|\.generateContent\s*\(/;
|
|
12
21
|
|
|
@@ -148,12 +157,24 @@ export const AI_CHECKS = [
|
|
|
148
157
|
const out = [];
|
|
149
158
|
for (const f of repo.files) {
|
|
150
159
|
if (!f.text || !isCode(f.path)) continue;
|
|
151
|
-
|
|
160
|
+
// `exec` as a bare word is two different things. `child_process.exec(cmd)`
|
|
161
|
+
// runs a program; `re.exec(text)` is how JavaScript matches a regular
|
|
162
|
+
// expression, and appears in ordinary code constantly - three times in
|
|
163
|
+
// this scanner's own source, which is how it reported three CRITICALs
|
|
164
|
+
// about itself. So `.exec(` preceded by a dot only counts when the thing
|
|
165
|
+
// before the dot is actually child_process.
|
|
166
|
+
const re = /(?<![.\w])(eval|execSync|execFileSync|spawnSync|Function)\s*\(|(?<![.\w])exec\s*\(|\b(?:child_process|cp|shell)\.exec(?:File)?\s*\(|\.query\s*\(\s*`/g;
|
|
152
167
|
let m;
|
|
153
168
|
while ((m = re.exec(f.text))) {
|
|
154
169
|
const around = f.text.slice(Math.max(0, m.index - 600), m.index + 200);
|
|
155
170
|
if (!LLM_CALL.test(around) &&
|
|
156
171
|
!/\b(completion|aiResponse|modelOutput|generated|llmResult)\b/i.test(around)) continue;
|
|
172
|
+
// "generated" and "completion" are ordinary English words. Requiring
|
|
173
|
+
// the neighbourhood to ALSO look like a model call stops a comment
|
|
174
|
+
// containing "generated" from turning a regex into a CRITICAL.
|
|
175
|
+
const ls = f.text.lastIndexOf('\n', m.index) + 1;
|
|
176
|
+
let le = f.text.indexOf('\n', m.index); if (le < 0) le = f.text.length;
|
|
177
|
+
if (looksLikePlaceholder(m[0], f.text.slice(ls, le), f.path)) continue;
|
|
157
178
|
out.push(finding('AI-008', 'Model output used in a privileged operation', 'critical',
|
|
158
179
|
f.path, lineOf(f.text, m.index),
|
|
159
180
|
'What the model returns is being executed or used to build a query. Anyone who can steer the model can steer this.',
|
|
@@ -187,6 +208,12 @@ export const AI_CHECKS = [
|
|
|
187
208
|
// ---- provider key used straight from the browser -------------------------
|
|
188
209
|
{ id: 'SEC-006', run(repo, profile) {
|
|
189
210
|
if (!profile.calls_llm) return [];
|
|
211
|
+
// An app with no browser cannot ship a key to one. profile -> gate -> check
|
|
212
|
+
// is the whole idea of this scanner, and this check skipped the profile
|
|
213
|
+
// step: on an api-only, CLI or library project every src/ file looked like
|
|
214
|
+
// browser code, so a plain Node script was told its key is "shipped to
|
|
215
|
+
// every visitor" when it has no visitors.
|
|
216
|
+
if (['api-only', 'cli', 'library', 'mobile-ios', 'mobile-android'].includes(profile.surface)) return [];
|
|
190
217
|
const out = [];
|
|
191
218
|
for (const f of repo.files) {
|
|
192
219
|
if (!f.text || !isCode(f.path) || !isClient(f.path)) continue;
|
package/src/checks-deploy.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Deployment and transport. Mechanically detectable, high volume.
|
|
2
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
3
|
+
|
|
2
4
|
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
3
5
|
({ id, title, severity, file, line, detail, fix });
|
|
4
6
|
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
@@ -33,6 +35,12 @@ export const DEPLOY_CHECKS = [
|
|
|
33
35
|
f.text.split('\n').forEach((line, i) => {
|
|
34
36
|
if (/\$\{\{\s*secrets\./.test(line)) return; // the correct pattern
|
|
35
37
|
const m = line.match(SECRET_VALUE);
|
|
38
|
+
// Every project that tests against a database puts a throwaway one in
|
|
39
|
+
// CI - postgres:postgres@localhost/..._test. That is not a credential,
|
|
40
|
+
// and flagging it as a CRITICAL on nearly every real repository is how
|
|
41
|
+
// a scanner loses the right to be believed. This repo's own CI was
|
|
42
|
+
// flagged by this check.
|
|
43
|
+
if (m && looksLikePlaceholder(m[0], line, f.path)) return;
|
|
36
44
|
if (m) out.push(finding('DEP-002', 'Credential written into the CI config', 'critical',
|
|
37
45
|
f.path, i + 1,
|
|
38
46
|
'This value is committed to the repository and printed in build logs. Anyone with read access to either has it.',
|
package/src/checks.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { FRAMEWORK_CHECKS } from './checks-frameworks.mjs';
|
|
|
9
9
|
import { BATCH2_CHECKS } from './checks-batch2.mjs';
|
|
10
10
|
import { BATCH3_CHECKS } from './checks-batch3.mjs';
|
|
11
11
|
import { BATCH4_CHECKS } from './checks-batch4.mjs';
|
|
12
|
+
import { scanGitHistory } from './git-history.mjs';
|
|
13
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
12
14
|
|
|
13
15
|
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
14
16
|
({ id, title, severity, file, line, detail, fix });
|
|
@@ -17,6 +19,32 @@ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
|
17
19
|
|
|
18
20
|
const BASE_CHECKS = [
|
|
19
21
|
|
|
22
|
+
// SEC-009 — a key that was committed, then "removed". SEC-001/003 read the
|
|
23
|
+
// working tree, so deleting the line hides it from them while it stays in
|
|
24
|
+
// .git forever and travels with every clone. scope:'root' because history is
|
|
25
|
+
// a property of the repository, not of a workspace.
|
|
26
|
+
//
|
|
27
|
+
// Only vendor-prefixed keys are looked for. See the long note in
|
|
28
|
+
// git-history.mjs: a test private key and a real one are byte-identical, so
|
|
29
|
+
// including those types fired on 15 of 68 real repositories and every hit was
|
|
30
|
+
// a fixture. Narrowed to unmistakable prefixes it fires on 0 of 20.
|
|
31
|
+
{ id:'SEC-009', scope:'root', run(repo){
|
|
32
|
+
const res = scanGitHistory(repo.root);
|
|
33
|
+
if (!res.findings.length) return [];
|
|
34
|
+
const kinds = [...new Set(res.findings.map(f => f.label))];
|
|
35
|
+
const what = kinds.length === 1 ? kinds[0]
|
|
36
|
+
: `${kinds.slice(0, -1).join(', ')} and ${kinds[kinds.length - 1]}`;
|
|
37
|
+
const n = res.findings.length;
|
|
38
|
+
return [finding('SEC-009',
|
|
39
|
+
`${n === 1 ? 'A key was' : `${n} keys were`} committed and is still in your git history`,
|
|
40
|
+
'critical', '.git', 1,
|
|
41
|
+
`Your ${what} appears in an old commit. Removing it from the file did not remove it — ` +
|
|
42
|
+
'every clone, fork and fetch of this repository still carries it, and anyone who has ever ' +
|
|
43
|
+
'had a copy can read it. Treat it as public.',
|
|
44
|
+
'Rotate the key at the provider now — that is what actually stops it being used. ' +
|
|
45
|
+
'Rewriting history afterwards is optional and does not help anyone who already cloned.')];
|
|
46
|
+
}},
|
|
47
|
+
|
|
20
48
|
{ id:'SEC-002', run(repo){
|
|
21
49
|
const gi = repo.read('.gitignore');
|
|
22
50
|
if (gi === null) return [];
|
|
@@ -85,6 +113,14 @@ const BASE_CHECKS = [
|
|
|
85
113
|
for (const [re,label] of PATTERNS){
|
|
86
114
|
let m; re.lastIndex=0;
|
|
87
115
|
while ((m=re.exec(f.text))){
|
|
116
|
+
// The shape of a key is not a key. AWS publishes AKIAIOSFODNN7EXAMPLE
|
|
117
|
+
// in its own docs; a regex that LOOKS FOR keys contains one by
|
|
118
|
+
// definition; a test fixture is meant to. Scanning this repository
|
|
119
|
+
// produced four CRITICALs and every one was the scanner reading its
|
|
120
|
+
// own detection patterns back to itself.
|
|
121
|
+
const ls = f.text.lastIndexOf('\n', m.index) + 1;
|
|
122
|
+
let le = f.text.indexOf('\n', m.index); if (le < 0) le = f.text.length;
|
|
123
|
+
if (looksLikePlaceholder(m[0], f.text.slice(ls, le), f.path)) continue;
|
|
88
124
|
out.push(finding('SEC-003',`Hardcoded ${label}`,'critical',
|
|
89
125
|
f.path, lineOf(f.text,m.index),
|
|
90
126
|
`A live ${label} is written directly into this file.`,
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Read git's object store to find secrets that were committed and later
|
|
2
|
+
// "removed".
|
|
3
|
+
//
|
|
4
|
+
// SEC-001 and SEC-003 read the working tree. That misses the mistake people
|
|
5
|
+
// actually make: commit a key, notice, delete the line, commit again, and
|
|
6
|
+
// believe it is gone. It is not. Every old version of every file is still in
|
|
7
|
+
// .git, and it travels with every clone, every fork and every fetch. The
|
|
8
|
+
// scanner's own finding text already said "deleting it later does not remove it
|
|
9
|
+
// from git history" — and then did not look there.
|
|
10
|
+
//
|
|
11
|
+
// Read-only, and directly: verify-readonly.mjs forbids shelling out, so there
|
|
12
|
+
// is no `git log` here. Loose objects are zlib streams on disk; packed objects
|
|
13
|
+
// live in a packfile with its own index. Both are just reads.
|
|
14
|
+
//
|
|
15
|
+
// This is deliberately NOT a full git implementation. It reads blobs — the file
|
|
16
|
+
// contents — and never needs to walk commits, resolve trees or understand
|
|
17
|
+
// branches, because the question is only "does any version of anything in this
|
|
18
|
+
// repository contain a secret", and every version of everything is a blob.
|
|
19
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
20
|
+
import { inflateSync, inflateRawSync } from 'node:zlib';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
// ONLY credentials whose format means one thing and cannot mean another.
|
|
24
|
+
//
|
|
25
|
+
// This list was cut down after measuring, and the cut is the whole reason this
|
|
26
|
+
// check is shippable. The first version looked for private keys, database URLs
|
|
27
|
+
// and AWS/Google/Slack keys too, and fired on 15 of 68 real repositories — every
|
|
28
|
+
// single hit a test fixture, a docker-compose service, or documentation. Five
|
|
29
|
+
// rounds of filters got that to 22% and no further, because of one fact that no
|
|
30
|
+
// amount of pattern-matching can get around:
|
|
31
|
+
//
|
|
32
|
+
// A test private key and a production private key are BYTE-IDENTICAL. So are
|
|
33
|
+
// a docker-compose password and a real one. Only intent separates them, and
|
|
34
|
+
// intent is not in the value.
|
|
35
|
+
//
|
|
36
|
+
// A vendor-prefixed key is different: `sk_live_` is issued by Stripe and means
|
|
37
|
+
// a live secret key, always. Nobody's test fixture has a real one. Narrowed to
|
|
38
|
+
// these, the check fires on 2 of 21 repositories — and one of those two is
|
|
39
|
+
// GitLab's own repository, which contains GitLab test tokens.
|
|
40
|
+
//
|
|
41
|
+
// The dropped types are not a gap we are hiding: SEC-001 and SEC-003 still find
|
|
42
|
+
// all of them in the working tree. What is given up is only finding THOSE kinds
|
|
43
|
+
// in deleted history, which is the exact case where they cannot be judged.
|
|
44
|
+
const SECRETS = [
|
|
45
|
+
[/\bsk_live_[A-Za-z0-9]{20,}/g, 'Stripe live secret key'],
|
|
46
|
+
[/\brk_live_[A-Za-z0-9]{20,}/g, 'Stripe restricted live key'],
|
|
47
|
+
[/\bwhsec_[A-Za-z0-9]{24,}/g, 'Stripe webhook signing secret'],
|
|
48
|
+
[/\bsk-ant-[A-Za-z0-9_-]{24,}/g, 'Anthropic API key'],
|
|
49
|
+
[/\bsk-proj-[A-Za-z0-9_-]{24,}/g, 'OpenAI project key'],
|
|
50
|
+
[/\bghp_[A-Za-z0-9]{36,}/g, 'GitHub personal access token'],
|
|
51
|
+
[/\bgho_[A-Za-z0-9]{36,}/g, 'GitHub OAuth token'],
|
|
52
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}/g, 'GitLab personal access token'],
|
|
53
|
+
[/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, 'SendGrid API key'],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// The first audit of this check fired on 7 of 8 major repositories and every
|
|
57
|
+
// single hit was documentation, a test fixture or a secretlint allowlist — the
|
|
58
|
+
// exact "matched a shape instead of judging the context" failure this codebase
|
|
59
|
+
// has already made seven times. Three filters, in order of how much they caught:
|
|
60
|
+
//
|
|
61
|
+
// 1. The VALUE itself is a sample. `sk_live_abcdefghijk...` and
|
|
62
|
+
// `redis://username:password@host:port/db` are not credentials, they are
|
|
63
|
+
// the shape of one written down. A real secret is high-entropy; a fake one
|
|
64
|
+
// spells words, runs the alphabet, or literally says "password".
|
|
65
|
+
// 2. The surrounding LINE is prose or a fixture — a docs code fence, an
|
|
66
|
+
// `expect(...)`, an allowlist entry.
|
|
67
|
+
// 3. The blob is a documentation or test FILE.
|
|
68
|
+
const FAKE_VALUE = [
|
|
69
|
+
// No \b: a fixture key spells the word INSIDE the token, e.g.
|
|
70
|
+
// sk_live_51H8xExAmPlEkEyAbCdEf123. Word boundaries never match there, and
|
|
71
|
+
// that gap let this check fire on its own repository's test file.
|
|
72
|
+
/(example|sample|dummy|placeholder|fake|changeme|redacted|notreal|donotuse)/i,
|
|
73
|
+
/\b(your[_-]?key|my[_-]?secret|xxx+)\b/i,
|
|
74
|
+
/abcdefghij|0123456789|qwerty|lorem/i,
|
|
75
|
+
// A substitution of ANY form means the real value is not here: ${VAR},
|
|
76
|
+
// {{var}}, [password], <password>, %s, $VAR. Every remaining false positive
|
|
77
|
+
// in the first two audits was one of these.
|
|
78
|
+
/[$%]\{|\{\{|\[[a-z][a-z0-9_.-]*\]|<[a-z][a-z0-9_.-]*>|\$[A-Z_][A-Z0-9_]*/i,
|
|
79
|
+
// credentials that are the words for credentials
|
|
80
|
+
/:\/\/(?:user|username|admin|root|foo|bar|test|me|you)[^:@/]*:(?:pass|password|passwd|secret|hunter2|foo|bar|test)/i,
|
|
81
|
+
// A password containing "test", "demo", "local" or "dev" is a fixture. The
|
|
82
|
+
// corpus is full of mostest_password, knextest, devpassword.
|
|
83
|
+
/:\/\/[^:@/]+:[^@/]*(?:test|demo|local|dev|dummy|ci)[^@/]*@/i,
|
|
84
|
+
// A password that merely repeats the username or the service is a default,
|
|
85
|
+
// not a secret: postgres:postgres@, mysql:mysql@, redis:redis@.
|
|
86
|
+
/:\/\/([a-z0-9_-]+):\1@/i,
|
|
87
|
+
// The host is an internal service name from a docker-compose or CI network —
|
|
88
|
+
// a single label with no dot, e.g. @postgres:5432, @db, @mysql. A production
|
|
89
|
+
// database is reached at a real hostname.
|
|
90
|
+
/@(?:postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|db|database|cache|queue)(?:[-_][a-z0-9-]+)?(?::\d+)?(?:[/?]|$)/i,
|
|
91
|
+
// a placeholder host is a placeholder URL
|
|
92
|
+
/@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|example\.(?:com|org|net)|your[^\s/]*)(?:[:/]|$)/i,
|
|
93
|
+
// no dot in the host at all: not a routable production endpoint
|
|
94
|
+
/:\/\/[^@/]+@[a-z0-9_-]+(?::\d+)?(?:[/?]|$)/i,
|
|
95
|
+
// AWS's own documentation key
|
|
96
|
+
/^AKIAIOSFODNN7EXAMPLE$/,
|
|
97
|
+
];
|
|
98
|
+
// Entropy: a real key is random. `sk_live_abcdef...` is not. Measured over the
|
|
99
|
+
// secret's payload, after the recognisable prefix.
|
|
100
|
+
function looksRandom(value) {
|
|
101
|
+
const body = value.replace(/^(sk_live_|rk_live_|whsec_|sk-ant-|sk-proj-|AKIA|AIza|gh[pousr]_|glpat-|xox[baprs]-|SG\.)/, '');
|
|
102
|
+
if (body.length < 12) return true; // too short to judge; keep it
|
|
103
|
+
const set = new Set(body.replace(/[^A-Za-z0-9]/g, ''));
|
|
104
|
+
if (set.size < 8) return false; // e.g. all a-f, or one repeated run
|
|
105
|
+
// sequential alphabet or digits is the signature of a hand-typed placeholder
|
|
106
|
+
let runs = 0;
|
|
107
|
+
for (let i = 1; i < body.length; i++) if (body.charCodeAt(i) === body.charCodeAt(i - 1) + 1) runs++;
|
|
108
|
+
return runs < body.length * 0.3;
|
|
109
|
+
}
|
|
110
|
+
// The line the secret sits on. Docs, assertions and allowlists are not leaks.
|
|
111
|
+
const FAKE_LINE = /(^\s*[#*]|^\s*\/\/|\.\. |::$|`{1,3}|expect\(|assert|describe\(|it\(|test\(|allows?"?\s*:|allowlist|secretlint|gitleaks|trufflehog|\.md:|<\/?[a-z]+>)/i;
|
|
112
|
+
|
|
113
|
+
// What the secret was ASSIGNED TO, looking back from the match. A real key and
|
|
114
|
+
// a test key are byte-identical — the only difference is intent, and intent is
|
|
115
|
+
// written in the name above it. `EXAMPLE_PRIVATE_KEY = """`, `KEY1 = """` under
|
|
116
|
+
// a "Keys and certificates for tests" docstring, `TEST_CERT`, `FIXTURE_KEY`.
|
|
117
|
+
// This was the last class of false positive left in the corpus audit.
|
|
118
|
+
const FAKE_DECLARATION = /\b(example|sample|test|tests|testing|fixture|mock|stub|dummy|fake|demo|placeholder|invalid|expired|revoked|specimen)[a-z0-9_]*\s*[:=]\s*(?:"""|'''|["'`]|$)/i;
|
|
119
|
+
// The blob is documentation or a fixture. Detected from the content, since a
|
|
120
|
+
// blob does not carry its path.
|
|
121
|
+
//
|
|
122
|
+
// The strongest signal is that the blob IS a test file. A test asserting that
|
|
123
|
+
// secret-handling works necessarily contains secret-shaped strings — this
|
|
124
|
+
// scanner's own test/redact.mjs does, and that is what made SEC-009 fire on
|
|
125
|
+
// Launchprep's own repository the first time it ran. A key inside a test file
|
|
126
|
+
// is a fixture; a real one would never be committed there deliberately.
|
|
127
|
+
const FAKE_BLOB = /(^|\n)(#{1,3} |\.\. _|={3,}$|-{3,}$)|describe\(|it\(['"`]|def test_|class Test|@pytest|\bfixtures?\b/i;
|
|
128
|
+
const IS_TEST_FILE = /(^|\n)\s*(?:import|from|const|let|require)[^\n]*\b(?:assert|chai|jest|mocha|vitest|pytest|unittest|testing)\b|(^|\n)\s*(?:describe|it|test)\s*\(|(^|\n)\s*(?:def test_|class Test)|\bok\(['"`]|expect\(/;
|
|
129
|
+
|
|
130
|
+
// A blob big enough to be a build artefact or a vendored bundle is not where a
|
|
131
|
+
// human pasted a key, and inflating hundreds of them is the whole cost here.
|
|
132
|
+
const MAX_BLOB = 400_000;
|
|
133
|
+
// A ceiling on total work so an enormous repository cannot make a scan hang.
|
|
134
|
+
// Reached only on repositories with very deep history; reported honestly when
|
|
135
|
+
// it happens rather than silently truncating.
|
|
136
|
+
const MAX_OBJECTS = 20_000;
|
|
137
|
+
|
|
138
|
+
function looseObjects(objDir, budget) {
|
|
139
|
+
const out = [];
|
|
140
|
+
let buckets;
|
|
141
|
+
try { buckets = readdirSync(objDir); } catch { return out; }
|
|
142
|
+
for (const b of buckets) {
|
|
143
|
+
if (!/^[0-9a-f]{2}$/.test(b)) continue;
|
|
144
|
+
let files;
|
|
145
|
+
try { files = readdirSync(join(objDir, b)); } catch { continue; }
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
if (out.length >= budget) return out;
|
|
148
|
+
out.push({ id: b + f, path: join(objDir, b, f) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A packfile stores objects as a stream of zlib chunks with no per-object
|
|
155
|
+
// offsets available without parsing .idx. Rather than implement idx parsing and
|
|
156
|
+
// delta reconstruction — a large amount of code for a small gain — the packfile
|
|
157
|
+
// is scanned for the zlib streams it contains and each is inflated where it
|
|
158
|
+
// can be. Objects stored as deltas against another object are skipped: they are
|
|
159
|
+
// modifications of a base, and a secret introduced in one shows up in the full
|
|
160
|
+
// copy of some version anyway. Reported in `partial` so a clean result on a
|
|
161
|
+
// packed repository never silently claims more than it checked.
|
|
162
|
+
function packedBlobs(packDir, budget, onBlob) {
|
|
163
|
+
let files;
|
|
164
|
+
try { files = readdirSync(packDir); } catch { return { scanned: 0, partial: false }; }
|
|
165
|
+
let scanned = 0, partial = false;
|
|
166
|
+
for (const f of files.filter(x => x.endsWith('.pack'))) {
|
|
167
|
+
let buf;
|
|
168
|
+
try { buf = readFileSync(join(packDir, f)); } catch { continue; }
|
|
169
|
+
// header: 'PACK', version, count
|
|
170
|
+
if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'PACK') continue;
|
|
171
|
+
// Walk looking for zlib stream starts (0x78 followed by a valid check byte).
|
|
172
|
+
// Inflating from a wrong offset simply throws and is skipped, so a false
|
|
173
|
+
// start costs nothing but a try/catch.
|
|
174
|
+
for (let i = 12; i < buf.length - 2; i++) {
|
|
175
|
+
if (scanned >= budget) { partial = true; return { scanned, partial }; }
|
|
176
|
+
if (buf[i] !== 0x78) continue;
|
|
177
|
+
const b1 = buf[i + 1];
|
|
178
|
+
if (((buf[i] << 8) | b1) % 31 !== 0) continue;
|
|
179
|
+
try {
|
|
180
|
+
const inflated = inflateSync(buf.subarray(i), { finishFlush: 2 /* Z_SYNC_FLUSH */ });
|
|
181
|
+
if (inflated.length && inflated.length <= MAX_BLOB) {
|
|
182
|
+
scanned++;
|
|
183
|
+
onBlob(inflated.toString('utf8'), `${f}@${i}`);
|
|
184
|
+
}
|
|
185
|
+
} catch { /* not a stream start, or a delta we do not reconstruct */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { scanned, partial };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Returns { findings, objectsScanned, partial, reason }.
|
|
192
|
+
// `reason` is set when history could not be read at all, so the caller can say
|
|
193
|
+
// so rather than implying a clean history.
|
|
194
|
+
export function scanGitHistory(root) {
|
|
195
|
+
const gitDir = join(root, '.git');
|
|
196
|
+
if (!existsSync(gitDir)) return { findings: [], objectsScanned: 0, partial: false, reason: 'no-git' };
|
|
197
|
+
// A worktree or submodule has .git as a FILE pointing elsewhere. Not followed:
|
|
198
|
+
// the pointer can lead outside the directory the user asked us to scan, and
|
|
199
|
+
// reading outside it is not ours to do.
|
|
200
|
+
try { if (!statSync(gitDir).isDirectory()) return { findings: [], objectsScanned: 0, partial: false, reason: 'git-is-a-file' }; }
|
|
201
|
+
catch { return { findings: [], objectsScanned: 0, partial: false, reason: 'no-git' }; }
|
|
202
|
+
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
const findings = [];
|
|
205
|
+
let objectsScanned = 0;
|
|
206
|
+
|
|
207
|
+
const inspect = (text, where) => {
|
|
208
|
+
if (!text || text.length > MAX_BLOB) return;
|
|
209
|
+
// A documentation page or a test file can contain any number of
|
|
210
|
+
// key-shaped strings and none of them are leaks.
|
|
211
|
+
const docish = FAKE_BLOB.test(text.slice(0, 2000)) || IS_TEST_FILE.test(text.slice(0, 4000));
|
|
212
|
+
for (const [re, label] of SECRETS) {
|
|
213
|
+
re.lastIndex = 0;
|
|
214
|
+
let m;
|
|
215
|
+
while ((m = re.exec(text))) {
|
|
216
|
+
const value = m[0];
|
|
217
|
+
if (FAKE_VALUE.some(f => f.test(value))) continue;
|
|
218
|
+
if (!looksRandom(value)) continue;
|
|
219
|
+
// the line it sits on decides as much as the value does
|
|
220
|
+
const ls = text.lastIndexOf('\n', m.index) + 1;
|
|
221
|
+
let le = text.indexOf('\n', m.index); if (le < 0) le = text.length;
|
|
222
|
+
const line = text.slice(ls, le);
|
|
223
|
+
if (FAKE_LINE.test(line)) continue;
|
|
224
|
+
// Look back a few hundred characters for the declaration this value was
|
|
225
|
+
// assigned to, and for a test/fixture docstring above it.
|
|
226
|
+
const before = text.slice(Math.max(0, m.index - 300), m.index);
|
|
227
|
+
if (FAKE_DECLARATION.test(before)) continue;
|
|
228
|
+
if (/\b(for tests?|test (?:keys?|certs?|fixtures?|data)|do not use|not a real)\b/i.test(before)) continue;
|
|
229
|
+
if (docish) continue;
|
|
230
|
+
// One finding per distinct secret, however many old commits carry it.
|
|
231
|
+
const key = label + ':' + value.slice(0, 24);
|
|
232
|
+
if (seen.has(key)) continue;
|
|
233
|
+
seen.add(key);
|
|
234
|
+
findings.push({ label, where, hint: value.slice(0, 7) + '…' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const objDir = join(gitDir, 'objects');
|
|
240
|
+
for (const o of looseObjects(objDir, MAX_OBJECTS)) {
|
|
241
|
+
try {
|
|
242
|
+
const raw = inflateSync(readFileSync(o.path));
|
|
243
|
+
const nul = raw.indexOf(0);
|
|
244
|
+
if (nul < 0) continue;
|
|
245
|
+
if (!raw.subarray(0, nul).toString('latin1').startsWith('blob')) continue;
|
|
246
|
+
objectsScanned++;
|
|
247
|
+
inspect(raw.subarray(nul + 1).toString('utf8'), o.id.slice(0, 8));
|
|
248
|
+
} catch { /* unreadable object; skipped */ }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const packed = packedBlobs(join(objDir, 'pack'), MAX_OBJECTS - objectsScanned, inspect);
|
|
252
|
+
objectsScanned += packed.scanned;
|
|
253
|
+
|
|
254
|
+
return { findings, objectsScanned, partial: packed.partial, reason: null };
|
|
255
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { scanRepo } from './fs-scan.mjs';
|
|
|
3
3
|
import { splitWorkspaces } from './workspace.mjs';
|
|
4
4
|
import { detectProfile, toGateProfile } from './detect.mjs';
|
|
5
5
|
import { gate, missingFacts } from './gate.mjs';
|
|
6
|
-
import { runChecks, runRootChecks } from './checks.mjs';
|
|
6
|
+
import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
|
|
7
7
|
import { render } from './report.mjs';
|
|
8
8
|
import { existsSync, statSync } from 'node:fs';
|
|
9
9
|
|
|
@@ -41,11 +41,48 @@ if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
|
41
41
|
const repo = scanRepo(target, { maxFiles: 12000 });
|
|
42
42
|
const packages = splitWorkspaces(repo);
|
|
43
43
|
|
|
44
|
+
// What the user told us about their own app, which beats what we guessed.
|
|
45
|
+
//
|
|
46
|
+
// The scanner works most things out from the code, but some facts are simply
|
|
47
|
+
// not in the code — where the users live, whether the AI is allowed to act,
|
|
48
|
+
// how many people are expected. Those checks were skipped and the report said
|
|
49
|
+
// "Correct it and the checks adjust", which was false: there was no way to
|
|
50
|
+
// correct anything. This is that way.
|
|
51
|
+
//
|
|
52
|
+
// Reading a file the user wrote is still read-only. Nothing is written.
|
|
53
|
+
const stated = (() => {
|
|
54
|
+
const raw = repo.read('launchprep.json');
|
|
55
|
+
if (raw === null) return null;
|
|
56
|
+
try {
|
|
57
|
+
const j = JSON.parse(raw);
|
|
58
|
+
return (j && typeof j === 'object' && !Array.isArray(j)) ? j : null;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// A broken file must be loud. Silently ignoring it would mean the user
|
|
61
|
+
// answers the questions, sees no change, and concludes the feature is fake.
|
|
62
|
+
process.stderr.write(`\n \x1b[31mlaunchprep.json could not be read: ${e.message}\x1b[0m\n`);
|
|
63
|
+
process.stderr.write(` \x1b[2mIt must be valid JSON. Nothing from it was used.\x1b[0m\n\n`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
|
|
68
|
+
// Merge one level deep so { "stack": { "auth": "clerk" } } overrides only auth
|
|
69
|
+
// and leaves the detected framework and database alone.
|
|
70
|
+
const applyStated = (profile) => {
|
|
71
|
+
if (!stated) return profile;
|
|
72
|
+
const out = { ...profile };
|
|
73
|
+
for (const [k, v] of Object.entries(stated)) {
|
|
74
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object')
|
|
75
|
+
out[k] = { ...out[k], ...v };
|
|
76
|
+
else out[k] = v;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
|
|
44
81
|
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
45
82
|
// simply match very few rules, which is the correct outcome, not a bug.
|
|
46
83
|
const scanned = packages.map(w => {
|
|
47
84
|
const full = detectProfile(w.view, { root: repo });
|
|
48
|
-
const profile = toGateProfile(full);
|
|
85
|
+
const profile = applyStated(toGateProfile(full));
|
|
49
86
|
const g = gate(profile);
|
|
50
87
|
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
51
88
|
const findings = runChecks(w.view, profile, applicableIds)
|
|
@@ -86,17 +123,28 @@ const shallow = lead.gate.evaluated
|
|
|
86
123
|
|
|
87
124
|
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
88
125
|
|
|
126
|
+
// "51 checks apply to this app" counted 17 that nothing examined — tier-2 rules
|
|
127
|
+
// that need the deep scan. Counting them as applying is a silent pass on a
|
|
128
|
+
// third of the headline number, which is the one thing this scanner must never
|
|
129
|
+
// do. It also threw away the sentence that sells the paid tier: those 17 are
|
|
130
|
+
// not a gap, they are the product.
|
|
131
|
+
const implemented = new Set(CHECKS.map(c => c.id));
|
|
132
|
+
const coverage = {
|
|
133
|
+
ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
|
|
134
|
+
needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
|
|
135
|
+
};
|
|
136
|
+
|
|
89
137
|
if (asJson) {
|
|
90
138
|
console.log(JSON.stringify({
|
|
91
139
|
packages: scanned.map(s => ({
|
|
92
140
|
name: s.name, profile: s.profile,
|
|
93
141
|
evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
|
|
94
142
|
})),
|
|
95
|
-
findings, questions,
|
|
143
|
+
findings, questions, coverage, stated,
|
|
96
144
|
shallow: shallow.map(r => ({ id: r.id, title: r.title, severity: r.severity })),
|
|
97
145
|
}, null, 2));
|
|
98
146
|
} else {
|
|
99
|
-
process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
|
|
147
|
+
process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow, coverage, stated }));
|
|
100
148
|
}
|
|
101
149
|
|
|
102
150
|
// The gate, after the full report has printed: the human still sees
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Is this value a real credential, or something that only looks like one?
|
|
2
|
+
//
|
|
3
|
+
// This is the seventh-and-eighth instance of the mistake this codebase keeps
|
|
4
|
+
// making: matching a SHAPE and calling it a secret. Scanning its own repository
|
|
5
|
+
// produced five CRITICALs and every one was this —
|
|
6
|
+
//
|
|
7
|
+
// .github/workflows/ci.yml postgresql://postgres:postgres@localhost/..._test
|
|
8
|
+
// cli/src/git-history.mjs /^AKIAIOSFODNN7EXAMPLE$/ (a detection pattern)
|
|
9
|
+
// cli/src/checks-*.mjs the regexes that look for model output
|
|
10
|
+
//
|
|
11
|
+
// None is a credential. The first is the throwaway database every project with
|
|
12
|
+
// CI tests spins up; the second is AWS's own published example; the third is
|
|
13
|
+
// source code that describes a pattern rather than containing a secret.
|
|
14
|
+
//
|
|
15
|
+
// The discipline is the one written at the top of the project guide: read the
|
|
16
|
+
// VALUE, and the line it sits on, not the shape. A leaked key is high-entropy,
|
|
17
|
+
// points at a real host, and is not sitting inside a regex.
|
|
18
|
+
//
|
|
19
|
+
// The redaction pass in redact.mjs learned the same lesson separately. This is
|
|
20
|
+
// the shared version so a fix in one place is a fix in both.
|
|
21
|
+
|
|
22
|
+
// The value spells out that it is not real.
|
|
23
|
+
const FAKE_WORDS = /(example|sample|dummy|placeholder|fake|changeme|redacted|notreal|donotuse|yourkey|your[_-]key|xxx+|test[_-]?key|foobar)/i;
|
|
24
|
+
|
|
25
|
+
// Sequences a human types when inventing a key.
|
|
26
|
+
const TYPED_BY_HAND = /abcdefghij|0123456789|qwerty|lorem|(.)\1{7,}/i;
|
|
27
|
+
|
|
28
|
+
// A substitution means the real value is somewhere else: ${VAR}, {{var}},
|
|
29
|
+
// [password], <password>, $VAR, %s.
|
|
30
|
+
const SUBSTITUTION = /[$%]\{|\{\{|\[[a-z][a-z0-9_.-]*\]|<[a-z][a-z0-9_.-]*>|\$[A-Z_][A-Z0-9_]*/i;
|
|
31
|
+
|
|
32
|
+
// A connection string that points nowhere real.
|
|
33
|
+
const LOCAL_OR_SERVICE_HOST =
|
|
34
|
+
/@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|example\.(?:com|org|net)|postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|db|database)(?:[-_][a-z0-9-]+)?(?::\d+)?(?:[/?]|$)/i;
|
|
35
|
+
// user and password are the same word, or are the words for themselves.
|
|
36
|
+
const DEFAULT_CREDENTIALS =
|
|
37
|
+
/:\/\/([a-z0-9_-]+):\1@|:\/\/(?:user|username|admin|root|foo|bar|test|me)[^:@/]*:(?:pass|password|passwd|secret|hunter2|foo|bar|test)/i;
|
|
38
|
+
// a password that announces it is for testing
|
|
39
|
+
const TEST_CREDENTIALS = /:\/\/[^:@/]+:[^@/]*(?:test|demo|local|dev|dummy|ci|example)[^@/]*@/i;
|
|
40
|
+
|
|
41
|
+
// AWS publishes these two in its own documentation. They are in a thousand
|
|
42
|
+
// READMEs and are not anyone's key.
|
|
43
|
+
const AWS_DOC_KEYS = /^(AKIAIOSFODNN7EXAMPLE|ASIAIOSFODNN7EXAMPLE)$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param value the matched string
|
|
47
|
+
* @param line the whole line it appeared on (optional, but much better with it)
|
|
48
|
+
* @param path the file path (optional)
|
|
49
|
+
*/
|
|
50
|
+
export function looksLikePlaceholder(value, line = '', path = '') {
|
|
51
|
+
const v = String(value);
|
|
52
|
+
|
|
53
|
+
if (FAKE_WORDS.test(v) || TYPED_BY_HAND.test(v) || SUBSTITUTION.test(v)) return true;
|
|
54
|
+
if (AWS_DOC_KEYS.test(v)) return true;
|
|
55
|
+
if (LOCAL_OR_SERVICE_HOST.test(v) || DEFAULT_CREDENTIALS.test(v) || TEST_CREDENTIALS.test(v)) return true;
|
|
56
|
+
|
|
57
|
+
const l = String(line);
|
|
58
|
+
// The line DEFINES a pattern rather than holding a secret: a regex literal,
|
|
59
|
+
// a list of shapes to look for. This is what made the scanner flag itself.
|
|
60
|
+
if (/\/\^|\^.*\$\/|\\b|\\d|\\w|\[A-Za-z0-9|\[0-9A-Z|RegExp\(|\.test\(|\.match\(/.test(l)) return true;
|
|
61
|
+
// A comment, or documentation showing the format.
|
|
62
|
+
if (/^\s*(?:[#*]|\/\/|--)/.test(l)) return true;
|
|
63
|
+
// An assertion or fixture.
|
|
64
|
+
if (/expect\(|assert|describe\(|it\(['"`]|toBe\(|toEqual\(/.test(l)) return true;
|
|
65
|
+
|
|
66
|
+
const p = String(path);
|
|
67
|
+
// A test file or a fixture directory is not where real credentials live.
|
|
68
|
+
if (/(^|\/)(test|tests|__tests__|spec|e2e|cypress|fixtures?|mocks?|__mocks__|examples?)\//.test(p)) return true;
|
|
69
|
+
if (/\.(test|spec)\.[a-z]+$/.test(p)) return true;
|
|
70
|
+
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The questions the scanner asks when it cannot tell something from the code,
|
|
2
|
+
// written for the person who reads the report.
|
|
3
|
+
//
|
|
4
|
+
// The scanner can gate on 33 facts. Five of them had wording; the other 28
|
|
5
|
+
// printed their internal name — a customer saw "? llm_tools_enabled" and
|
|
6
|
+
// "? has_webhooks_in" as the last thing on screen. The stated audience is
|
|
7
|
+
// people who cannot read code, and those are not questions, they are variable
|
|
8
|
+
// names a programmer wrote for themselves.
|
|
9
|
+
//
|
|
10
|
+
// Each entry carries:
|
|
11
|
+
// ask the question, in words someone non-technical can answer
|
|
12
|
+
// answer what to write in launchprep.json, shown so the answer is obvious
|
|
13
|
+
// why what it unlocks, so the question does not feel like paperwork
|
|
14
|
+
//
|
|
15
|
+
// The rule for the wording is the same one the findings follow: name the
|
|
16
|
+
// consequence, not the mechanism. "Can people sign in?" not "has_accounts".
|
|
17
|
+
export const QUESTION = {
|
|
18
|
+
// --- who and where -------------------------------------------------------
|
|
19
|
+
jurisdictions: {
|
|
20
|
+
ask: 'Where do your users live?',
|
|
21
|
+
answer: '"jurisdictions": ["eu"] — any of: eu, uk, us, ca, ru, kz, other',
|
|
22
|
+
why: 'decides which privacy laws reach you at all',
|
|
23
|
+
},
|
|
24
|
+
business_model: {
|
|
25
|
+
ask: 'Who is this for — businesses, consumers, or a marketplace?',
|
|
26
|
+
answer: '"business_model": "b2b-saas" — b2b-saas, b2c, marketplace, internal',
|
|
27
|
+
why: 'a marketplace holds other people’s money and has different duties',
|
|
28
|
+
},
|
|
29
|
+
expected_scale: {
|
|
30
|
+
ask: 'Roughly how many users do you expect in the first year?',
|
|
31
|
+
answer: '"expected_scale": "under-100k" — under-1k, under-100k, over-100k',
|
|
32
|
+
why: 'what is safe at 100 users falls over at 100,000',
|
|
33
|
+
},
|
|
34
|
+
audience_locale: {
|
|
35
|
+
ask: 'Do you serve more than one language or region?',
|
|
36
|
+
answer: '"audience_locale": "multi" — single, multi',
|
|
37
|
+
why: 'dates, times and text break first when you cross a border',
|
|
38
|
+
},
|
|
39
|
+
serves_currency: {
|
|
40
|
+
ask: 'Do you charge in more than one currency?',
|
|
41
|
+
answer: '"serves_currency": "multi" — single, multi',
|
|
42
|
+
why: 'rounding and exchange rates are where money quietly goes missing',
|
|
43
|
+
},
|
|
44
|
+
data_sensitivity: {
|
|
45
|
+
ask: 'Do you hold anything especially sensitive — money, health, or data about children?',
|
|
46
|
+
answer: '"data_sensitivity": "financial" — normal, financial, health, children',
|
|
47
|
+
why: 'these carry duties ordinary personal data does not',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// --- what the app is -----------------------------------------------------
|
|
51
|
+
surface: {
|
|
52
|
+
ask: 'What is it — a website, a web app, an API, or a mobile app?',
|
|
53
|
+
answer: '"surface": "web-app" — web-site, web-app, api-only, mobile-ios, mobile-android, cli, library',
|
|
54
|
+
why: 'a static site cannot have most of these problems, and is not asked about them',
|
|
55
|
+
},
|
|
56
|
+
stage: {
|
|
57
|
+
ask: 'Is this live, about to launch, or still a prototype?',
|
|
58
|
+
answer: '"stage": "pre-launch" — prototype, pre-launch, production',
|
|
59
|
+
why: 'a prototype is not asked about backups; something about to launch is',
|
|
60
|
+
},
|
|
61
|
+
is_public: {
|
|
62
|
+
ask: 'Can anyone on the internet reach it, or is it internal only?',
|
|
63
|
+
answer: '"is_public": true',
|
|
64
|
+
why: 'an internal tool has a very different attack surface',
|
|
65
|
+
},
|
|
66
|
+
tenancy: {
|
|
67
|
+
ask: 'Do separate companies or teams share one database?',
|
|
68
|
+
answer: '"tenancy": "multi-tenant-shared-db" — single-tenant, multi-tenant-shared-db, multi-tenant-isolated',
|
|
69
|
+
why: 'this is the setting behind "one customer can read another customer’s data"',
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// --- accounts and access -------------------------------------------------
|
|
73
|
+
has_accounts: {
|
|
74
|
+
ask: 'Can people sign in?',
|
|
75
|
+
answer: '"has_accounts": true',
|
|
76
|
+
why: 'unlocks every check about logins, sessions and passwords',
|
|
77
|
+
},
|
|
78
|
+
has_roles: {
|
|
79
|
+
ask: 'Do some users have more power than others — admin, editor, viewer?',
|
|
80
|
+
answer: '"has_roles": true',
|
|
81
|
+
why: 'unlocks the checks about someone giving themselves permissions',
|
|
82
|
+
},
|
|
83
|
+
has_admin_panel: {
|
|
84
|
+
ask: 'Is there an admin area only you and your staff can reach?',
|
|
85
|
+
answer: '"has_admin_panel": true',
|
|
86
|
+
why: 'an admin page left open is the shortest path to everything',
|
|
87
|
+
},
|
|
88
|
+
has_invitations: {
|
|
89
|
+
ask: 'Can users invite other people to join them?',
|
|
90
|
+
answer: '"has_invitations": true',
|
|
91
|
+
why: 'invite links are commonly guessable or never expire',
|
|
92
|
+
},
|
|
93
|
+
stack_auth: {
|
|
94
|
+
ask: 'How do people sign in — your own code, or a service?',
|
|
95
|
+
answer: '"stack": { "auth": "supabase-auth" } — custom, supabase-auth, clerk, auth0, nextauth, devise',
|
|
96
|
+
why: 'hand-written login code is checked far more strictly',
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// --- money ---------------------------------------------------------------
|
|
100
|
+
handles_payments: {
|
|
101
|
+
ask: 'Do you take payments, and through whom?',
|
|
102
|
+
answer: '"handles_payments": "stripe" — none, stripe, paddle, lemonsqueezy, other',
|
|
103
|
+
why: 'unlocks the checks about webhooks, refunds and money stored as decimals',
|
|
104
|
+
},
|
|
105
|
+
has_subscriptions: {
|
|
106
|
+
ask: 'Do people pay you repeatedly — a subscription?',
|
|
107
|
+
answer: '"has_subscriptions": true',
|
|
108
|
+
why: 'failed renewals and cancellations are where subscription apps leak money',
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// --- AI ------------------------------------------------------------------
|
|
112
|
+
calls_llm: {
|
|
113
|
+
ask: 'Does your app call an AI model?',
|
|
114
|
+
answer: '"calls_llm": true',
|
|
115
|
+
why: 'unlocks the AI checks — runaway costs, prompt injection, model output',
|
|
116
|
+
},
|
|
117
|
+
llm_input_from_user: {
|
|
118
|
+
ask: 'Does anything a user types get sent to the AI?',
|
|
119
|
+
answer: '"llm_input_from_user": true',
|
|
120
|
+
why: 'this is the difference between a prompt you wrote and one a stranger did',
|
|
121
|
+
},
|
|
122
|
+
llm_tools_enabled: {
|
|
123
|
+
ask: 'Can the AI do things on its own — send email, call an API, change data?',
|
|
124
|
+
answer: '"llm_tools_enabled": true',
|
|
125
|
+
why: 'an AI that can only talk is a very different risk from one that can act',
|
|
126
|
+
},
|
|
127
|
+
has_rag: {
|
|
128
|
+
ask: 'Does the AI read from your own documents or database to answer?',
|
|
129
|
+
answer: '"has_rag": true',
|
|
130
|
+
why: 'documents the AI reads can carry instructions that hijack it',
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// --- how it runs ---------------------------------------------------------
|
|
134
|
+
sends_email: {
|
|
135
|
+
ask: 'Does your app send email to users?',
|
|
136
|
+
answer: '"sends_email": true',
|
|
137
|
+
why: 'unlocks deliverability, unsubscribe duties and password-reset checks',
|
|
138
|
+
},
|
|
139
|
+
has_file_uploads: {
|
|
140
|
+
ask: 'Can users upload files or images?',
|
|
141
|
+
answer: '"has_file_uploads": true',
|
|
142
|
+
why: 'uploads are how a stranger gets a file onto your server',
|
|
143
|
+
},
|
|
144
|
+
has_background_jobs: {
|
|
145
|
+
ask: 'Does work happen in the background — queues, scheduled tasks?',
|
|
146
|
+
answer: '"has_background_jobs": true',
|
|
147
|
+
why: 'a job that fails silently is a job nobody knows failed',
|
|
148
|
+
},
|
|
149
|
+
has_realtime: {
|
|
150
|
+
ask: 'Is there anything live — chat, notifications, a shared cursor?',
|
|
151
|
+
answer: '"has_realtime": true',
|
|
152
|
+
why: 'live connections are authorised differently from ordinary pages',
|
|
153
|
+
},
|
|
154
|
+
has_webhooks_in: {
|
|
155
|
+
ask: 'Do other services send data INTO your app automatically?',
|
|
156
|
+
answer: '"has_webhooks_in": true',
|
|
157
|
+
why: 'an unverified webhook is an open door anyone can post through',
|
|
158
|
+
},
|
|
159
|
+
collects_analytics: {
|
|
160
|
+
ask: 'Do you track how people use the app?',
|
|
161
|
+
answer: '"collects_analytics": true',
|
|
162
|
+
why: 'tracking without consent is the most common privacy fine',
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// --- the plumbing --------------------------------------------------------
|
|
166
|
+
stack_database: {
|
|
167
|
+
ask: 'What stores your data?',
|
|
168
|
+
answer: '"stack": { "database": "postgres" } — postgres, supabase, mysql, planetscale, mongodb, sqlite, none',
|
|
169
|
+
why: 'each has its own way of leaking one customer’s rows to another',
|
|
170
|
+
},
|
|
171
|
+
stack_framework: {
|
|
172
|
+
ask: 'What is it built with?',
|
|
173
|
+
answer: '"stack": { "framework": "next" } — next, react, vue, django, rails, express, laravel, other',
|
|
174
|
+
why: 'unlocks the checks specific to that framework’s own footguns',
|
|
175
|
+
},
|
|
176
|
+
stack_host: {
|
|
177
|
+
ask: 'Where does it run?',
|
|
178
|
+
answer: '"stack": { "host": "vercel" } — vercel, netlify, cloudflare, railway, fly, aws, gcp, vps, other',
|
|
179
|
+
why: 'a serverless host and a rented server fail in opposite ways',
|
|
180
|
+
},
|
|
181
|
+
stack_orm: {
|
|
182
|
+
ask: 'How does your code talk to the database — a library, or hand-written SQL?',
|
|
183
|
+
answer: '"stack": { "orm": "prisma" } — prisma, drizzle, typeorm, sequelize, activerecord, raw-sql, none',
|
|
184
|
+
why: 'hand-written SQL is checked much harder for injection',
|
|
185
|
+
},
|
|
186
|
+
has_migrations: {
|
|
187
|
+
ask: 'Do you have database migrations — versioned schema changes?',
|
|
188
|
+
answer: '"has_migrations": true',
|
|
189
|
+
why: 'a migration that cannot be undone is a deploy that cannot be undone',
|
|
190
|
+
},
|
|
191
|
+
has_ci: {
|
|
192
|
+
ask: 'Does anything run your tests automatically when you push?',
|
|
193
|
+
answer: '"has_ci": true',
|
|
194
|
+
why: 'unlocks the checks about what your pipeline does and does not catch',
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// gate.mjs uses dotted names (stack.auth); JSON and this file use underscores
|
|
199
|
+
// for the top level. One map, so neither side has to know about the other.
|
|
200
|
+
const DOTTED = { stack_auth: 'stack.auth', stack_database: 'stack.database',
|
|
201
|
+
stack_framework: 'stack.framework', stack_host: 'stack.host',
|
|
202
|
+
stack_orm: 'stack.orm' };
|
|
203
|
+
const UNDERSCORED = Object.fromEntries(Object.entries(DOTTED).map(([k, v]) => [v, k]));
|
|
204
|
+
|
|
205
|
+
export const questionFor = (fact) => QUESTION[UNDERSCORED[fact] || fact] || null;
|
|
206
|
+
export const factNames = () => Object.keys(QUESTION).map(k => DOTTED[k] || k);
|
package/src/report.mjs
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { BRAND } from './brand.mjs';
|
|
2
|
+
import { questionFor } from './questions.mjs';
|
|
2
3
|
|
|
3
4
|
const C = { red:'\x1b[31m', yel:'\x1b[33m', blu:'\x1b[34m', gry:'\x1b[90m',
|
|
4
5
|
bold:'\x1b[1m', dim:'\x1b[2m', grn:'\x1b[32m', cyn:'\x1b[36m', off:'\x1b[0m' };
|
|
5
6
|
const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'MEDIUM'], low:[C.gry,'LOW'] };
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
jurisdictions: 'Where are your users? (EU / UK / US / other — this decides which privacy rules apply)',
|
|
10
|
-
expected_scale: 'How many users do you expect — under 1k, under 100k, or more?',
|
|
11
|
-
serves_currency: 'Do you charge in more than one currency?',
|
|
12
|
-
audience_locale: 'Do you serve more than one language or region?',
|
|
13
|
-
};
|
|
8
|
+
// The wording lives in questions.mjs — 33 facts, not the 5 that had text.
|
|
9
|
+
// A customer used to read "? llm_tools_enabled" as the last line of a scan.
|
|
14
10
|
|
|
15
|
-
export function render({ repo, scanned, lead, findings, questions, shallow = [] }) {
|
|
11
|
+
export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null }) {
|
|
16
12
|
const L = [];
|
|
17
13
|
const p = (s = '') => L.push(s);
|
|
18
14
|
const pr = lead.profile;
|
|
@@ -28,6 +24,21 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
|
|
|
28
24
|
row('Type', pr.surface, ev('surface'));
|
|
29
25
|
row('Stack', [pr.stack.framework, pr.stack.database, pr.stack.host].filter(Boolean).join(' · ') || '—');
|
|
30
26
|
row('Accounts', pr.has_accounts ? 'yes' : 'no', ev('has_accounts'));
|
|
27
|
+
|
|
28
|
+
// What the user stated beats what we detected, so it must be VISIBLE. A wrong
|
|
29
|
+
// answer silently changing which checks run would be worse than no answers
|
|
30
|
+
// at all — they would never find out why something was skipped.
|
|
31
|
+
if (stated && Object.keys(stated).length) {
|
|
32
|
+
const flat = [];
|
|
33
|
+
for (const [k, v] of Object.entries(stated)) {
|
|
34
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
35
|
+
for (const [k2, v2] of Object.entries(v)) flat.push(`${k}.${k2}=${v2}`);
|
|
36
|
+
else flat.push(`${k}=${Array.isArray(v) ? v.join('/') : v}`);
|
|
37
|
+
}
|
|
38
|
+
p(` ${C.gry}${'You said'.padEnd(10)}${C.off}${flat.slice(0, 6).join(' ')}` +
|
|
39
|
+
`${flat.length > 6 ? ` ${C.dim}+${flat.length - 6} more${C.off}` : ''}` +
|
|
40
|
+
` ${C.dim}(from launchprep.json)${C.off}`);
|
|
41
|
+
}
|
|
31
42
|
if (pr.tenancy !== 'none') row('Tenancy', pr.tenancy, ev('tenancy'));
|
|
32
43
|
if (pr.calls_llm) row('AI', (pr.llm_providers || []).join(', ') || 'yes', ev('calls_llm'));
|
|
33
44
|
if (pr.handles_payments !== 'none') row('Payments', pr.handles_payments);
|
|
@@ -78,7 +89,16 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
|
|
|
78
89
|
// ---------- what we did not check, and why ----------
|
|
79
90
|
const g = lead.gate;
|
|
80
91
|
p(`\n${C.bold}Coverage${C.off}`);
|
|
81
|
-
|
|
92
|
+
// Say what RAN, not what applies. The difference is the tier-2 rules, which
|
|
93
|
+
// need a model to read the code — counting those as "applied" reported a
|
|
94
|
+
// pass on checks nothing performed.
|
|
95
|
+
if (coverage) {
|
|
96
|
+
p(` ${C.bold}${coverage.ran}${C.off} of ${g.total} checks ran here`);
|
|
97
|
+
if (coverage.needsDeep)
|
|
98
|
+
p(` ${C.bold}${coverage.needsDeep}${C.off} more apply to you but need a deep scan ${C.dim}(they need a model to read the code)${C.off}`);
|
|
99
|
+
} else {
|
|
100
|
+
p(` ${C.bold}${g.evaluated.length}${C.off} of ${g.total} checks apply to this app`);
|
|
101
|
+
}
|
|
82
102
|
p(` ${C.dim}${g.skipped.length} skipped — they don't fit what you built${C.off}`);
|
|
83
103
|
|
|
84
104
|
const bySkipFact = {};
|
|
@@ -92,10 +112,19 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
|
|
|
92
112
|
// ---------- the three questions ----------
|
|
93
113
|
if (questions.length) {
|
|
94
114
|
p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
|
|
95
|
-
for (const [factName, n] of questions)
|
|
96
|
-
|
|
115
|
+
for (const [factName, n] of questions) {
|
|
116
|
+
const q = questionFor(factName);
|
|
117
|
+
p(` ${C.cyn}?${C.off} ${q ? q.ask : factName} ${C.dim}(unlocks ${n})${C.off}`);
|
|
118
|
+
if (q) p(` ${C.dim}${q.answer}${C.off}`);
|
|
119
|
+
}
|
|
97
120
|
}
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
// This used to say "Correct it and the checks adjust" with no way on earth to
|
|
123
|
+
// do so — no flag, no file, no prompt. It was the last line of every scan and
|
|
124
|
+
// it invited an action that did not exist. Now it names the file that works.
|
|
125
|
+
if (questions.length)
|
|
126
|
+
p(`\n ${C.dim}Put those in ${C.off}launchprep.json${C.dim} at the top of your project and run again — the answers unlock the checks above.${C.off}\n`);
|
|
127
|
+
else
|
|
128
|
+
p(`\n ${C.dim}Wrong about your app? Correct it in ${C.off}launchprep.json${C.dim} and run again.${C.off}\n`);
|
|
100
129
|
return L.join('\n');
|
|
101
130
|
}
|
package/src/rules.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.1","count":289,"rules":[{"id":"SEC-001","title":".env committed to version control","severity":"critical","requires":[],"tier":1,"check":"git ls-files matches ^\\.env(\\.|$) and not .env.example","fix":"git rm --cached .env; add to .gitignore; ROTATE every key in it","note":"rotation is the part everyone skips. history retains the file.","category":"secrets"},{"id":"SEC-002","title":".env absent from .gitignore","severity":"high","requires":[],"tier":1,"check":".gitignore lacks a pattern matching .env","category":"secrets"},{"id":"SEC-003","title":"Hardcoded API key / token / password in source","severity":"critical","requires":[],"tier":1,"check":"entropy + provider prefix scan (sk-, sk-ant-, AKIA, ghp_, xoxb-, eyJ...) outside .env","category":"secrets"},{"id":"SEC-004","title":"Server-only secret exposed to the client bundle","severity":"critical","requires":["surface in [web-app, web-site]"],"tier":1,"check":"NEXT_PUBLIC_/VITE_/REACT_APP_ prefix on a name matching (KEY|SECRET|TOKEN|PASSWORD), or a secret literal reachable from a client component","note":"the single most common vibe-coder leak. the prefix makes it PUBLIC, and the name reads like it is protected.","category":"secrets"},{"id":"SEC-005","title":"Supabase service_role key used outside a server context","severity":"critical","requires":["stack.database == supabase"],"tier":1,"check":"service_role key referenced in a client component, edge config, or NEXT_PUBLIC_ var","note":"service_role bypasses RLS entirely. in the browser it is a full database handover.","category":"secrets"},{"id":"SEC-006","title":"Paid-API key called directly from the browser","severity":"critical","requires":["calls_llm == true"],"tier":1,"check":"LLM SDK instantiated in client-side code, or provider endpoint called from browser","fix":"proxy through your own server route; never ship the provider key","category":"secrets"},{"id":"SEC-007","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"SEC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"AUTH-001","title":"Session token stored in localStorage","severity":"high","requires":["has_accounts == true","surface in [web-app, web-site]"],"tier":1,"check":"localStorage.setItem / getItem with a token-like key","fix":"httpOnly + Secure + SameSite cookie","note":"localStorage is readable by any XSS. a cookie with httpOnly is not.","category":"authentication"},{"id":"AUTH-002","title":"Role or permission read from client-controlled storage","severity":"critical","requires":["has_accounts == true"],"tier":1,"check":"role/isAdmin/plan/tier read from localStorage, sessionStorage, cookie, or JWT claim without server-side re-verification","note":"the corpus demo is literally 'open Application storage, change Member to Admin, refresh'.","category":"authentication"},{"id":"AUTH-003","title":"No brute-force limit on login","severity":"high","requires":["has_accounts == true"],"tier":1,"check":"login route has no attempt counter / lockout / rate limit","fix":"max 5 attempts per identifier per 15 min, plus IP-level cap","category":"authentication"},{"id":"AUTH-004","title":"Password reset endpoint unthrottled","severity":"high","requires":["has_accounts == true","sends_email == true"],"tier":1,"check":"password-reset route lacks rate limiting","note":"corpus example — 67,000 requests at 2am against one reset endpoint.","category":"authentication"},{"id":"AUTH-005","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AUTH-006","tier":2,"severity":"medium","requires":["has_admin_panel == true","stage == production"]},{"id":"AUTH-007","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"AUTH-008","title":"Password stored without a modern KDF","severity":"critical","requires":["stack.auth == custom"],"tier":1,"check":"password hashing absent, or md5/sha1/sha256 without bcrypt/argon2/scrypt","category":"authentication"},{"id":"AUTH-009","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production","is_public == true"]},{"id":"AUTH-010","tier":2,"severity":"high","requires":["stack.host in [vps, aws, gcp]"]},{"id":"AUTHZ-001","title":"Supabase table with RLS disabled","severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"tier":1,"check":"table in migrations without ENABLE ROW LEVEL SECURITY","note":"corpus calls this the single most common vulnerability found across startups AND enterprises. RLS is OFF by default for raw-SQL tables.","category":"authorization"},{"id":"AUTHZ-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"has_static_approximation":true,"title":"RLS enabled but policy is permissive"},{"id":"AUTHZ-003","tier":2,"severity":"critical","requires":["has_accounts == true"],"has_static_approximation":true,"title":"IDOR — object accessed by ID without an ownership check"},{"id":"AUTHZ-004","tier":2,"severity":"critical","requires":["has_admin_panel == true"],"has_static_approximation":true,"title":"Admin authorization enforced only in the UI"},{"id":"AUTHZ-005","tier":2,"severity":"critical","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"Usage limits stored in a row the user can modify"},{"id":"AUTHZ-006","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"],"has_static_approximation":true,"title":"Missing tenant scope on a domain query"},{"id":"AUTHZ-007","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"AUTHZ-008","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RATE-001","title":"No rate limiting on any endpoint","severity":"high","requires":["is_public == true"],"tier":1,"check":"no rate-limit middleware, no gateway policy, no per-route limiter","category":"rate-limiting"},{"id":"RATE-002","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"RATE-003","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"No spend ceiling on paid-API endpoints"},{"id":"RATE-004","tier":2,"severity":"high","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"No per-user quota on AI features"},{"id":"RATE-005","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RATE-006","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"RATE-007","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AI-001","tier":2,"severity":"critical","requires":["calls_llm == true","llm_input_from_user == true"],"has_static_approximation":true,"title":"User input reaches the system-prompt position"},{"id":"AI-002","tier":2,"severity":"high","requires":["calls_llm == true","llm_input_from_user == true"]},{"id":"AI-003","title":"No max_tokens ceiling on completions","severity":"high","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"provider call omits max_tokens, or sets it above the plan's economics","note":"unbounded output is unbounded cost, per request.","category":"ai-safety"},{"id":"AI-004","title":"No model allowlist","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"model id derived from request input, or no constant/allowlist","note":"a client-chosen model is a client-chosen price.","category":"ai-safety"},{"id":"AI-005","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AI-006","tier":2,"severity":"critical","requires":["calls_llm == true","llm_tools_enabled == true"]},{"id":"AI-007","title":"Model output rendered as HTML without sanitisation","severity":"critical","requires":["calls_llm == true","surface in [web-app, web-site]"],"tier":1,"origin":"gap","check":"completion piped to dangerouslySetInnerHTML / v-html / innerHTML","note":"injection in, XSS out.","category":"ai-safety"},{"id":"AI-008","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"Model output used in a privileged operation without validation"},{"id":"AI-009","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AI-010","tier":2,"severity":"high","requires":["calls_llm == true","data_sensitivity != none"]},{"id":"AI-011","title":"No retry/backoff on provider 429s","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","category":"ai-safety"},{"id":"AI-012","tier":2,"severity":"high","requires":["calls_llm == true","stack.host in [vercel, netlify]"]},{"id":"AI-013","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2b-saas, b2c, marketplace]"]},{"id":"PAY-001","title":"Webhook signature not verified","severity":"critical","requires":["handles_payments != none","has_webhooks_in == true"],"tier":1,"check":"webhook route parses the body without constructEvent / signature check","note":"an unverified webhook endpoint is an unauthenticated 'mark this order paid' API.","category":"payments"},{"id":"PAY-002","tier":2,"severity":"high","requires":["handles_payments in [stripe, paddle, lemonsqueezy]"]},{"id":"PAY-003","tier":2,"severity":"high","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-004","tier":2,"severity":"medium","requires":["handles_payments != none"]},{"id":"PAY-005","tier":2,"severity":"high","requires":["has_subscriptions == true"]},{"id":"PAY-006","title":"Price or amount accepted from the client","severity":"critical","requires":["handles_payments != none"],"tier":1,"check":"amount/price/currency read from the request body when creating a charge","category":"payments"},{"id":"PAY-007","tier":2,"severity":"medium","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-008","tier":2,"severity":"high","requires":["surface in [mobile-ios]","has_subscriptions == true"]},{"id":"DATA-001","title":"Unbounded list query — no pagination","severity":"high","requires":["stack.database != none"],"tier":1,"check":"list endpoint without limit/offset or cursor","note":"corpus — fine at 50 users, loads 50,000 rows at 500.","category":"data-and-scale"},{"id":"DATA-002","tier":2,"severity":"high","requires":["stack.database != none"]},{"id":"DATA-003","title":"Missing index on a filtered or joined column","severity":"high","requires":["stack.database in [postgres, supabase, mysql, planetscale]"],"tier":1,"check":"WHERE / JOIN / ORDER BY column with no index in migrations","note":"50ms at 100 rows, 30s at 100k. the code never changed.","category":"data-and-scale"},{"id":"DATA-004","title":"No connection pooling","severity":"high","requires":["stack.database in [postgres, supabase, mysql]","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"serverless multiplies connections per invocation; the DB runs out first.","category":"data-and-scale"},{"id":"DATA-005","title":"SELECT * on wide or hot tables","severity":"medium","requires":["stack.database != none"],"tier":1,"category":"data-and-scale"},{"id":"DATA-006","tier":2,"severity":"critical","requires":[]},{"id":"DATA-007","title":"Raw SQL built by string concatenation","severity":"critical","requires":["stack.orm == raw-sql"],"tier":1,"check":"query string interpolating a request value","category":"data-and-scale"},{"id":"DATA-008","title":"No payload size limit","severity":"medium","requires":["is_public == true"],"tier":1,"check":"body parser without a size cap","category":"data-and-scale"},{"id":"DATA-009","title":"Text columns not 4-byte-safe","severity":"medium","requires":["stack.database in [mysql, planetscale]"],"tier":1,"check":"utf8 rather than utf8mb4 on user-facing text columns","note":"corpus failure — a user typed an emoji and the app crashed. 3-byte utf8 cannot hold a 4-byte codepoint.","category":"data-and-scale"},{"id":"DATA-010","tier":2,"severity":"medium","requires":["stage == pre-launch","expected_scale != hobby"]},{"id":"DATA-011","tier":2,"severity":"medium","requires":["sends_email == true or has_file_uploads == true"]},{"id":"DATA-012","tier":2,"severity":"critical","requires":["stage == production","stack.database != none"]},{"id":"DATA-013","tier":2,"severity":"critical","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"DATA-014","title":"User uploads stored on ephemeral disk","severity":"critical","requires":["has_file_uploads == true","stack.host in [vercel, netlify, railway, fly, render]"],"tier":1,"check":"writes to local fs on a platform with an ephemeral filesystem","note":"matches CRMini — files vanish on every redeploy.","origin":"gap","category":"data-and-scale"},{"id":"DATA-015","title":"User input concatenated into a SQL query","severity":"critical","tier":1,"check":"a request value interpolated into a SQL string instead of passed as a parameter","note":"the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.","origin":"gap","category":"data-and-scale"},{"id":"INF-001","tier":2,"severity":"medium","requires":["stage == production","expected_scale in [under-100k, over-100k]","stack.host in [vps, aws, gcp]"]},{"id":"INF-002","title":"No health check or auto-restart","severity":"medium","requires":["stage == production","stack.host in [vps, aws, gcp]"],"tier":1,"category":"infrastructure"},{"id":"INF-003","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-004","tier":2,"severity":"low","requires":["stage == production","expected_scale != hobby"]},{"id":"INF-005","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-006","title":"HTTPS not enforced","severity":"high","requires":["is_public == true"],"tier":1,"check":"no HSTS, no http->https redirect","category":"infrastructure"},{"id":"INF-007","title":"Permissive CORS","severity":"high","requires":["surface in [web-app, api-only]"],"tier":1,"check":"Access-Control-Allow-Origin: * on credentialed routes","origin":"gap","category":"infrastructure"},{"id":"INF-008","tier":2,"severity":"critical","requires":["stack.database != none","stack.host in [vps, aws, gcp]"]},{"id":"INF-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"INF-010","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"INF-011","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, marketplace]"]},{"id":"LEG-001","title":"No privacy policy while collecting personal data","severity":"high","requires":["data_sensitivity != none","is_public == true"],"tier":1,"note":"corpus is emphatic — a single contact form collecting an email is already personal data.","category":"legal"},{"id":"LEG-002","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LEG-003","title":"No terms of service","severity":"medium","requires":["is_public == true","has_accounts == true"],"tier":1,"category":"legal"},{"id":"LEG-004","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk, ca]"]},{"id":"LEG-005","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, ru, kz]"]},{"id":"LEG-006","title":"Accessibility baseline unmet","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"missing alt text, insufficient contrast, no heading hierarchy, unlabelled controls","note":"corpus flags active litigation risk in US and AU.","category":"legal"},{"id":"LEG-007","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]","business_model != hobby"]},{"id":"LEG-008","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"LEG-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [ru, kz, other]"]},{"id":"LEG-010","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"OBS-001","title":"No error monitoring","severity":"high","requires":["stage == production"],"tier":1,"origin":"gap","category":"observability"},{"id":"OBS-002","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"OBS-003","title":"No product analytics","severity":"low","requires":["stage in [pre-launch, production]","business_model != internal-tool"],"tier":1,"category":"observability"},{"id":"OBS-004","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"UX-001","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-002","tier":2,"severity":"high","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-003","tier":2,"severity":"low","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-004","tier":2,"severity":"low","requires":["stage in [pre-launch, production]","business_model in [b2c, b2b-saas]"]},{"id":"UX-005","title":"No social preview image","severity":"low","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"category":"ux-readiness"},{"id":"UX-006","title":"No sitemap.xml / not indexable","severity":"low","requires":["is_public == true","surface == web-site"],"tier":1,"category":"ux-readiness"},{"id":"AIOP-001","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-002","title":"Model id pinned to an alias that can silently change","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","note":"floating aliases shift behaviour and price under you with no deploy.","category":"ai-operations"},{"id":"AIOP-003","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-004","tier":2,"severity":"low","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-005","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-006","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-007","tier":2,"severity":"critical","requires":["has_rag == true"]},{"id":"AIOP-008","tier":2,"severity":"critical","requires":["has_rag == true","tenancy == multi-tenant-shared-db"]},{"id":"AIOP-009","tier":2,"severity":"medium","requires":["has_rag == true"]},{"id":"AIOP-010","title":"Agent loop has no iteration ceiling","severity":"critical","requires":["llm_tools_enabled == true"],"tier":1,"origin":"gap","note":"an unbounded loop is an unbounded invoice.","category":"ai-operations"},{"id":"AIOP-011","tier":2,"severity":"high","requires":["llm_tools_enabled == true"]},{"id":"AIOP-012","tier":2,"severity":"critical","requires":["llm_tools_enabled == true"]},{"id":"AIOP-013","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-014","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-015","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2c, b2b-saas]"]},{"id":"AIOP-016","title":"User-facing AI errors leak provider internals","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"AIOP-017","tier":2,"severity":"medium","requires":["calls_llm == true","is_public == true","business_model == b2c"]},{"id":"AIOP-018","tier":2,"severity":"critical","requires":["calls_llm == true","is_public == true"],"has_static_approximation":true,"title":"Free tier allows unauthenticated AI calls"},{"id":"AIOP-019","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AIOP-020","title":"Temperature or sampling unset for a deterministic task","severity":"low","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"DEP-001","title":"Secrets injected at build time instead of runtime","severity":"high","requires":["stage in [pre-launch, production]"],"tier":1,"note":"build-time inlining bakes the value into the artifact; rotation needs a rebuild.","category":"deployment"},{"id":"DEP-002","title":"Secrets present in CI logs or config","severity":"critical","requires":["has_ci == true"],"tier":1,"check":"plaintext credential in workflow yaml, or echoed to build output","category":"deployment"},{"id":"DEP-003","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-004","tier":2,"severity":"high","requires":["has_migrations == true","stage == production"]},{"id":"DEP-005","title":"Migrations are not reversible","severity":"medium","requires":["has_migrations == true","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-006","tier":2,"severity":"critical","requires":["has_migrations == true","stage == production"]},{"id":"DEP-007","tier":2,"severity":"high","requires":["stage == production"]},{"id":"DEP-008","title":"No health endpoint","severity":"medium","requires":["stage == production","surface in [web-app, api-only]"],"tier":1,"category":"deployment"},{"id":"DEP-009","title":"No graceful shutdown handling","severity":"medium","requires":["stage == production","has_background_jobs == true"],"tier":1,"check":"no SIGTERM handler draining in-flight work","category":"deployment"},{"id":"DEP-010","title":"Dependencies unpinned","severity":"medium","requires":["stage in [pre-launch, production]"],"tier":1,"check":"no lockfile committed, or ranges in a deployed manifest","category":"deployment"},{"id":"DEP-011","title":"Known-vulnerable dependencies","severity":"high","requires":[],"tier":1,"check":"audit surfaces advisories at high or critical","category":"deployment"},{"id":"DEP-012","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"DEP-013","title":"Third-party script loaded without integrity check","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"external <script> without SRI","category":"deployment"},{"id":"DEP-014","title":"Source maps published in production","severity":"medium","requires":["surface in [web-app, web-site]","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-015","title":"Debug mode or verbose errors enabled in production","severity":"high","requires":["stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-016","title":"No CI check gating merges","severity":"low","requires":["has_ci == true","business_model in [b2b-saas, marketplace]"],"tier":1,"category":"deployment"},{"id":"DEP-017","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-018","tier":2,"severity":"high","requires":["stage == production","stack.database != none"]},{"id":"RU-001","title":"Timestamps stored without timezone","severity":"high","requires":["stack.database != none"],"tier":1,"check":"timestamp column without time zone, or naive datetime written from app code","note":"works perfectly until your second user is in another country.","category":"real-user-readiness"},{"id":"RU-002","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"RU-003","title":"Money stored as float","severity":"critical","requires":["handles_payments != none or serves_currency == multi"],"tier":1,"check":"float/double/real column holding an amount","note":"rounding error in currency is a correctness bug, not a style issue.","category":"real-user-readiness"},{"id":"RU-004","title":"Currency not stored alongside amount","severity":"high","requires":["serves_currency == multi"],"tier":1,"category":"real-user-readiness"},{"id":"RU-005","tier":2,"severity":"low","requires":["data_sensitivity != none","audience_locale == multi"]},{"id":"RU-006","title":"Text input not accepting the full Unicode range","severity":"high","requires":["stack.database != none"],"tier":1,"note":"corpus — an emoji crashed a production app because the column was 3-byte utf8.","category":"real-user-readiness"},{"id":"RU-007","title":"No mobile viewport handling","severity":"high","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"RU-009","tier":2,"severity":"medium","requires":["has_accounts == true"]},{"id":"RU-010","title":"No support or contact channel","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"category":"real-user-readiness"},{"id":"RU-011","tier":2,"severity":"high","requires":["sends_email == true","stage == production"]},{"id":"RU-012","tier":2,"severity":"medium","requires":["sends_email == true","stage == production"]},{"id":"RU-013","title":"Marketing email without an unsubscribe path","severity":"high","requires":["sends_email == true","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-014","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RU-015","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"RU-016","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RU-017","tier":2,"severity":"high","requires":["has_accounts == true","stack.auth == custom"]},{"id":"RU-018","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"RU-019","title":"Session lifetime unbounded","severity":"medium","requires":["has_accounts == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-020","tier":2,"severity":"low","requires":["has_accounts == true","business_model in [b2c, b2b-saas]"]},{"id":"TEN-001","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","stack.database in [postgres, supabase]"]},{"id":"TEN-002","title":"No composite index on (tenant_id, ...) for hot queries","severity":"medium","requires":["tenancy == multi-tenant-shared-db"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-003","title":"Invitation token does not expire","severity":"high","requires":["has_invitations == true"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-004","tier":2,"severity":"critical","requires":["has_invitations == true","has_roles == true"]},{"id":"TEN-005","tier":2,"severity":"critical","requires":["has_roles == true"]},{"id":"TEN-006","tier":2,"severity":"critical","requires":["has_roles == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-007","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_accounts == true"]},{"id":"TEN-008","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","has_file_uploads == true"]},{"id":"TEN-009","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-010","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","has_subscriptions == true"]},{"id":"TEN-011","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","calls_llm == true"]},{"id":"TEN-012","tier":2,"severity":"high","requires":["has_admin_panel == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-013","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-014","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_webhooks_in == true"]},{"id":"UP-001","title":"File type validated by extension or client MIME only","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-002","title":"No upload size limit","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-003","title":"Upload path built from a user-supplied filename","severity":"critical","requires":["has_file_uploads == true"],"tier":1,"check":"filename concatenated into a storage path without normalisation","category":"file-uploads"},{"id":"UP-004","tier":2,"severity":"high","requires":["has_file_uploads == true","surface in [web-app, web-site]"]},{"id":"UP-005","title":"SVG accepted without sanitisation","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-006","tier":2,"severity":"medium","requires":["has_file_uploads == true","tenancy == multi-tenant-shared-db"]},{"id":"UP-007","title":"Signed URLs with excessive lifetime","severity":"medium","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-008","title":"EXIF metadata not stripped from images","severity":"medium","requires":["has_file_uploads == true","data_sensitivity != none"],"tier":1,"note":"photo uploads routinely carry GPS coordinates.","category":"file-uploads"},{"id":"UP-009","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-010","tier":2,"severity":"medium","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"UP-011","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-012","tier":2,"severity":"high","requires":["has_file_uploads == true"]},{"id":"API-001","title":"Mass assignment — request body spread into a model","severity":"critical","requires":["stack.database != none"],"tier":1,"check":"request body passed wholesale to create/update","note":"this is how a user sets their own is_admin.","category":"api-design"},{"id":"API-002","title":"Internal error details returned to the client","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-003","title":"Stack traces reachable in production responses","severity":"high","requires":["is_public == true","stage == production"],"tier":1,"category":"api-design"},{"id":"API-004","title":"No maximum page size","severity":"medium","requires":["is_public == true"],"tier":1,"check":"limit parameter accepted without an upper bound","category":"api-design"},{"id":"API-005","title":"Sort or filter parameter interpolated into a query","severity":"critical","requires":["stack.database != none"],"tier":1,"category":"api-design"},{"id":"API-006","title":"State-changing operation exposed over GET","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-007","tier":2,"severity":"high","requires":["has_accounts == true","surface in [web-app]"]},{"id":"API-008","tier":2,"severity":"low","requires":["surface == api-only"]},{"id":"API-009","tier":2,"severity":"low","requires":["has_accounts == true"]},{"id":"API-010","title":"No request timeout on outbound calls","severity":"medium","requires":["stage == production"],"tier":1,"category":"api-design"},{"id":"API-011","tier":2,"severity":"medium","requires":["surface == api-only","stage == production"]},{"id":"API-012","title":"Security headers missing","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"no CSP, X-Content-Type-Options, Referrer-Policy, frame ancestors","category":"api-design"},{"id":"API-013","title":"Open redirect","severity":"high","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"check":"redirect target read from a query parameter without an allowlist","category":"api-design"},{"id":"API-014","tier":2,"severity":"critical","requires":["is_public == true"],"has_static_approximation":true,"title":"SSRF — outbound request to a user-supplied URL"},{"id":"NEXT-001","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-002","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-003","tier":2,"severity":"high","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-004","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-005","tier":2,"severity":"high","requires":["stack.framework == next"]},{"id":"NEXT-006","title":"Secret imported into a module reachable from the client graph","severity":"critical","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-007","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-008","tier":2,"severity":"medium","requires":["stack.framework == next"]},{"id":"NEXT-009","title":"Image optimizer allows arbitrary remote hosts","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"remotePatterns with a wildcard hostname","category":"framework-next"},{"id":"NEXT-010","title":"Route handler lacks runtime/duration config for long work","severity":"medium","requires":["stack.framework == next","calls_llm == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-011","title":"Error boundary absent","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"no error.tsx / global-error.tsx","category":"framework-next"},{"id":"NEXT-012","title":"Loading UI absent on data routes","severity":"low","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-013","title":"Cookies set without secure attributes","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-014","title":"Redirect after login uses an unvalidated next parameter","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"SUP-001","title":"Storage bucket public by default","severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"],"tier":1,"note":"mirrors the RLS problem — permissive default, silent exposure.","category":"platform-supabase"},{"id":"SUP-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"]},{"id":"SUP-003","tier":2,"severity":"critical","requires":["stack.database == supabase","has_realtime == true"]},{"id":"SUP-004","tier":2,"severity":"critical","requires":["stack.database == supabase"]},{"id":"SUP-005","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"]},{"id":"SUP-006","title":"Postgres function marked SECURITY DEFINER without a search_path","severity":"high","requires":["stack.database in [supabase, postgres]"],"tier":1,"category":"platform-supabase"},{"id":"SUP-007","tier":2,"severity":"high","requires":["stack.database == supabase"]},{"id":"SUP-008","tier":2,"severity":"low","requires":["stack.auth == supabase-auth","stage == production"]},{"id":"SUP-009","tier":2,"severity":"critical","requires":["stack.database in [supabase, postgres]","has_accounts == true"]},{"id":"SUP-010","tier":2,"severity":"high","requires":["stack.database == supabase","has_webhooks_in == true"]},{"id":"SUP-011","title":"Connection string uses the direct port under serverless","severity":"high","requires":["stack.database == supabase","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"use the pooler; direct connections exhaust under serverless fan-out.","category":"platform-supabase"},{"id":"SUP-012","tier":2,"severity":"high","requires":["stack.database == supabase","stage == production"]},{"id":"LIFE-001","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-002","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-003","tier":2,"severity":"high","requires":["data_sensitivity != none"]},{"id":"LIFE-004","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-005","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-006","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-007","tier":2,"severity":"critical","requires":["data_sensitivity != none"]},{"id":"LIFE-008","tier":2,"severity":"critical","requires":["stage == production","data_sensitivity != none"]},{"id":"LIFE-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-010","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-011","tier":2,"severity":"medium","requires":["business_model == b2b-saas","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-012","tier":2,"severity":"critical","requires":["data_sensitivity == children"]},{"id":"MOB-001","title":"Secret embedded in the app binary","severity":"critical","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"note":"a shipped binary is a public file. anything in it is published.","category":"mobile"},{"id":"MOB-002","title":"Token stored outside the platform secure store","severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_accounts == true"],"tier":1,"check":"credential in UserDefaults / SharedPreferences rather than Keychain / Keystore","category":"mobile"},{"id":"MOB-003","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"MOB-004","title":"Permissions requested without a usage description","severity":"high","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"category":"mobile"},{"id":"MOB-005","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-006","tier":2,"severity":"medium","requires":["surface == mobile-ios","has_accounts == true"]},{"id":"MOB-007","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_subscriptions == true"]},{"id":"MOB-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","stage == production"]},{"id":"MOB-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-010","tier":2,"severity":"low","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"INC-001","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"INC-002","tier":2,"severity":"high","requires":["stage == production","calls_llm == true"]},{"id":"INC-003","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, b2c]"]},{"id":"INC-004","title":"No security contact or disclosure path","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"origin":"gap","check":"no security.txt, no security contact","category":"incident-readiness"},{"id":"INC-005","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"INC-006","tier":2,"severity":"critical","requires":["calls_llm == true","stage == production"]},{"id":"INC-007","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"INC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"INC-009","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"INC-010","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"DJ-001","title":"DEBUG is on in a deployed setting","severity":"critical","requires":["stack.framework == django","stage in [pre-launch, production]"],"tier":1,"check":"DEBUG = True in a settings module not named local/dev","note":"Django's debug page prints settings, installed apps, the SQL that ran and a full traceback to whoever triggers the error. Django's own docs call deploying with it on a security problem.","fix":"DEBUG = False in production, and set ALLOWED_HOSTS.","category":"framework-django"},{"id":"DJ-002","title":"SECRET_KEY written into source","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SECRET_KEY assigned a literal rather than read from the environment","note":"SECRET_KEY signs sessions, password-reset tokens and CSRF tokens. Anyone holding it can forge a session for any user.","fix":"Read it from an environment variable and rotate the committed value.","category":"framework-django"},{"id":"DJ-003","title":"ALLOWED_HOSTS accepts any host","severity":"high","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"ALLOWED_HOSTS contains '*'","note":"enables host-header poisoning, which turns password-reset emails into links pointing at an attacker's domain.","category":"framework-django"},{"id":"DJ-004","title":"CSRF middleware removed","severity":"critical","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"MIDDLEWARE lacks django.middleware.csrf.CsrfViewMiddleware","note":"it ships enabled. Its absence means someone deliberately removed it.","category":"framework-django"},{"id":"DJ-005","tier":2,"severity":"critical","requires":["stack.framework == django"]},{"id":"DJ-006","title":"Raw SQL built by interpolation","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":".raw() or .extra() containing an f-string, % or .format()","fix":"Pass parameters: .raw('SELECT … WHERE id = %s', [id])","category":"framework-django"},{"id":"DJ-007","title":"HTTPS not enforced","severity":"high","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_SSL_REDIRECT absent or False","category":"framework-django"},{"id":"DJ-008","title":"Session and CSRF cookies not marked secure","severity":"high","requires":["stack.framework == django","has_accounts == true","stage == production"],"tier":1,"check":"SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE missing or False","note":"without these the cookies travel over plain HTTP on the first request.","category":"framework-django"},{"id":"DJ-009","title":"HSTS not configured","severity":"medium","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_HSTS_SECONDS is 0 or unset","category":"framework-django"},{"id":"DJ-010","title":"ModelForm or serializer exposes every field","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"fields = '__all__' on a ModelForm or ModelSerializer","note":"Django's own mass-assignment shape. Every column becomes writable from the request, including is_staff, is_superuser and any balance you keep on the model.","fix":"List the fields you actually accept.","category":"framework-django"},{"id":"DJ-011","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-012","title":"Password validators removed","severity":"medium","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"AUTH_PASSWORD_VALIDATORS is an empty list","category":"framework-django"},{"id":"DJ-013","title":"Clickjacking protection disabled","severity":"medium","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"XFrameOptionsMiddleware absent, or X_FRAME_OPTIONS set to ALLOWALL","category":"framework-django"},{"id":"DJ-014","title":"Sessions serialised with pickle","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SESSION_SERIALIZER set to PickleSerializer","note":"combined with a leaked SECRET_KEY this is remote code execution, not just session forgery.","category":"framework-django"},{"id":"DJ-015","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-016","title":"DEBUG toolbar or dev-only app installed in production","severity":"high","requires":["stack.framework == django","stage == production"],"tier":1,"check":"debug_toolbar, django_extensions or silk present in production INSTALLED_APPS","category":"framework-django"},{"id":"DJ-017","tier":2,"severity":"medium","requires":["stack.framework == django","has_file_uploads == true","stage == production"]},{"id":"DJ-018","tier":2,"severity":"high","requires":["stack.framework == django","has_accounts == true","is_public == true"]},{"id":"RB-001","title":"secret_key_base committed to the repository","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"a literal secret_key_base in config/secrets.yml, credentials.yml or an initializer","note":"signs every session cookie. With it, anyone mints a session for any user — and Rails historically deserialised session data, so it has meant RCE.","fix":"Use encrypted credentials or ENV, and rotate the exposed key.","category":"framework-rails"},{"id":"RB-002","title":"config.force_ssl not enabled in production","severity":"high","requires":["stack.framework == rails","stage == production","is_public == true"],"tier":1,"check":"config.force_ssl absent or false in config/environments/production.rb","note":"one line, and it enables the redirect, secure cookies and HSTS together.","category":"framework-rails"},{"id":"RB-003","title":"CSRF protection disabled or skipped","severity":"critical","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"skip_before_action :verify_authenticity_token, or protect_from_forgery with: :null_session on a state-changing controller","category":"framework-rails"},{"id":"RB-004","title":"html_safe or raw applied to user content","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":".html_safe or raw() on a value derived from params, or <%== in an ERB template","note":"ERB escapes by default. These are the ways to turn that off.","category":"framework-rails"},{"id":"RB-005","title":"SQL built by string interpolation","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"where(\"… #{…}\"), find_by_sql or order() containing interpolation","fix":"where('email = ?', email) — the placeholder form.","category":"framework-rails"},{"id":"RB-006","title":"Strong parameters bypassed with permit!","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"params.permit! or params.require(:x).permit!","note":"permit! whitelists everything the caller sent. Rails added strong parameters precisely because of the GitHub mass-assignment incident.","category":"framework-rails"},{"id":"RB-007","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-008","title":"Unsafe deserialisation of user input","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"Marshal.load, YAML.load or Oj.load applied to request data","note":"each of these instantiates arbitrary Ruby objects. Use YAML.safe_load.","category":"framework-rails"},{"id":"RB-009","title":"File served from a user-supplied path","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"send_file or render file: built from params","note":"../../config/master.key is a valid filename as far as the filesystem cares.","category":"framework-rails"},{"id":"RB-010","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-011","title":"Sensitive parameters not filtered from logs","severity":"high","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"config.filter_parameters omits password, token or secret","note":"otherwise plaintext passwords are written into production logs on every sign-in, and logs are backed up, shipped and searched.","category":"framework-rails"},{"id":"RB-012","title":"Detailed exception pages enabled in production","severity":"high","requires":["stack.framework == rails","stage == production"],"tier":1,"check":"config.consider_all_requests_local = true in production.rb","note":"turns every 500 into a stack trace with source and local variables.","category":"framework-rails"},{"id":"RB-013","tier":2,"severity":"high","requires":["stack.framework == rails"]},{"id":"RB-014","title":"Devise configured without lockable or timeout","severity":"medium","requires":["stack.framework == rails","stack.auth == devise","is_public == true"],"tier":1,"check":"devise model lacks :lockable, or Devise.timeout_in is unset","note":"Devise ships neither brute-force lockout nor session expiry enabled.","category":"framework-rails"},{"id":"RB-015","tier":2,"severity":"medium","requires":["stack.framework == rails","has_background_jobs == true"]}]}
|
|
1
|
+
{"version":"0.1","count":290,"rules":[{"id":"SEC-001","title":".env committed to version control","severity":"critical","requires":[],"tier":1,"check":"git ls-files matches ^\\.env(\\.|$) and not .env.example","fix":"git rm --cached .env; add to .gitignore; ROTATE every key in it","note":"rotation is the part everyone skips. history retains the file.","category":"secrets"},{"id":"SEC-002","title":".env absent from .gitignore","severity":"high","requires":[],"tier":1,"check":".gitignore lacks a pattern matching .env","category":"secrets"},{"id":"SEC-003","title":"Hardcoded API key / token / password in source","severity":"critical","requires":[],"tier":1,"check":"entropy + provider prefix scan (sk-, sk-ant-, AKIA, ghp_, xoxb-, eyJ...) outside .env","category":"secrets"},{"id":"SEC-009","title":"A live key is still in your git history","severity":"critical","requires":[],"tier":1,"check":"vendor-prefixed live keys (sk_live_, rk_live_, whsec_, sk-ant-, sk-proj-, ghp_, gho_, glpat-, SG.) found in any blob in .git, read directly without shelling out. Deliberately NOT private keys or database URLs: a test fixture and a real one are byte-identical, and including them fired on 15 of 68 real repositories with every hit a fixture.","category":"secrets"},{"id":"SEC-004","title":"Server-only secret exposed to the client bundle","severity":"critical","requires":["surface in [web-app, web-site]"],"tier":1,"check":"NEXT_PUBLIC_/VITE_/REACT_APP_ prefix on a name matching (KEY|SECRET|TOKEN|PASSWORD), or a secret literal reachable from a client component","note":"the single most common vibe-coder leak. the prefix makes it PUBLIC, and the name reads like it is protected.","category":"secrets"},{"id":"SEC-005","title":"Supabase service_role key used outside a server context","severity":"critical","requires":["stack.database == supabase"],"tier":1,"check":"service_role key referenced in a client component, edge config, or NEXT_PUBLIC_ var","note":"service_role bypasses RLS entirely. in the browser it is a full database handover.","category":"secrets"},{"id":"SEC-006","title":"Paid-API key called directly from the browser","severity":"critical","requires":["calls_llm == true"],"tier":1,"check":"LLM SDK instantiated in client-side code, or provider endpoint called from browser","fix":"proxy through your own server route; never ship the provider key","category":"secrets"},{"id":"SEC-007","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"SEC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"AUTH-001","title":"Session token stored in localStorage","severity":"high","requires":["has_accounts == true","surface in [web-app, web-site]"],"tier":1,"check":"localStorage.setItem / getItem with a token-like key","fix":"httpOnly + Secure + SameSite cookie","note":"localStorage is readable by any XSS. a cookie with httpOnly is not.","category":"authentication"},{"id":"AUTH-002","title":"Role or permission read from client-controlled storage","severity":"critical","requires":["has_accounts == true"],"tier":1,"check":"role/isAdmin/plan/tier read from localStorage, sessionStorage, cookie, or JWT claim without server-side re-verification","note":"the corpus demo is literally 'open Application storage, change Member to Admin, refresh'.","category":"authentication"},{"id":"AUTH-003","title":"No brute-force limit on login","severity":"high","requires":["has_accounts == true"],"tier":1,"check":"login route has no attempt counter / lockout / rate limit","fix":"max 5 attempts per identifier per 15 min, plus IP-level cap","category":"authentication"},{"id":"AUTH-004","title":"Password reset endpoint unthrottled","severity":"high","requires":["has_accounts == true","sends_email == true"],"tier":1,"check":"password-reset route lacks rate limiting","note":"corpus example — 67,000 requests at 2am against one reset endpoint.","category":"authentication"},{"id":"AUTH-005","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AUTH-006","tier":2,"severity":"medium","requires":["has_admin_panel == true","stage == production"]},{"id":"AUTH-007","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"AUTH-008","title":"Password stored without a modern KDF","severity":"critical","requires":["stack.auth == custom"],"tier":1,"check":"password hashing absent, or md5/sha1/sha256 without bcrypt/argon2/scrypt","category":"authentication"},{"id":"AUTH-009","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production","is_public == true"]},{"id":"AUTH-010","tier":2,"severity":"high","requires":["stack.host in [vps, aws, gcp]"]},{"id":"AUTHZ-001","title":"Supabase table with RLS disabled","severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"tier":1,"check":"table in migrations without ENABLE ROW LEVEL SECURITY","note":"corpus calls this the single most common vulnerability found across startups AND enterprises. RLS is OFF by default for raw-SQL tables.","category":"authorization"},{"id":"AUTHZ-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"],"has_static_approximation":true,"title":"RLS enabled but policy is permissive"},{"id":"AUTHZ-003","tier":2,"severity":"critical","requires":["has_accounts == true"],"has_static_approximation":true,"title":"IDOR — object accessed by ID without an ownership check"},{"id":"AUTHZ-004","tier":2,"severity":"critical","requires":["has_admin_panel == true"],"has_static_approximation":true,"title":"Admin authorization enforced only in the UI"},{"id":"AUTHZ-005","tier":2,"severity":"critical","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"Usage limits stored in a row the user can modify"},{"id":"AUTHZ-006","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"],"has_static_approximation":true,"title":"Missing tenant scope on a domain query"},{"id":"AUTHZ-007","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"AUTHZ-008","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RATE-001","title":"No rate limiting on any endpoint","severity":"high","requires":["is_public == true"],"tier":1,"check":"no rate-limit middleware, no gateway policy, no per-route limiter","category":"rate-limiting"},{"id":"RATE-002","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"RATE-003","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"No spend ceiling on paid-API endpoints"},{"id":"RATE-004","tier":2,"severity":"high","requires":["calls_llm == true","has_accounts == true"],"has_static_approximation":true,"title":"No per-user quota on AI features"},{"id":"RATE-005","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RATE-006","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"RATE-007","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"AI-001","tier":2,"severity":"critical","requires":["calls_llm == true","llm_input_from_user == true"],"has_static_approximation":true,"title":"User input reaches the system-prompt position"},{"id":"AI-002","tier":2,"severity":"high","requires":["calls_llm == true","llm_input_from_user == true"]},{"id":"AI-003","title":"No max_tokens ceiling on completions","severity":"high","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"provider call omits max_tokens, or sets it above the plan's economics","note":"unbounded output is unbounded cost, per request.","category":"ai-safety"},{"id":"AI-004","title":"No model allowlist","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","check":"model id derived from request input, or no constant/allowlist","note":"a client-chosen model is a client-chosen price.","category":"ai-safety"},{"id":"AI-005","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AI-006","tier":2,"severity":"critical","requires":["calls_llm == true","llm_tools_enabled == true"]},{"id":"AI-007","title":"Model output rendered as HTML without sanitisation","severity":"critical","requires":["calls_llm == true","surface in [web-app, web-site]"],"tier":1,"origin":"gap","check":"completion piped to dangerouslySetInnerHTML / v-html / innerHTML","note":"injection in, XSS out.","category":"ai-safety"},{"id":"AI-008","tier":2,"severity":"critical","requires":["calls_llm == true"],"has_static_approximation":true,"title":"Model output used in a privileged operation without validation"},{"id":"AI-009","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AI-010","tier":2,"severity":"high","requires":["calls_llm == true","data_sensitivity != none"]},{"id":"AI-011","title":"No retry/backoff on provider 429s","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","category":"ai-safety"},{"id":"AI-012","tier":2,"severity":"high","requires":["calls_llm == true","stack.host in [vercel, netlify]"]},{"id":"AI-013","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2b-saas, b2c, marketplace]"]},{"id":"PAY-001","title":"Webhook signature not verified","severity":"critical","requires":["handles_payments != none","has_webhooks_in == true"],"tier":1,"check":"webhook route parses the body without constructEvent / signature check","note":"an unverified webhook endpoint is an unauthenticated 'mark this order paid' API.","category":"payments"},{"id":"PAY-002","tier":2,"severity":"high","requires":["handles_payments in [stripe, paddle, lemonsqueezy]"]},{"id":"PAY-003","tier":2,"severity":"high","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-004","tier":2,"severity":"medium","requires":["handles_payments != none"]},{"id":"PAY-005","tier":2,"severity":"high","requires":["has_subscriptions == true"]},{"id":"PAY-006","title":"Price or amount accepted from the client","severity":"critical","requires":["handles_payments != none"],"tier":1,"check":"amount/price/currency read from the request body when creating a charge","category":"payments"},{"id":"PAY-007","tier":2,"severity":"medium","requires":["handles_payments != none","has_webhooks_in == true"]},{"id":"PAY-008","tier":2,"severity":"high","requires":["surface in [mobile-ios]","has_subscriptions == true"]},{"id":"DATA-001","title":"Unbounded list query — no pagination","severity":"high","requires":["stack.database != none"],"tier":1,"check":"list endpoint without limit/offset or cursor","note":"corpus — fine at 50 users, loads 50,000 rows at 500.","category":"data-and-scale"},{"id":"DATA-002","tier":2,"severity":"high","requires":["stack.database != none"]},{"id":"DATA-003","title":"Missing index on a filtered or joined column","severity":"high","requires":["stack.database in [postgres, supabase, mysql, planetscale]"],"tier":1,"check":"WHERE / JOIN / ORDER BY column with no index in migrations","note":"50ms at 100 rows, 30s at 100k. the code never changed.","category":"data-and-scale"},{"id":"DATA-004","title":"No connection pooling","severity":"high","requires":["stack.database in [postgres, supabase, mysql]","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"serverless multiplies connections per invocation; the DB runs out first.","category":"data-and-scale"},{"id":"DATA-005","title":"SELECT * on wide or hot tables","severity":"medium","requires":["stack.database != none"],"tier":1,"category":"data-and-scale"},{"id":"DATA-006","tier":2,"severity":"critical","requires":[]},{"id":"DATA-007","title":"Raw SQL built by string concatenation","severity":"critical","requires":["stack.orm == raw-sql"],"tier":1,"check":"query string interpolating a request value","category":"data-and-scale"},{"id":"DATA-008","title":"No payload size limit","severity":"medium","requires":["is_public == true"],"tier":1,"check":"body parser without a size cap","category":"data-and-scale"},{"id":"DATA-009","title":"Text columns not 4-byte-safe","severity":"medium","requires":["stack.database in [mysql, planetscale]"],"tier":1,"check":"utf8 rather than utf8mb4 on user-facing text columns","note":"corpus failure — a user typed an emoji and the app crashed. 3-byte utf8 cannot hold a 4-byte codepoint.","category":"data-and-scale"},{"id":"DATA-010","tier":2,"severity":"medium","requires":["stage == pre-launch","expected_scale != hobby"]},{"id":"DATA-011","tier":2,"severity":"medium","requires":["sends_email == true or has_file_uploads == true"]},{"id":"DATA-012","tier":2,"severity":"critical","requires":["stage == production","stack.database != none"]},{"id":"DATA-013","tier":2,"severity":"critical","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"DATA-014","title":"User uploads stored on ephemeral disk","severity":"critical","requires":["has_file_uploads == true","stack.host in [vercel, netlify, railway, fly, render]"],"tier":1,"check":"writes to local fs on a platform with an ephemeral filesystem","note":"matches CRMini — files vanish on every redeploy.","origin":"gap","category":"data-and-scale"},{"id":"DATA-015","title":"User input concatenated into a SQL query","severity":"critical","tier":1,"check":"a request value interpolated into a SQL string instead of passed as a parameter","note":"the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.","origin":"gap","category":"data-and-scale"},{"id":"INF-001","tier":2,"severity":"medium","requires":["stage == production","expected_scale in [under-100k, over-100k]","stack.host in [vps, aws, gcp]"]},{"id":"INF-002","title":"No health check or auto-restart","severity":"medium","requires":["stage == production","stack.host in [vps, aws, gcp]"],"tier":1,"category":"infrastructure"},{"id":"INF-003","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-004","tier":2,"severity":"low","requires":["stage == production","expected_scale != hobby"]},{"id":"INF-005","tier":2,"severity":"low","requires":["expected_scale == over-100k"]},{"id":"INF-006","title":"HTTPS not enforced","severity":"high","requires":["is_public == true"],"tier":1,"check":"no HSTS, no http->https redirect","category":"infrastructure"},{"id":"INF-007","title":"Permissive CORS","severity":"high","requires":["surface in [web-app, api-only]"],"tier":1,"check":"Access-Control-Allow-Origin: * on credentialed routes","origin":"gap","category":"infrastructure"},{"id":"INF-008","tier":2,"severity":"critical","requires":["stack.database != none","stack.host in [vps, aws, gcp]"]},{"id":"INF-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"INF-010","tier":2,"severity":"medium","requires":["is_public == true","stage == production"]},{"id":"INF-011","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, marketplace]"]},{"id":"LEG-001","title":"No privacy policy while collecting personal data","severity":"high","requires":["data_sensitivity != none","is_public == true"],"tier":1,"note":"corpus is emphatic — a single contact form collecting an email is already personal data.","category":"legal"},{"id":"LEG-002","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LEG-003","title":"No terms of service","severity":"medium","requires":["is_public == true","has_accounts == true"],"tier":1,"category":"legal"},{"id":"LEG-004","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk, ca]"]},{"id":"LEG-005","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, ru, kz]"]},{"id":"LEG-006","title":"Accessibility baseline unmet","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"missing alt text, insufficient contrast, no heading hierarchy, unlabelled controls","note":"corpus flags active litigation risk in US and AU.","category":"legal"},{"id":"LEG-007","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]","business_model != hobby"]},{"id":"LEG-008","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"LEG-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [ru, kz, other]"]},{"id":"LEG-010","tier":2,"severity":"medium","requires":["is_public == true"]},{"id":"OBS-001","title":"No error monitoring","severity":"high","requires":["stage == production"],"tier":1,"origin":"gap","category":"observability"},{"id":"OBS-002","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"OBS-003","title":"No product analytics","severity":"low","requires":["stage in [pre-launch, production]","business_model != internal-tool"],"tier":1,"category":"observability"},{"id":"OBS-004","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"UX-001","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-002","tier":2,"severity":"high","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-003","tier":2,"severity":"low","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"UX-004","tier":2,"severity":"low","requires":["stage in [pre-launch, production]","business_model in [b2c, b2b-saas]"]},{"id":"UX-005","title":"No social preview image","severity":"low","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"category":"ux-readiness"},{"id":"UX-006","title":"No sitemap.xml / not indexable","severity":"low","requires":["is_public == true","surface == web-site"],"tier":1,"category":"ux-readiness"},{"id":"AIOP-001","tier":2,"severity":"high","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-002","title":"Model id pinned to an alias that can silently change","severity":"medium","requires":["calls_llm == true","stage == production"],"tier":1,"origin":"gap","note":"floating aliases shift behaviour and price under you with no deploy.","category":"ai-operations"},{"id":"AIOP-003","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-004","tier":2,"severity":"low","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-005","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-006","tier":2,"severity":"high","requires":["calls_llm == true"]},{"id":"AIOP-007","tier":2,"severity":"critical","requires":["has_rag == true"]},{"id":"AIOP-008","tier":2,"severity":"critical","requires":["has_rag == true","tenancy == multi-tenant-shared-db"]},{"id":"AIOP-009","tier":2,"severity":"medium","requires":["has_rag == true"]},{"id":"AIOP-010","title":"Agent loop has no iteration ceiling","severity":"critical","requires":["llm_tools_enabled == true"],"tier":1,"origin":"gap","note":"an unbounded loop is an unbounded invoice.","category":"ai-operations"},{"id":"AIOP-011","tier":2,"severity":"high","requires":["llm_tools_enabled == true"]},{"id":"AIOP-012","tier":2,"severity":"critical","requires":["llm_tools_enabled == true"]},{"id":"AIOP-013","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-014","tier":2,"severity":"medium","requires":["calls_llm == true","stage == production"]},{"id":"AIOP-015","tier":2,"severity":"medium","requires":["calls_llm == true","business_model in [b2c, b2b-saas]"]},{"id":"AIOP-016","title":"User-facing AI errors leak provider internals","severity":"medium","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"AIOP-017","tier":2,"severity":"medium","requires":["calls_llm == true","is_public == true","business_model == b2c"]},{"id":"AIOP-018","tier":2,"severity":"critical","requires":["calls_llm == true","is_public == true"],"has_static_approximation":true,"title":"Free tier allows unauthenticated AI calls"},{"id":"AIOP-019","tier":2,"severity":"medium","requires":["calls_llm == true"]},{"id":"AIOP-020","title":"Temperature or sampling unset for a deterministic task","severity":"low","requires":["calls_llm == true"],"tier":1,"origin":"gap","category":"ai-operations"},{"id":"DEP-001","title":"Secrets injected at build time instead of runtime","severity":"high","requires":["stage in [pre-launch, production]"],"tier":1,"note":"build-time inlining bakes the value into the artifact; rotation needs a rebuild.","category":"deployment"},{"id":"DEP-002","title":"Secrets present in CI logs or config","severity":"critical","requires":["has_ci == true"],"tier":1,"check":"plaintext credential in workflow yaml, or echoed to build output","category":"deployment"},{"id":"DEP-003","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-004","tier":2,"severity":"high","requires":["has_migrations == true","stage == production"]},{"id":"DEP-005","title":"Migrations are not reversible","severity":"medium","requires":["has_migrations == true","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-006","tier":2,"severity":"critical","requires":["has_migrations == true","stage == production"]},{"id":"DEP-007","tier":2,"severity":"high","requires":["stage == production"]},{"id":"DEP-008","title":"No health endpoint","severity":"medium","requires":["stage == production","surface in [web-app, api-only]"],"tier":1,"category":"deployment"},{"id":"DEP-009","title":"No graceful shutdown handling","severity":"medium","requires":["stage == production","has_background_jobs == true"],"tier":1,"check":"no SIGTERM handler draining in-flight work","category":"deployment"},{"id":"DEP-010","title":"Dependencies unpinned","severity":"medium","requires":["stage in [pre-launch, production]"],"tier":1,"check":"no lockfile committed, or ranges in a deployed manifest","category":"deployment"},{"id":"DEP-011","title":"Known-vulnerable dependencies","severity":"high","requires":[],"tier":1,"check":"audit surfaces advisories at high or critical","category":"deployment"},{"id":"DEP-012","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"DEP-013","title":"Third-party script loaded without integrity check","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"external <script> without SRI","category":"deployment"},{"id":"DEP-014","title":"Source maps published in production","severity":"medium","requires":["surface in [web-app, web-site]","stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-015","title":"Debug mode or verbose errors enabled in production","severity":"high","requires":["stage == production"],"tier":1,"category":"deployment"},{"id":"DEP-016","title":"No CI check gating merges","severity":"low","requires":["has_ci == true","business_model in [b2b-saas, marketplace]"],"tier":1,"category":"deployment"},{"id":"DEP-017","tier":2,"severity":"medium","requires":["stage in [pre-launch, production]"]},{"id":"DEP-018","tier":2,"severity":"high","requires":["stage == production","stack.database != none"]},{"id":"RU-001","title":"Timestamps stored without timezone","severity":"high","requires":["stack.database != none"],"tier":1,"check":"timestamp column without time zone, or naive datetime written from app code","note":"works perfectly until your second user is in another country.","category":"real-user-readiness"},{"id":"RU-002","tier":2,"severity":"medium","requires":["surface in [web-app, mobile-ios, mobile-android]"]},{"id":"RU-003","title":"Money stored as float","severity":"critical","requires":["handles_payments != none or serves_currency == multi"],"tier":1,"check":"float/double/real column holding an amount","note":"rounding error in currency is a correctness bug, not a style issue.","category":"real-user-readiness"},{"id":"RU-004","title":"Currency not stored alongside amount","severity":"high","requires":["serves_currency == multi"],"tier":1,"category":"real-user-readiness"},{"id":"RU-005","tier":2,"severity":"low","requires":["data_sensitivity != none","audience_locale == multi"]},{"id":"RU-006","title":"Text input not accepting the full Unicode range","severity":"high","requires":["stack.database != none"],"tier":1,"note":"corpus — an emoji crashed a production app because the column was 3-byte utf8.","category":"real-user-readiness"},{"id":"RU-007","title":"No mobile viewport handling","severity":"high","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"RU-009","tier":2,"severity":"medium","requires":["has_accounts == true"]},{"id":"RU-010","title":"No support or contact channel","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"category":"real-user-readiness"},{"id":"RU-011","tier":2,"severity":"high","requires":["sends_email == true","stage == production"]},{"id":"RU-012","tier":2,"severity":"medium","requires":["sends_email == true","stage == production"]},{"id":"RU-013","title":"Marketing email without an unsubscribe path","severity":"high","requires":["sends_email == true","is_public == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-014","tier":2,"severity":"high","requires":["sends_email == true"]},{"id":"RU-015","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"RU-016","tier":2,"severity":"high","requires":["has_accounts == true"]},{"id":"RU-017","tier":2,"severity":"high","requires":["has_accounts == true","stack.auth == custom"]},{"id":"RU-018","tier":2,"severity":"medium","requires":["has_accounts == true","is_public == true"]},{"id":"RU-019","title":"Session lifetime unbounded","severity":"medium","requires":["has_accounts == true"],"tier":1,"category":"real-user-readiness"},{"id":"RU-020","tier":2,"severity":"low","requires":["has_accounts == true","business_model in [b2c, b2b-saas]"]},{"id":"TEN-001","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","stack.database in [postgres, supabase]"]},{"id":"TEN-002","title":"No composite index on (tenant_id, ...) for hot queries","severity":"medium","requires":["tenancy == multi-tenant-shared-db"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-003","title":"Invitation token does not expire","severity":"high","requires":["has_invitations == true"],"tier":1,"category":"multi-tenancy"},{"id":"TEN-004","tier":2,"severity":"critical","requires":["has_invitations == true","has_roles == true"]},{"id":"TEN-005","tier":2,"severity":"critical","requires":["has_roles == true"]},{"id":"TEN-006","tier":2,"severity":"critical","requires":["has_roles == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-007","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_accounts == true"]},{"id":"TEN-008","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db","has_file_uploads == true"]},{"id":"TEN-009","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-010","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","has_subscriptions == true"]},{"id":"TEN-011","tier":2,"severity":"medium","requires":["tenancy == multi-tenant-shared-db","calls_llm == true"]},{"id":"TEN-012","tier":2,"severity":"high","requires":["has_admin_panel == true","tenancy == multi-tenant-shared-db"]},{"id":"TEN-013","tier":2,"severity":"critical","requires":["tenancy == multi-tenant-shared-db"]},{"id":"TEN-014","tier":2,"severity":"high","requires":["tenancy == multi-tenant-shared-db","has_webhooks_in == true"]},{"id":"UP-001","title":"File type validated by extension or client MIME only","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-002","title":"No upload size limit","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-003","title":"Upload path built from a user-supplied filename","severity":"critical","requires":["has_file_uploads == true"],"tier":1,"check":"filename concatenated into a storage path without normalisation","category":"file-uploads"},{"id":"UP-004","tier":2,"severity":"high","requires":["has_file_uploads == true","surface in [web-app, web-site]"]},{"id":"UP-005","title":"SVG accepted without sanitisation","severity":"high","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-006","tier":2,"severity":"medium","requires":["has_file_uploads == true","tenancy == multi-tenant-shared-db"]},{"id":"UP-007","title":"Signed URLs with excessive lifetime","severity":"medium","requires":["has_file_uploads == true"],"tier":1,"category":"file-uploads"},{"id":"UP-008","title":"EXIF metadata not stripped from images","severity":"medium","requires":["has_file_uploads == true","data_sensitivity != none"],"tier":1,"note":"photo uploads routinely carry GPS coordinates.","category":"file-uploads"},{"id":"UP-009","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-010","tier":2,"severity":"medium","requires":["has_file_uploads == true","has_accounts == true"]},{"id":"UP-011","tier":2,"severity":"medium","requires":["has_file_uploads == true"]},{"id":"UP-012","tier":2,"severity":"high","requires":["has_file_uploads == true"]},{"id":"API-001","title":"Mass assignment — request body spread into a model","severity":"critical","requires":["stack.database != none"],"tier":1,"check":"request body passed wholesale to create/update","note":"this is how a user sets their own is_admin.","category":"api-design"},{"id":"API-002","title":"Internal error details returned to the client","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-003","title":"Stack traces reachable in production responses","severity":"high","requires":["is_public == true","stage == production"],"tier":1,"category":"api-design"},{"id":"API-004","title":"No maximum page size","severity":"medium","requires":["is_public == true"],"tier":1,"check":"limit parameter accepted without an upper bound","category":"api-design"},{"id":"API-005","title":"Sort or filter parameter interpolated into a query","severity":"critical","requires":["stack.database != none"],"tier":1,"category":"api-design"},{"id":"API-006","title":"State-changing operation exposed over GET","severity":"high","requires":["is_public == true"],"tier":1,"category":"api-design"},{"id":"API-007","tier":2,"severity":"high","requires":["has_accounts == true","surface in [web-app]"]},{"id":"API-008","tier":2,"severity":"low","requires":["surface == api-only"]},{"id":"API-009","tier":2,"severity":"low","requires":["has_accounts == true"]},{"id":"API-010","title":"No request timeout on outbound calls","severity":"medium","requires":["stage == production"],"tier":1,"category":"api-design"},{"id":"API-011","tier":2,"severity":"medium","requires":["surface == api-only","stage == production"]},{"id":"API-012","title":"Security headers missing","severity":"medium","requires":["surface in [web-app, web-site]","is_public == true"],"tier":1,"check":"no CSP, X-Content-Type-Options, Referrer-Policy, frame ancestors","category":"api-design"},{"id":"API-013","title":"Open redirect","severity":"high","requires":["is_public == true","surface in [web-app, web-site]"],"tier":1,"check":"redirect target read from a query parameter without an allowlist","category":"api-design"},{"id":"API-014","tier":2,"severity":"critical","requires":["is_public == true"],"has_static_approximation":true,"title":"SSRF — outbound request to a user-supplied URL"},{"id":"NEXT-001","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-002","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-003","tier":2,"severity":"high","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-004","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-005","tier":2,"severity":"high","requires":["stack.framework == next"]},{"id":"NEXT-006","title":"Secret imported into a module reachable from the client graph","severity":"critical","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-007","tier":2,"severity":"critical","requires":["stack.framework == next","has_accounts == true"]},{"id":"NEXT-008","tier":2,"severity":"medium","requires":["stack.framework == next"]},{"id":"NEXT-009","title":"Image optimizer allows arbitrary remote hosts","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"remotePatterns with a wildcard hostname","category":"framework-next"},{"id":"NEXT-010","title":"Route handler lacks runtime/duration config for long work","severity":"medium","requires":["stack.framework == next","calls_llm == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-011","title":"Error boundary absent","severity":"medium","requires":["stack.framework == next"],"tier":1,"check":"no error.tsx / global-error.tsx","category":"framework-next"},{"id":"NEXT-012","title":"Loading UI absent on data routes","severity":"low","requires":["stack.framework == next"],"tier":1,"category":"framework-next"},{"id":"NEXT-013","title":"Cookies set without secure attributes","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"NEXT-014","title":"Redirect after login uses an unvalidated next parameter","severity":"high","requires":["stack.framework == next","has_accounts == true"],"tier":1,"category":"framework-next"},{"id":"SUP-001","title":"Storage bucket public by default","severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"],"tier":1,"note":"mirrors the RLS problem — permissive default, silent exposure.","category":"platform-supabase"},{"id":"SUP-002","tier":2,"severity":"critical","requires":["stack.database == supabase","has_file_uploads == true"]},{"id":"SUP-003","tier":2,"severity":"critical","requires":["stack.database == supabase","has_realtime == true"]},{"id":"SUP-004","tier":2,"severity":"critical","requires":["stack.database == supabase"]},{"id":"SUP-005","tier":2,"severity":"critical","requires":["stack.database == supabase","has_accounts == true"]},{"id":"SUP-006","title":"Postgres function marked SECURITY DEFINER without a search_path","severity":"high","requires":["stack.database in [supabase, postgres]"],"tier":1,"category":"platform-supabase"},{"id":"SUP-007","tier":2,"severity":"high","requires":["stack.database == supabase"]},{"id":"SUP-008","tier":2,"severity":"low","requires":["stack.auth == supabase-auth","stage == production"]},{"id":"SUP-009","tier":2,"severity":"critical","requires":["stack.database in [supabase, postgres]","has_accounts == true"]},{"id":"SUP-010","tier":2,"severity":"high","requires":["stack.database == supabase","has_webhooks_in == true"]},{"id":"SUP-011","title":"Connection string uses the direct port under serverless","severity":"high","requires":["stack.database == supabase","stack.host in [vercel, netlify, cloudflare]"],"tier":1,"note":"use the pooler; direct connections exhaust under serverless fan-out.","category":"platform-supabase"},{"id":"SUP-012","tier":2,"severity":"high","requires":["stack.database == supabase","stage == production"]},{"id":"LIFE-001","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-002","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-003","tier":2,"severity":"high","requires":["data_sensitivity != none"]},{"id":"LIFE-004","tier":2,"severity":"medium","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-005","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-006","tier":2,"severity":"high","requires":["data_sensitivity in [financial, health]"]},{"id":"LIFE-007","tier":2,"severity":"critical","requires":["data_sensitivity != none"]},{"id":"LIFE-008","tier":2,"severity":"critical","requires":["stage == production","data_sensitivity != none"]},{"id":"LIFE-009","tier":2,"severity":"medium","requires":["data_sensitivity != none","stage == production"]},{"id":"LIFE-010","tier":2,"severity":"high","requires":["collects_analytics == true","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-011","tier":2,"severity":"medium","requires":["business_model == b2b-saas","jurisdictions includes any of [eu, uk]"]},{"id":"LIFE-012","tier":2,"severity":"critical","requires":["data_sensitivity == children"]},{"id":"MOB-001","title":"Secret embedded in the app binary","severity":"critical","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"note":"a shipped binary is a public file. anything in it is published.","category":"mobile"},{"id":"MOB-002","title":"Token stored outside the platform secure store","severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_accounts == true"],"tier":1,"check":"credential in UserDefaults / SharedPreferences rather than Keychain / Keystore","category":"mobile"},{"id":"MOB-003","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"MOB-004","title":"Permissions requested without a usage description","severity":"high","requires":["surface in [mobile-ios, mobile-android]"],"tier":1,"category":"mobile"},{"id":"MOB-005","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-006","tier":2,"severity":"medium","requires":["surface == mobile-ios","has_accounts == true"]},{"id":"MOB-007","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]","has_subscriptions == true"]},{"id":"MOB-008","tier":2,"severity":"medium","requires":["surface in [mobile-ios, mobile-android]","stage == production"]},{"id":"MOB-009","tier":2,"severity":"high","requires":["surface in [mobile-ios, mobile-android]"]},{"id":"MOB-010","tier":2,"severity":"low","requires":["surface in [mobile-ios, mobile-android]","data_sensitivity in [financial, health]"]},{"id":"INC-001","tier":2,"severity":"high","requires":["has_accounts == true","stage == production"]},{"id":"INC-002","tier":2,"severity":"high","requires":["stage == production","calls_llm == true"]},{"id":"INC-003","tier":2,"severity":"low","requires":["stage == production","business_model in [b2b-saas, b2c]"]},{"id":"INC-004","title":"No security contact or disclosure path","severity":"medium","requires":["is_public == true","stage == production"],"tier":1,"origin":"gap","check":"no security.txt, no security contact","category":"incident-readiness"},{"id":"INC-005","tier":2,"severity":"high","requires":["data_sensitivity != none","jurisdictions includes any of [eu, uk]"]},{"id":"INC-006","tier":2,"severity":"critical","requires":["calls_llm == true","stage == production"]},{"id":"INC-007","tier":2,"severity":"medium","requires":["has_accounts == true","stage == production"]},{"id":"INC-008","tier":2,"severity":"medium","requires":["stage == production"]},{"id":"INC-009","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"INC-010","tier":2,"severity":"low","requires":["stage == production","business_model == b2b-saas"]},{"id":"DJ-001","title":"DEBUG is on in a deployed setting","severity":"critical","requires":["stack.framework == django","stage in [pre-launch, production]"],"tier":1,"check":"DEBUG = True in a settings module not named local/dev","note":"Django's debug page prints settings, installed apps, the SQL that ran and a full traceback to whoever triggers the error. Django's own docs call deploying with it on a security problem.","fix":"DEBUG = False in production, and set ALLOWED_HOSTS.","category":"framework-django"},{"id":"DJ-002","title":"SECRET_KEY written into source","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SECRET_KEY assigned a literal rather than read from the environment","note":"SECRET_KEY signs sessions, password-reset tokens and CSRF tokens. Anyone holding it can forge a session for any user.","fix":"Read it from an environment variable and rotate the committed value.","category":"framework-django"},{"id":"DJ-003","title":"ALLOWED_HOSTS accepts any host","severity":"high","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"ALLOWED_HOSTS contains '*'","note":"enables host-header poisoning, which turns password-reset emails into links pointing at an attacker's domain.","category":"framework-django"},{"id":"DJ-004","title":"CSRF middleware removed","severity":"critical","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"MIDDLEWARE lacks django.middleware.csrf.CsrfViewMiddleware","note":"it ships enabled. Its absence means someone deliberately removed it.","category":"framework-django"},{"id":"DJ-005","tier":2,"severity":"critical","requires":["stack.framework == django"]},{"id":"DJ-006","title":"Raw SQL built by interpolation","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":".raw() or .extra() containing an f-string, % or .format()","fix":"Pass parameters: .raw('SELECT … WHERE id = %s', [id])","category":"framework-django"},{"id":"DJ-007","title":"HTTPS not enforced","severity":"high","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_SSL_REDIRECT absent or False","category":"framework-django"},{"id":"DJ-008","title":"Session and CSRF cookies not marked secure","severity":"high","requires":["stack.framework == django","has_accounts == true","stage == production"],"tier":1,"check":"SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE missing or False","note":"without these the cookies travel over plain HTTP on the first request.","category":"framework-django"},{"id":"DJ-009","title":"HSTS not configured","severity":"medium","requires":["stack.framework == django","stage == production","is_public == true"],"tier":1,"check":"SECURE_HSTS_SECONDS is 0 or unset","category":"framework-django"},{"id":"DJ-010","title":"ModelForm or serializer exposes every field","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"fields = '__all__' on a ModelForm or ModelSerializer","note":"Django's own mass-assignment shape. Every column becomes writable from the request, including is_staff, is_superuser and any balance you keep on the model.","fix":"List the fields you actually accept.","category":"framework-django"},{"id":"DJ-011","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-012","title":"Password validators removed","severity":"medium","requires":["stack.framework == django","has_accounts == true"],"tier":1,"check":"AUTH_PASSWORD_VALIDATORS is an empty list","category":"framework-django"},{"id":"DJ-013","title":"Clickjacking protection disabled","severity":"medium","requires":["stack.framework == django","is_public == true"],"tier":1,"check":"XFrameOptionsMiddleware absent, or X_FRAME_OPTIONS set to ALLOWALL","category":"framework-django"},{"id":"DJ-014","title":"Sessions serialised with pickle","severity":"critical","requires":["stack.framework == django"],"tier":1,"check":"SESSION_SERIALIZER set to PickleSerializer","note":"combined with a leaked SECRET_KEY this is remote code execution, not just session forgery.","category":"framework-django"},{"id":"DJ-015","tier":2,"severity":"critical","requires":["stack.framework == django","has_accounts == true"]},{"id":"DJ-016","title":"DEBUG toolbar or dev-only app installed in production","severity":"high","requires":["stack.framework == django","stage == production"],"tier":1,"check":"debug_toolbar, django_extensions or silk present in production INSTALLED_APPS","category":"framework-django"},{"id":"DJ-017","tier":2,"severity":"medium","requires":["stack.framework == django","has_file_uploads == true","stage == production"]},{"id":"DJ-018","tier":2,"severity":"high","requires":["stack.framework == django","has_accounts == true","is_public == true"]},{"id":"RB-001","title":"secret_key_base committed to the repository","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"a literal secret_key_base in config/secrets.yml, credentials.yml or an initializer","note":"signs every session cookie. With it, anyone mints a session for any user — and Rails historically deserialised session data, so it has meant RCE.","fix":"Use encrypted credentials or ENV, and rotate the exposed key.","category":"framework-rails"},{"id":"RB-002","title":"config.force_ssl not enabled in production","severity":"high","requires":["stack.framework == rails","stage == production","is_public == true"],"tier":1,"check":"config.force_ssl absent or false in config/environments/production.rb","note":"one line, and it enables the redirect, secure cookies and HSTS together.","category":"framework-rails"},{"id":"RB-003","title":"CSRF protection disabled or skipped","severity":"critical","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"skip_before_action :verify_authenticity_token, or protect_from_forgery with: :null_session on a state-changing controller","category":"framework-rails"},{"id":"RB-004","title":"html_safe or raw applied to user content","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":".html_safe or raw() on a value derived from params, or <%== in an ERB template","note":"ERB escapes by default. These are the ways to turn that off.","category":"framework-rails"},{"id":"RB-005","title":"SQL built by string interpolation","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"where(\"… #{…}\"), find_by_sql or order() containing interpolation","fix":"where('email = ?', email) — the placeholder form.","category":"framework-rails"},{"id":"RB-006","title":"Strong parameters bypassed with permit!","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"params.permit! or params.require(:x).permit!","note":"permit! whitelists everything the caller sent. Rails added strong parameters precisely because of the GitHub mass-assignment incident.","category":"framework-rails"},{"id":"RB-007","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-008","title":"Unsafe deserialisation of user input","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"Marshal.load, YAML.load or Oj.load applied to request data","note":"each of these instantiates arbitrary Ruby objects. Use YAML.safe_load.","category":"framework-rails"},{"id":"RB-009","title":"File served from a user-supplied path","severity":"critical","requires":["stack.framework == rails"],"tier":1,"check":"send_file or render file: built from params","note":"../../config/master.key is a valid filename as far as the filesystem cares.","category":"framework-rails"},{"id":"RB-010","tier":2,"severity":"critical","requires":["stack.framework == rails","has_accounts == true"]},{"id":"RB-011","title":"Sensitive parameters not filtered from logs","severity":"high","requires":["stack.framework == rails","has_accounts == true"],"tier":1,"check":"config.filter_parameters omits password, token or secret","note":"otherwise plaintext passwords are written into production logs on every sign-in, and logs are backed up, shipped and searched.","category":"framework-rails"},{"id":"RB-012","title":"Detailed exception pages enabled in production","severity":"high","requires":["stack.framework == rails","stage == production"],"tier":1,"check":"config.consider_all_requests_local = true in production.rb","note":"turns every 500 into a stack trace with source and local variables.","category":"framework-rails"},{"id":"RB-013","tier":2,"severity":"high","requires":["stack.framework == rails"]},{"id":"RB-014","title":"Devise configured without lockable or timeout","severity":"medium","requires":["stack.framework == rails","stack.auth == devise","is_public == true"],"tier":1,"check":"devise model lacks :lockable, or Devise.timeout_in is unset","note":"Devise ships neither brute-force lockout nor session expiry enabled.","category":"framework-rails"},{"id":"RB-015","tier":2,"severity":"medium","requires":["stack.framework == rails","has_background_jobs == true"]}]}
|