launchprep 0.0.1 → 0.2.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.
@@ -0,0 +1,255 @@
1
+ // AI cost and safety. Barely covered by the corpus, which is exactly why it
2
+ // matters: this is the family nobody else is checking.
3
+ const finding = (id, title, severity, file, line, detail, fix) =>
4
+ ({ id, title, severity, file, line, detail, fix });
5
+
6
+ const lineOf = (text, i) => text.slice(0, i).split('\n').length;
7
+ const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
8
+ const isServer = (p) => /\/(api|routes?|server|actions?|controllers?|lib|services?)\//.test(p);
9
+ const isClient = (p) => /\/(components?|pages?|app|src)\//.test(p) && !isServer(p);
10
+
11
+ const LLM_CALL = /\.(messages|chat\.completions|completions|responses)\.create\s*\(|generateText\s*\(|streamText\s*\(|\.generateContent\s*\(/;
12
+
13
+ export const AI_CHECKS = [
14
+
15
+ // ---- a public AI endpoint is a public API on your credit card -------------
16
+ { id: 'AIOP-018', run(repo, profile) {
17
+ if (!profile.calls_llm || !profile.is_public) return [];
18
+ const out = [];
19
+ for (const f of repo.files) {
20
+ if (!f.text || !isCode(f.path) || !isServer(f.path)) continue;
21
+ if (!LLM_CALL.test(f.text)) continue;
22
+ const authed = /\b(session|getUser|auth\(\)|getServerSession|req\.user|userId|apiKey|requireAuth|clerkId)\b/.test(f.text);
23
+ if (authed) continue;
24
+ const m = f.text.match(LLM_CALL);
25
+ out.push(finding('AIOP-018', 'AI endpoint reachable without logging in', 'critical',
26
+ f.path, lineOf(f.text, f.text.indexOf(m[0])),
27
+ 'Anyone on the internet can call this endpoint and it will spend your model credits. No account, no limit, no way to identify who did it.',
28
+ 'Require a signed-in user before calling the model, and attach a per-user budget.'));
29
+ }
30
+ return out;
31
+ }},
32
+
33
+ // ---- no ceiling on what a single request can cost ------------------------
34
+ { id: 'AI-003', run(repo, profile) {
35
+ if (!profile.calls_llm) return [];
36
+ // Two problems the 68-repo audit exposed, and it fired 1,471 times.
37
+ //
38
+ // A fixed 700-character window is not the call. A generateText() with a long
39
+ // prompt puts maxOutputTokens past the end of the window, so a bounded call
40
+ // reads as unbounded. Read the balanced expression instead.
41
+ //
42
+ // And even when each hit is TRUE, 1,471 of them is not a report. An AI
43
+ // platform has hundreds of call sites; listing every one is the same failure
44
+ // as DATA-003's 605 - correct, and useless. One finding, with the count.
45
+ const call = (t, i, max = 3000) => {
46
+ const open = t.indexOf('(', i);
47
+ if (open === -1) return t.slice(i, i + 400);
48
+ let depth = 0;
49
+ for (let j = open; j < Math.min(t.length, open + max); j++) {
50
+ if (t[j] === '(' || t[j] === '{') depth++;
51
+ else if (t[j] === ')' || t[j] === '}') { if (--depth === 0) return t.slice(i, j + 1); }
52
+ }
53
+ return t.slice(i, i + max);
54
+ };
55
+ const BOUNDED = /max_tokens|maxTokens|max_output_tokens|maxOutputTokens|max_completion_tokens|maxCompletionTokens|stopSequences|stop_sequences/;
56
+
57
+ const sites = [];
58
+ for (const f of repo.files) {
59
+ if (!f.text || !isCode(f.path)) continue;
60
+ const re = new RegExp(LLM_CALL.source, 'g');
61
+ let m;
62
+ while ((m = re.exec(f.text))) {
63
+ if (BOUNDED.test(call(f.text, m.index))) continue;
64
+ sites.push({ path: f.path, line: lineOf(f.text, m.index) });
65
+ }
66
+ }
67
+ if (!sites.length) return [];
68
+ const eg = sites.slice(0, 4).map(x => `${x.path}:${x.line}`).join(', ');
69
+ return [finding('AI-003',
70
+ `${sites.length} model call${sites.length === 1 ? '' : 's'} with no token ceiling`,
71
+ 'high', sites[0].path, sites[0].line,
72
+ `Without a token limit one request can generate until the model decides to stop, and you pay for every token of it. A prompt that loops, or a user who asks for "everything you know about X", bills whatever it likes.` +
73
+ (sites.length > 4 ? ` Seen at ${eg}, and ${sites.length - 4} more.` : ` Seen at ${eg}.`),
74
+ 'Set a token ceiling on every model call - max_tokens, or maxOutputTokens on the Vercel AI SDK.')];
75
+ }},
76
+
77
+ // ---- the model to run chosen by the caller -------------------------------
78
+ { id: 'AI-004', run(repo, profile) {
79
+ if (!profile.calls_llm) return [];
80
+ const out = [];
81
+ for (const f of repo.files) {
82
+ if (!f.text || !isCode(f.path)) continue;
83
+ // The model VALUE has to come from the request. Looking for the word
84
+ // "request" anywhere within 400 characters found n8n's
85
+ // model: createModel(modelConfig, ...)
86
+ // because an unrelated `request.modelName` sat six lines above it. That is
87
+ // judging the neighbourhood, not the line.
88
+ const re = /model\s*:\s*(?!['"`])([a-zA-Z_$][\w.$\[\]'"]*)/g;
89
+ let m;
90
+ while ((m = re.exec(f.text))) {
91
+ const around = f.text.slice(Math.max(0, m.index - 400), m.index + 200);
92
+ if (!LLM_CALL.test(around)) continue;
93
+ if (!/^(body|req|request|params|query|input|payload|searchParams|args|dto)\b/i.test(m[1])) continue;
94
+ out.push(finding('AI-004', 'Caller decides which model runs', 'medium',
95
+ f.path, lineOf(f.text, m.index),
96
+ 'The model name comes from the request, so a caller can select your most expensive model on every call.',
97
+ 'Pick the model from a fixed allowlist on the server.'));
98
+ }
99
+ }
100
+ return out;
101
+ }},
102
+
103
+ // ---- user text landing in the system prompt ------------------------------
104
+ { id: 'AI-001', run(repo, profile) {
105
+ if (!profile.calls_llm) return [];
106
+ const out = [];
107
+ for (const f of repo.files) {
108
+ if (!f.text || !isCode(f.path)) continue;
109
+ const re = /(?:\bsystem\w*\s*[:=]\s*[`"'][^`"']{0,400}\$\{[^}]+\}|role\s*:\s*['"`]system['"`][\s\S]{0,200}?content\s*:\s*[`"'][^`"']{0,300}\$\{[^}]+\})/g;
110
+ let m;
111
+ while ((m = re.exec(f.text))) {
112
+ const interp = m[0].match(/\$\{([^}]+)\}/)?.[1] || '';
113
+ if (!/\b(body|req|request|input|message|query|prompt|user|params|content)\b/i.test(interp)) continue;
114
+ out.push(finding('AI-001', 'User input goes into the system prompt', 'critical',
115
+ f.path, lineOf(f.text, m.index),
116
+ 'Text from the user is being pasted into the instructions that control the model. A user can write "ignore previous instructions" and take over what your app does.',
117
+ 'Keep user text in the user turn, wrapped in clear delimiters. Never build the system prompt from request data.'));
118
+ }
119
+ }
120
+ return out;
121
+ }},
122
+
123
+ // ---- model output treated as trusted ------------------------------------
124
+ { id: 'AI-007', run(repo, profile) {
125
+ if (!profile.calls_llm) return [];
126
+ const out = [];
127
+ for (const f of repo.files) {
128
+ if (!f.text || !/\.(tsx|jsx|ts|js|vue|svelte)$/.test(f.path)) continue;
129
+ const re = /(dangerouslySetInnerHTML|\bv-html\b|\.innerHTML\s*=)/g;
130
+ let m;
131
+ while ((m = re.exec(f.text))) {
132
+ const around = f.text.slice(Math.max(0, m.index - 400), m.index + 200);
133
+ // only names that unambiguously mean model output - "message" and "content"
134
+ // appear in every forum, chat and CMS ever written
135
+ if (!/\b(completion|aiResponse|ai_response|modelOutput|model_output|llmResult|llm_response|generatedText|generated_text|assistantMessage|assistant_message)\b/i.test(around)) continue;
136
+ out.push(finding('AI-007', 'Model output rendered as raw HTML', 'critical',
137
+ f.path, lineOf(f.text, m.index),
138
+ 'Whatever the model returns is injected into the page as HTML. If a user can influence the prompt, they can make the model emit a script tag that runs for everyone who views it.',
139
+ 'Render model output as text, or sanitise it before inserting it as HTML.'));
140
+ }
141
+ }
142
+ return out;
143
+ }},
144
+
145
+ // ---- model output driving a privileged operation -------------------------
146
+ { id: 'AI-008', run(repo, profile) {
147
+ if (!profile.calls_llm) return [];
148
+ const out = [];
149
+ for (const f of repo.files) {
150
+ if (!f.text || !isCode(f.path)) continue;
151
+ const re = /\b(eval|exec|execSync|spawnSync|Function)\s*\(|\.query\s*\(\s*`/g;
152
+ let m;
153
+ while ((m = re.exec(f.text))) {
154
+ const around = f.text.slice(Math.max(0, m.index - 600), m.index + 200);
155
+ if (!LLM_CALL.test(around) &&
156
+ !/\b(completion|aiResponse|modelOutput|generated|llmResult)\b/i.test(around)) continue;
157
+ out.push(finding('AI-008', 'Model output used in a privileged operation', 'critical',
158
+ f.path, lineOf(f.text, m.index),
159
+ 'What the model returns is being executed or used to build a query. Anyone who can steer the model can steer this.',
160
+ 'Validate model output against a strict schema, and never pass it to eval, a shell, or a raw SQL string.'));
161
+ }
162
+ }
163
+ return out;
164
+ }},
165
+
166
+ // ---- agent loop that can run forever ------------------------------------
167
+ { id: 'AIOP-010', run(repo, profile) {
168
+ if (!profile.calls_llm) return [];
169
+ const out = [];
170
+ for (const f of repo.files) {
171
+ if (!f.text || !isCode(f.path)) continue;
172
+ const re = /while\s*\(\s*(true|1)\s*\)|for\s*\(\s*;\s*;\s*\)/g;
173
+ let m;
174
+ while ((m = re.exec(f.text))) {
175
+ const body = f.text.slice(m.index, m.index + 1200);
176
+ if (!LLM_CALL.test(body)) continue;
177
+ if (/\b(maxIterations|maxSteps|max_steps|iterations?\s*<|steps?\s*<|attempts?\s*<|turn\s*<)\b/.test(body)) continue;
178
+ out.push(finding('AIOP-010', 'Agent loop has no iteration limit', 'critical',
179
+ f.path, lineOf(f.text, m.index),
180
+ 'This loop calls the model with no maximum number of turns. One task that fails to converge keeps calling the API until something else stops it, and you pay per turn.',
181
+ 'Add a hard maximum number of iterations and stop when it is reached.'));
182
+ }
183
+ }
184
+ return out;
185
+ }},
186
+
187
+ // ---- provider key used straight from the browser -------------------------
188
+ { id: 'SEC-006', run(repo, profile) {
189
+ if (!profile.calls_llm) return [];
190
+ const out = [];
191
+ for (const f of repo.files) {
192
+ if (!f.text || !isCode(f.path) || !isClient(f.path)) continue;
193
+ const re = /new\s+(Anthropic|OpenAI|GoogleGenerativeAI|Groq)\s*\(|dangerouslyAllowBrowser\s*:\s*true/g;
194
+ let m;
195
+ while ((m = re.exec(f.text))) {
196
+ out.push(finding('SEC-006', 'AI provider called directly from the browser', 'critical',
197
+ f.path, lineOf(f.text, m.index),
198
+ 'Creating the client in browser code means your API key is shipped to every visitor. Anyone can extract it and spend your credits.',
199
+ 'Call the provider from your own server route and keep the key there.'));
200
+ }
201
+ }
202
+ return out;
203
+ }},
204
+
205
+ // ---- usage limits the user can edit -------------------------------------
206
+ { id: 'AUTHZ-005', run(repo, profile) {
207
+ if (!profile.calls_llm || !profile.has_accounts) return [];
208
+ const out = [];
209
+ const QUOTA = /\b(credits?|tokens?_?(used|left|remaining|limit)|usage_?limit|quota|plan_?limit|monthly_?limit)\b/i;
210
+ for (const f of repo.files) {
211
+ if (!f.text || !/\.(sql|prisma|ts)$/.test(f.path)) continue;
212
+ const isSchema = /pgTable\s*\(|CREATE TABLE|^model\s+\w+/mi.test(f.text);
213
+ if (!isSchema) continue;
214
+ // a quota column sitting on the same table the user updates
215
+ const re = /(CREATE TABLE\s+(?:IF NOT EXISTS\s+)?["`]?(?:public\.)?(\w*users?\w*|\w*profiles?\w*|\w*accounts?\w*)[\s\S]{0,800}?);|((?:pgTable|model)\s*\(?\s*["'`]?(\w*users?\w*|\w*profiles?\w*)[\s\S]{0,800}?\})/gi;
216
+ let m;
217
+ while ((m = re.exec(f.text))) {
218
+ const block = m[0];
219
+ if (!QUOTA.test(block)) continue;
220
+ out.push(finding('AUTHZ-005', 'AI usage limits stored on a user-editable row', 'critical',
221
+ f.path, lineOf(f.text, m.index),
222
+ 'The credit or quota column lives on the same record the user can update. If they can write to their own row, they can reset their own limit — and this is the exact breach that hit an app in your research.',
223
+ 'Move quota to a table users cannot write to, and enforce the deduction server-side.'));
224
+ break;
225
+ }
226
+ }
227
+ return out;
228
+ }},
229
+
230
+ // ---- no per-user budget at all ------------------------------------------
231
+ { id: 'RATE-004', run(repo, profile) {
232
+ if (!profile.calls_llm || !profile.has_accounts) return [];
233
+ const anyQuota = repo.grep(/\b(credits?|quota|usage_?limit|tokensUsed|tokens_used|rateLimit|ratelimit)\b/i).length;
234
+ if (anyQuota) return [];
235
+ const site = repo.files.find(f => f.text && isCode(f.path) && isServer(f.path) && LLM_CALL.test(f.text));
236
+ if (!site) return [];
237
+ return [finding('RATE-004', 'No per-user limit on AI usage', 'high',
238
+ site.path, lineOf(site.text, site.text.search(LLM_CALL)),
239
+ 'Nothing in the codebase tracks how much any single user spends. One account running a script can generate a bill limited only by your provider account.',
240
+ 'Count tokens or requests per user, store the total, and refuse the call once they pass their allowance.')];
241
+ }},
242
+
243
+ // ---- nothing bounds total spend ------------------------------------------
244
+ { id: 'RATE-003', run(repo, profile) {
245
+ if (!profile.calls_llm || profile.stage !== 'production') return [];
246
+ const cap = repo.grep(/\b(dailyBudget|monthlyBudget|spendLimit|costLimit|budget_?cap|maxSpend|COST_LIMIT|BUDGET)\b/i).length;
247
+ if (cap) return [];
248
+ const site = repo.files.find(f => f.text && isCode(f.path) && LLM_CALL.test(f.text));
249
+ if (!site) return [];
250
+ return [finding('RATE-003', 'No overall spending ceiling', 'critical',
251
+ site.path, lineOf(site.text, site.text.search(LLM_CALL)),
252
+ 'Per-request limits cap one call, not the total. Nothing here stops a bad day from becoming a five-figure invoice — the failure mode behind every horror story in your research.',
253
+ 'Track cumulative spend and stop calling the provider past a daily ceiling. Set a budget alert in the provider console as a backstop.')];
254
+ }},
255
+ ];
@@ -0,0 +1,267 @@
1
+ // Accounts, sessions, and the secrets that protect them.
2
+ const finding = (id, title, severity, file, line, detail, fix) =>
3
+ ({ id, title, severity, file, line, detail, fix });
4
+ const lineOf = (t, i) => t.slice(0, i).split('\n').length;
5
+ const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
6
+
7
+ const RATE_LIMITER =
8
+ /rate-?limit|rateLimit|Ratelimit|throttle|slowDown|@upstash\/ratelimit|express-rate-limit|limiter\.|bottleneck/i;
9
+
10
+ export const AUTH_CHECKS = [
11
+
12
+ // ---- an .env that nothing is stopping from being committed ---------------
13
+ { id: 'SEC-001', run(repo) {
14
+ const envs = repo.files.filter(f => /(^|\/)\.env(\.local|\.production|\.development)?$/.test(f.path));
15
+ if (!envs.length) return [];
16
+
17
+ // Asked per file, against every .gitignore from the git repository root
18
+ // down - not just one at the scan root. Our own api/.env is excluded by a
19
+ // rule one directory up, and reading only the local file reported it as a
20
+ // CRITICAL leak. The whole chain, or the answer is a guess.
21
+ const ignored = repo.isIgnored || (() => false);
22
+
23
+ // A committed .env full of ChangeMe and localhost is a template, not a leak.
24
+ // Only the values decide - flagging a placeholder file as CRITICAL is how a
25
+ // scanner teaches people to ignore it.
26
+ const PLACEHOLDER = /(changeme|your[-_]|xxx|todo|fixme|placeholder|example|dummy|<[^>]+>|^$|localhost|127\.0\.0\.1|\bfoo\b|\bbar\b|test[-_]?value)/i;
27
+ const REAL_SECRET = [
28
+ [/sk-ant-[A-Za-z0-9_-]{20,}/, 'an Anthropic key'],
29
+ [/sk-[A-Za-z0-9]{32,}/, 'an OpenAI key'],
30
+ [/AKIA[0-9A-Z]{16}/, 'an AWS access key'],
31
+ [/ghp_[A-Za-z0-9]{30,}/, 'a GitHub token'],
32
+ [/xox[baprs]-[A-Za-z0-9-]{15,}/, 'a Slack token'],
33
+ [/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/, 'a JWT'],
34
+ [/postgres(?:ql)?:\/\/[^:\s]+:[^@\s]{8,}@(?!localhost|127\.)/, 'a live database URL'],
35
+ [/[A-Za-z0-9+\/]{40,}={0,2}\s*$/m, 'a long random value'],
36
+ ];
37
+
38
+ const out = [];
39
+ for (const f of envs) {
40
+ if (!f.text || ignored(f.path)) continue;
41
+ const hits = [];
42
+ for (const line of f.text.split('\n')) {
43
+ const eq = line.indexOf('=');
44
+ if (line.trimStart().startsWith('#') || eq === -1) continue;
45
+ const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
46
+ if (!value || PLACEHOLDER.test(value)) continue;
47
+ for (const [re, label] of REAL_SECRET)
48
+ if (re.test(value)) { hits.push(label); break; }
49
+ }
50
+ if (!hits.length) continue;
51
+ out.push(finding('SEC-001', `${f.path} holds real credentials and is not gitignored`, 'critical',
52
+ f.path, 1,
53
+ `This file contains ${[...new Set(hits)].join(' and ')}, and nothing in .gitignore excludes it. The next commit publishes it, and deleting it later does not remove it from git history.`,
54
+ 'Add .env* to .gitignore. If it has already been committed, rotate every key in it - treat them as public.'));
55
+ }
56
+ return out;
57
+ }},
58
+
59
+ // ---- Supabase service_role key anywhere near the browser -----------------
60
+ { id: 'SEC-005', run(repo, profile) {
61
+ if (profile.stack?.database !== 'supabase') return [];
62
+ const out = [];
63
+ for (const f of repo.files) {
64
+ if (!f.text) continue;
65
+ const re = /SERVICE_ROLE|service_role/g;
66
+ let m;
67
+ while ((m = re.exec(f.text))) {
68
+ const line = f.text.slice(f.text.lastIndexOf('\n', m.index) + 1,
69
+ f.text.indexOf('\n', m.index));
70
+ const isPublic = /NEXT_PUBLIC_|VITE_|REACT_APP_|PUBLIC_/.test(line);
71
+ const inClient = /\/(components?|pages?)\//.test(f.path) || /['"]use client['"]/.test(f.text.slice(0, 200));
72
+ if (!isPublic && !inClient) continue;
73
+ out.push(finding('SEC-005', 'Supabase service_role key exposed to the browser', 'critical',
74
+ f.path, lineOf(f.text, m.index),
75
+ 'The service_role key bypasses row level security completely. In browser code it hands every visitor unrestricted read and write access to your entire database.',
76
+ 'Use the anon key in the browser. Keep service_role on the server only, and rotate it — assume it is compromised.'));
77
+ break;
78
+ }
79
+ }
80
+ return out;
81
+ }},
82
+
83
+ // ---- login with no attempt limit -----------------------------------------
84
+ { id: 'AUTH-003', run(repo, profile) {
85
+ if (!profile.has_accounts || !profile.is_public) return [];
86
+ if (['clerk', 'auth0', 'supabase-auth'].includes(profile.stack?.auth)) return []; // provider handles it
87
+ const login = repo.files.filter(f => f.text && isCode(f.path) &&
88
+ /(login|signin|sign-in|authenticate)/i.test(f.path) &&
89
+ /\/(api|routes?|server|actions?)\//.test(f.path));
90
+ if (!login.length) return [];
91
+ if (login.some(f => RATE_LIMITER.test(f.text))) return [];
92
+ if (repo.grep(RATE_LIMITER, /middleware\.(ts|js)$/).length) return [];
93
+ const f = login[0];
94
+ return [finding('AUTH-003', 'Login endpoint has no attempt limit', 'high',
95
+ f.path, 1,
96
+ 'Nothing slows down repeated guesses. A script can try millions of passwords against every account you have, and you will not notice.',
97
+ 'Limit attempts per account and per IP — five in fifteen minutes is a common baseline — and lock or delay after that.')];
98
+ }},
99
+
100
+ // ---- password reset that can be used to flood someone --------------------
101
+ { id: 'AUTH-004', run(repo, profile) {
102
+ if (!profile.has_accounts || !profile.sends_email) return [];
103
+ const reset = repo.files.filter(f => f.text && isCode(f.path) &&
104
+ /(reset|forgot).*(password|pass)|password.*(reset|forgot)/i.test(f.path) &&
105
+ /\/(api|routes?|server|actions?)\//.test(f.path));
106
+ if (!reset.length) return [];
107
+ if (reset.some(f => RATE_LIMITER.test(f.text))) return [];
108
+ const f = reset[0];
109
+ return [finding('AUTH-004', 'Password reset endpoint is not rate limited', 'high',
110
+ f.path, 1,
111
+ 'Anyone can trigger unlimited reset emails to any address. That floods your users\' inboxes, burns your email reputation, and costs you per send — the 2am attack pattern from your research.',
112
+ 'Rate limit by email address and by IP, and cap total resets per address per hour.')];
113
+ }},
114
+
115
+ // ---- passwords stored with something that is not a KDF -------------------
116
+ { id: 'AUTH-008', run(repo, profile) {
117
+ if (profile.stack?.auth !== 'custom') return [];
118
+ const out = [];
119
+ for (const f of repo.files) {
120
+ if (!f.text || !isCode(f.path)) continue;
121
+ const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)/g;
122
+ let m;
123
+ while ((m = re.exec(f.text))) {
124
+ const around = f.text.slice(Math.max(0, m.index - 400), m.index + 400);
125
+ if (!/password|passwd|\bpwd\b|credential/i.test(around)) continue;
126
+ out.push(finding('AUTH-008', `Passwords hashed with ${m[1]}`, 'critical',
127
+ f.path, lineOf(f.text, m.index),
128
+ `${m[1]} is built to be fast, which is exactly wrong for passwords — a modern GPU tries billions of guesses per second. If your database leaks, the passwords are recoverable.`,
129
+ 'Use bcrypt, scrypt, or argon2id. They are deliberately slow, which is the point.'));
130
+ }
131
+ }
132
+ return out;
133
+ }},
134
+
135
+ // ---- privileges read from something the user controls --------------------
136
+ { id: 'AUTH-002', run(repo, profile) {
137
+ if (!profile.has_accounts) return [];
138
+ const out = [];
139
+ for (const f of repo.files) {
140
+ if (!f.text || !isCode(f.path)) continue;
141
+ const re = /(?:localStorage|sessionStorage)\.getItem\s*\(\s*['"`][^'"`]*(role|admin|plan|tier|permission|premium|subscri)/gi;
142
+ let m;
143
+ while ((m = re.exec(f.text))) {
144
+ out.push(finding('AUTH-002', 'Permission level read from browser storage', 'critical',
145
+ f.path, lineOf(f.text, m.index),
146
+ 'The user can open developer tools and edit this value. If anything is granted based on it, they grant it to themselves — this is the "change Member to Admin" demo from your research.',
147
+ 'Read the role from the session on the server on every request that depends on it.'));
148
+ }
149
+ }
150
+ return out;
151
+ }},
152
+
153
+ // ---- session cookie missing its protections ------------------------------
154
+ { id: 'NEXT-013', run(repo, profile) {
155
+ if (!profile.has_accounts) return [];
156
+ const out = [];
157
+ for (const f of repo.files) {
158
+ if (!f.text || !isCode(f.path)) continue;
159
+ const re = /cookies\(\)\.set\s*\(|res\.cookie\s*\(|setCookie\s*\(/g;
160
+ let m;
161
+ while ((m = re.exec(f.text))) {
162
+ const call = f.text.slice(m.index, m.index + 400);
163
+ const around = f.text.slice(Math.max(0, m.index - 200), m.index + 400);
164
+ if (!/session|token|jwt|auth/i.test(around)) continue;
165
+ const missing = [];
166
+ if (!/httpOnly\s*:\s*true/i.test(call)) missing.push('httpOnly');
167
+ if (!/secure\s*:\s*true/i.test(call)) missing.push('secure');
168
+ if (!/sameSite/i.test(call)) missing.push('sameSite');
169
+ if (!missing.length) continue;
170
+ out.push(finding('NEXT-013', `Session cookie is missing ${missing.join(', ')}`, 'high',
171
+ f.path, lineOf(f.text, m.index),
172
+ 'Without httpOnly any script on the page can read the session. Without secure it travels over plain HTTP. Without sameSite another site can make requests carrying it.',
173
+ 'Set httpOnly: true, secure: true, sameSite: "lax" on the session cookie.'));
174
+ }
175
+ }
176
+ return out;
177
+ }},
178
+
179
+ // ---- nothing limits anything --------------------------------------------
180
+ { id: 'RATE-001', run(repo, profile) {
181
+ if (!profile.is_public || profile.surface === 'library') return [];
182
+ const hasRoutes = repo.files.some(f => /\/(api|routes?)\//.test(f.path) && isCode(f.path));
183
+ if (!hasRoutes) return [];
184
+ if (repo.grep(RATE_LIMITER).length) return [];
185
+ if (repo.exists('vercel.json') && /rateLimit/i.test(repo.read('vercel.json') || '')) return [];
186
+ const f = repo.files.find(x => /\/(api|routes?)\//.test(x.path) && isCode(x.path));
187
+ return [finding('RATE-001', 'No rate limiting anywhere in the project', 'high',
188
+ f.path, 1,
189
+ 'Every endpoint accepts unlimited requests. One person with a loop can exhaust your database connections, your email quota, or your API credits.',
190
+ 'Add rate limiting middleware covering all routes, with tighter limits on login, signup, password reset, and anything that costs money per call.')];
191
+ }},
192
+
193
+ // ---- a state change behind a GET ----------------------------------------
194
+ { id: 'API-006', run(repo, profile) {
195
+ if (!profile.is_public) return [];
196
+ const out = [];
197
+ for (const f of repo.files) {
198
+ if (!f.text || !isCode(f.path)) continue;
199
+ if (!/\/(api|routes?)\//.test(f.path)) continue;
200
+ const re = /export\s+(?:async\s+)?function\s+GET\s*\([^)]*\)\s*\{/g;
201
+ let m;
202
+ while ((m = re.exec(f.text))) {
203
+ const body = f.text.slice(m.index, m.index + 900);
204
+ if (!/\.(delete|destroy|update|create|insert)\s*\(|DELETE\s+FROM|UPDATE\s+\w+\s+SET/i.test(body)) continue;
205
+ out.push(finding('API-006', 'A GET request changes data', 'high',
206
+ f.path, lineOf(f.text, m.index),
207
+ 'Browsers, link previews and crawlers fetch GET URLs on their own. Anything that changes state behind one can be triggered by a link in a chat message.',
208
+ 'Move the operation to POST, PUT or DELETE, and add CSRF protection.'));
209
+ }
210
+ }
211
+ return out;
212
+ }},
213
+
214
+ // ---- HTTPS not enforced --------------------------------------------------
215
+ // Reported from the field: this fired on a correctly configured Railway deploy
216
+ // whose nginx.conf DOES send Strict-Transport-Security, because repo.grep
217
+ // defaults to source-file extensions and .conf is not one of them - so the
218
+ // evidence was there and simply never read.
219
+ //
220
+ // Adding Railway to a list of known platforms would fix that one deploy and
221
+ // leave the next platform broken. The general signal is better: a server that
222
+ // terminates TLS itself listens on 443. One listening on 8080 is behind
223
+ // something that already did, and cannot redirect what it never receives.
224
+ { id: 'INF-006', run(repo, profile) {
225
+ if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
226
+
227
+ const CONFIGS = /\.(conf|toml|ya?ml|json|ini|cfg)$|\.(ts|tsx|js|jsx|mjs|cjs|py|rb|php|erb)$|(^|\/)(Caddyfile|Procfile|Dockerfile)$/;
228
+ const configText = repo.files
229
+ .filter(f => f.text && CONFIGS.test(f.path)).map(f => f.text).join('\n');
230
+
231
+ // already saying it, in any file type
232
+ if (/Strict-Transport-Security|hsts|forceSSL|force_ssl|SECURE_SSL_REDIRECT|SECURE_HSTS/i.test(configText)) return [];
233
+
234
+ // a managed platform terminates TLS and redirects at its edge
235
+ if (['vercel', 'netlify', 'cloudflare', 'railway', 'fly', 'render', 'heroku', 'deno']
236
+ .includes(profile.stack?.host)) return [];
237
+ if (repo.has(/(^|\/)(railway\.json|vercel\.json|netlify\.toml|fly\.toml|render\.yaml|app\.yaml|Procfile)$/)) return [];
238
+
239
+ // behind a proxy: nothing here listens on 443, so nothing here sees http://
240
+ const listens = [...configText.matchAll(/\blisten\s+(?:\[::\]:)?(\d{2,5})/gi)].map(m => m[1]);
241
+ if (listens.length && !listens.includes('443')) return [];
242
+
243
+ const f = repo.files.find(x => /(next\.config|server\.[tj]s|app\.[tj]s|nginx\.conf|middleware\.[tj]s)/.test(x.path));
244
+ if (!f) return [];
245
+ return [finding('INF-006', 'HTTPS is not enforced', 'high',
246
+ f.path, 1,
247
+ 'Without a redirect and HSTS, a first visit can happen over plain HTTP, where session cookies and form posts are readable by anyone on the network.',
248
+ 'Redirect all HTTP to HTTPS and send Strict-Transport-Security.')];
249
+ }},
250
+
251
+ // ---- security headers ----------------------------------------------------
252
+ { id: 'API-012', run(repo, profile) {
253
+ if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
254
+ const all = repo.files.filter(f => f.text).map(f => f.text).join('\n');
255
+ const missing = [];
256
+ if (!/Content-Security-Policy/i.test(all)) missing.push('Content-Security-Policy');
257
+ if (!/X-Content-Type-Options/i.test(all)) missing.push('X-Content-Type-Options');
258
+ if (!/(X-Frame-Options|frame-ancestors)/i.test(all)) missing.push('X-Frame-Options');
259
+ if (missing.length < 2) return [];
260
+ const f = repo.files.find(x => /(next\.config|middleware\.[tj]s|server\.[tj]s|vercel\.json|netlify\.toml)/.test(x.path));
261
+ if (!f) return [];
262
+ return [finding('API-012', `Security headers missing: ${missing.join(', ')}`, 'medium',
263
+ f.path, 1,
264
+ 'These headers are what stop your pages being framed by a lookalike site, and limit the damage if untrusted content ever reaches the page.',
265
+ 'Set them once in your framework config or middleware — it applies to every response.')];
266
+ }},
267
+ ];