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,322 @@
1
+ // User input concatenated into a SQL query.
2
+ //
3
+ // This was missing from all 288 rules. A project with `${req.params.id}` dropped
4
+ // straight into a query got back "SELECT * on users, MEDIUM" and nothing about
5
+ // the injection on the same line - which is the single most famous way a launch
6
+ // goes wrong, and the one a reader would most expect us to catch.
7
+ //
8
+ // It is also the check most likely to cry wolf, because half the SQL in a modern
9
+ // codebase is written with tagged templates that look identical to the dangerous
10
+ // form and are completely safe. So the discipline that removed 166 false alarms
11
+ // applies in full: read the value, judge the line, and require evidence the
12
+ // thing is used the dangerous way. Three separate facts must all hold:
13
+ //
14
+ // 1. the string is actually SQL - a verb AND a clause, not the word "select"
15
+ // 2. something is interpolated into it - not a constant string
16
+ // 3. that something came from a request - not a literal, not a column name
17
+ //
18
+ // And then the safe forms are subtracted, because every one of them would
19
+ // otherwise fire on correct code.
20
+
21
+ const finding = (id, title, severity, file, line, detail, fix) =>
22
+ ({ id, title, severity, file, line, detail, fix });
23
+ const lineOf = (t, i) => t.slice(0, i).split('\n').length;
24
+
25
+ // Comments blanked to spaces; offsets and line numbers survive, the words do
26
+ // not. A comment reading "never do `SELECT * FROM users WHERE id = ${id}`" is
27
+ // advice against the bug, not the bug.
28
+ const codeOnly = (t) => {
29
+ let out = '', i = 0;
30
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
31
+ while (i < t.length) {
32
+ const two = t.slice(i, i + 2);
33
+ if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
34
+ if (two === '/*') { const e = t.indexOf('*/', i + 2); const j = e === -1 ? t.length : e + 2; out += blank(t.slice(i, j)); i = j; continue; }
35
+ if (t[i] === '#' && /(^|\n)[ \t]*$/.test(out.slice(-40))) { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
36
+ out += t[i]; i++;
37
+ }
38
+ return out;
39
+ };
40
+
41
+ // A verb and a clause. "select" alone matches a React prop, a CSS selector, a
42
+ // variable called selectedUser and about forty other innocent things.
43
+ const IS_SQL = /\b(select|insert\s+into|update|delete\s+from|replace\s+into)\b[\s\S]{0,300}?\b(from|into|where|set|values|join)\b/i;
44
+
45
+ // The expression must trace to something the caller sent. A table name pulled
46
+ // from a constant is a different (smaller) problem and not this finding.
47
+ //
48
+ // Every entry here is ROOTED - req.query, not query. The first version accepted
49
+ // a bare `query.`, `params.` and `body.` and immediately flagged Sequelize's own
50
+ //
51
+ // query.query = `SELECT * FROM FINAL TABLE (${query.query})`
52
+ //
53
+ // as a critical injection, because `query.query` matched. That is the twelfth
54
+ // time this codebase has matched a NAME where it needed a CONTEXT, and on a
55
+ // CRITICAL rule it is the one that costs the most: a reader cannot tell a
56
+ // wrong finding from an irrelevant one, and stops believing the other 288.
57
+ const FROM_REQUEST = new RegExp([
58
+ 'req\\.(params|query|body|headers|cookies)',
59
+ 'request\\.(params|query|body|args|form|json|GET|POST|values|data)',
60
+ '\\bctx\\.(request|params|query)',
61
+ 'searchParams\\.get',
62
+ '\\$_(GET|POST|REQUEST|COOKIE)\\b',
63
+ 'event\\.(queryStringParameters|pathParameters|body)',
64
+ '\\bgetQuery\\(|\\breadBody\\(',
65
+ '\\bformData\\.get',
66
+ ].join('|'));
67
+
68
+ // Bare `params.id` IS the request in a Next.js, Remix or SvelteKit route
69
+ // handler - and is just a variable anywhere else. So it counts as evidence
70
+ // only in a file that is a route handler, which is a fact about the file
71
+ // rather than a guess about the name.
72
+ const BARE_PARAMS = /\bparams\.[A-Za-z_$]|\bsearchParams\.[A-Za-z_$]/;
73
+ const IS_ROUTE_FILE = (path, code) =>
74
+ /(^|\/)(app|pages|src\/app|src\/pages)\/.*\/(route|page)\.(ts|tsx|js|jsx)$/.test(path) ||
75
+ /(^|\/)pages\/api\//.test(path) ||
76
+ /(^|\/)routes?\//.test(path) ||
77
+ /\+server\.(ts|js)$/.test(path) ||
78
+ /\bexport\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/.test(code) ||
79
+ /\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=/.test(code);
80
+
81
+ // One hop of data flow, because one hop is where the bug actually lives.
82
+ //
83
+ // const { id } = req.params;
84
+ // db.query(`select * from users where id = ${id}`)
85
+ //
86
+ // is far more common than interpolating req.params.id directly, and a check
87
+ // that only saw the direct form would miss most real instances while claiming
88
+ // to cover this. So: collect the names a file binds FROM a request, then treat
89
+ // those names as the request.
90
+ //
91
+ // Scope is ignored on purpose. Tracking it properly needs a parser, and a file
92
+ // that reads req.params.id into `id` and then pastes `id` into SQL is not
93
+ // meaningfully ambiguous. What this must not do is taint a name bound from
94
+ // anything else, which is why only these forms count.
95
+ const TAINT_SOURCES = [
96
+ // const { a, b } = req.params / req.query / req.body / await req.json()
97
+ /(?:const|let|var)\s*\{([^}]{1,200})\}\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|json\(\)|formData\(\))/g,
98
+ // const { id } = await params (Next.js 15 route handlers)
99
+ /(?:const|let|var)\s*\{([^}]{1,200})\}\s*=\s*await\s+(?:params|searchParams)\b/g,
100
+ // const x = req.query.y / req.params.y / req.body.y
101
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|headers|cookies)\b/g,
102
+ // const x = searchParams.get('y') / url.searchParams.get('y') / formData.get('y')
103
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[\w.$]*(?:searchParams|formData)\.get\s*\(/g,
104
+ // python: x = request.args.get('y') / request.form['y'] / request.json['y']
105
+ /([A-Za-z_][\w]*)\s*=\s*request\.(?:args|form|json|values|data|GET|POST)\b/g,
106
+ // php: $x = $_GET['y']
107
+ /\$([A-Za-z_]\w*)\s*=\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/g,
108
+ ];
109
+
110
+ function taintedNames(code) {
111
+ const names = new Set();
112
+ for (const re of TAINT_SOURCES) {
113
+ re.lastIndex = 0;
114
+ let m;
115
+ while ((m = re.exec(code))) {
116
+ for (const part of m[1].split(',')) {
117
+ // { id } and { id: userId } and { id = 1 } all bind the LAST name
118
+ const n = part.split(':').pop().split('=')[0].trim().replace(/^\.\.\./, '');
119
+ if (/^[A-Za-z_$][\w$]*$/.test(n)) names.add(n);
120
+ }
121
+ }
122
+ }
123
+ // Names so generic that binding one would taint half the file's arithmetic.
124
+ for (const junk of ['data', 'body', 'params', 'query', 'req', 'request', 'options', 'props']) names.delete(junk);
125
+ return names;
126
+ }
127
+
128
+ const usesTainted = (expr, names) => {
129
+ if (!names.size) return false;
130
+ for (const m of expr.matchAll(/[A-Za-z_$][\w$]*/g)) if (names.has(m[0])) return true;
131
+ return false;
132
+ };
133
+
134
+ // Wrapped in a numeric coercion it cannot carry a quote, a comment marker or a
135
+ // semicolon. Flagging it would be flagging arithmetic.
136
+ const COERCED = /^\s*(Number|parseInt|parseFloat|int|float|Integer\.parseInt|to_i|intval)\s*\(|^\s*\+\s*[A-Za-z_$]/;
137
+
138
+ // Tagged templates that parameterise. These look EXACTLY like the dangerous
139
+ // form and are the reason a naive version of this check would be unusable:
140
+ // prisma.$queryRaw`...${id}...` is safe, prisma.$queryRawUnsafe(`...${id}...`)
141
+ // is not, and the difference is six characters.
142
+ const SAFE_TAG = /(^|[^\w$])(sql|SQL|sqlx?|prisma\.\$queryRaw|prisma\.\$executeRaw|\$queryRaw|\$executeRaw|db\.sql|tx\.sql|conn\.sql|knex\.raw|Prisma\.sql|drizzle|postgres|neon|planetscale)$/;
143
+
144
+ // Where a template literal ends, honouring escapes and nested ${ } which may
145
+ // themselves contain template literals.
146
+ function endOfTemplate(t, start) {
147
+ let i = start + 1;
148
+ while (i < t.length) {
149
+ const c = t[i];
150
+ if (c === '\\') { i += 2; continue; }
151
+ if (c === '`') return i;
152
+ if (c === '$' && t[i + 1] === '{') {
153
+ let depth = 1; i += 2;
154
+ while (i < t.length && depth > 0) {
155
+ if (t[i] === '\\') { i += 2; continue; }
156
+ if (t[i] === '`') { const e = endOfTemplate(t, i); i = e === -1 ? t.length : e + 1; continue; }
157
+ if (t[i] === '{') depth++;
158
+ else if (t[i] === '}') depth--;
159
+ i++;
160
+ }
161
+ continue;
162
+ }
163
+ i++;
164
+ }
165
+ return -1;
166
+ }
167
+
168
+ // The ${ ... } expressions inside one template literal, as text.
169
+ function holes(tpl) {
170
+ const out = [];
171
+ for (let i = 0; i < tpl.length; i++) {
172
+ if (tpl[i] === '\\') { i++; continue; }
173
+ if (tpl[i] === '$' && tpl[i + 1] === '{') {
174
+ let depth = 1, j = i + 2; const from = j;
175
+ while (j < tpl.length && depth > 0) {
176
+ if (tpl[j] === '\\') { j += 2; continue; }
177
+ if (tpl[j] === '{') depth++;
178
+ else if (tpl[j] === '}') depth--;
179
+ if (depth > 0) j++;
180
+ }
181
+ out.push(tpl.slice(from, j));
182
+ i = j;
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ const JS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
189
+ const PY = /\.py$/;
190
+ const PHP = /\.php$/;
191
+ const RB = /\.rb$/;
192
+
193
+ function scanJs(path, code, out) {
194
+ const routeFile = IS_ROUTE_FILE(path, code);
195
+ const tainted = taintedNames(code);
196
+ const isInput = (h) => FROM_REQUEST.test(h)
197
+ || (routeFile && BARE_PARAMS.test(h))
198
+ || usesTainted(h, tainted);
199
+ for (let i = 0; i < code.length; i++) {
200
+ if (code[i] !== '`') continue;
201
+ const end = endOfTemplate(code, i);
202
+ if (end === -1) break;
203
+ const tpl = code.slice(i + 1, end);
204
+ const before = code.slice(Math.max(0, i - 60), i).trimEnd();
205
+ i = end;
206
+
207
+ if (!IS_SQL.test(tpl)) continue;
208
+ if (SAFE_TAG.test(before)) continue; // parameterised by the tag
209
+
210
+ const bad = holes(tpl).filter(h => isInput(h) && !COERCED.test(h));
211
+ if (!bad.length) continue;
212
+ out.push({ line: lineOf(code, i - tpl.length), expr: bad[0].trim(), how: 'a template literal' });
213
+ }
214
+
215
+ // "select ... where id = " + req.params.id
216
+ const CONCAT = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\+\s*([^;\n]{1,120})/g;
217
+ let m;
218
+ while ((m = CONCAT.exec(code))) {
219
+ if (!IS_SQL.test(m[2])) continue;
220
+ const rhs = m[3];
221
+ if (!isInput(rhs) || COERCED.test(rhs)) continue;
222
+ out.push({ line: lineOf(code, m.index), expr: rhs.trim().slice(0, 60), how: 'string concatenation' });
223
+ }
224
+ }
225
+
226
+ function scanPy(path, code, out) {
227
+ const tainted = taintedNames(code);
228
+ const isInput = (h) => FROM_REQUEST.test(h) || usesTainted(h, tainted);
229
+ // f"... {req.args['id']} ..." -- the f prefix is what makes it interpolate
230
+ const FSTR = /\bf(["'])((?:(?!\1)[^\\]|\\.)*)\1|\bf("""|''')([\s\S]*?)\3/g;
231
+ let m;
232
+ while ((m = FSTR.exec(code))) {
233
+ const body = m[2] ?? m[4] ?? '';
234
+ if (!IS_SQL.test(body)) continue;
235
+ const bad = [...body.matchAll(/\{([^{}]+)\}/g)].map(x => x[1])
236
+ .filter(h => isInput(h) && !COERCED.test(h));
237
+ if (!bad.length) continue;
238
+ out.push({ line: lineOf(code, m.index), expr: bad[0].trim(), how: 'an f-string' });
239
+ }
240
+
241
+ // "... %s ..." % x and "...".format(x)
242
+ //
243
+ // The % HAS to be applied to the string. cursor.execute("... %s", (x,)) is
244
+ // the correct parameterised call and looks almost the same - the difference
245
+ // is a comma instead of a percent sign, and it is the whole difference.
246
+ const APPLIED = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*(?:%\s*|\.format\s*\()([^\n;]{1,120})/g;
247
+ while ((m = APPLIED.exec(code))) {
248
+ if (!IS_SQL.test(m[2])) continue;
249
+ if (!isInput(m[3]) || COERCED.test(m[3])) continue;
250
+ out.push({ line: lineOf(code, m.index), expr: m[3].trim().slice(0, 60), how: 'string formatting' });
251
+ }
252
+ }
253
+
254
+ function scanPhp(path, code, out) {
255
+ const tainted = taintedNames(code);
256
+ const isInput = (h) => FROM_REQUEST.test(h) || usesTainted(h, tainted);
257
+ const STR = /"((?:[^"\\]|\\.)*)"/g;
258
+ let m;
259
+ while ((m = STR.exec(code))) {
260
+ const body = m[1];
261
+ if (!IS_SQL.test(body)) continue;
262
+ const bad = [...body.matchAll(/\{?\$([A-Za-z_]\w*(?:\[[^\]]+\])?(?:->\w+)?)\}?/g)].map(x => '$' + x[1])
263
+ .filter(h => isInput(h));
264
+ if (bad.length) { out.push({ line: lineOf(code, m.index), expr: bad[0], how: 'string interpolation' }); continue; }
265
+ }
266
+ const DOT = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\.\s*([^;\n]{1,120})/g;
267
+ while ((m = DOT.exec(code))) {
268
+ if (!IS_SQL.test(m[2])) continue;
269
+ if (!isInput(m[3])) continue;
270
+ out.push({ line: lineOf(code, m.index), expr: m[3].trim().slice(0, 60), how: 'string concatenation' });
271
+ }
272
+ }
273
+
274
+ function scanRb(path, code, out) {
275
+ const STR = /"((?:[^"\\]|\\.)*)"/g;
276
+ let m;
277
+ while ((m = STR.exec(code))) {
278
+ const body = m[1];
279
+ if (!IS_SQL.test(body)) continue;
280
+ const bad = [...body.matchAll(/#\{([^}]+)\}/g)].map(x => x[1])
281
+ .filter(h => FROM_REQUEST.test(h) && !COERCED.test(h));
282
+ if (!bad.length) continue;
283
+ out.push({ line: lineOf(code, m.index), expr: bad[0].trim(), how: 'string interpolation' });
284
+ }
285
+ }
286
+
287
+ export const INJECTION_CHECKS = [
288
+
289
+ { id: 'DATA-015', run(repo) {
290
+ const out = [];
291
+ for (const f of repo.files) {
292
+ if (!f.text) continue;
293
+ const isJs = JS.test(f.path), isPy = PY.test(f.path),
294
+ isPhp = PHP.test(f.path), isRb = RB.test(f.path);
295
+ if (!isJs && !isPy && !isPhp && !isRb) continue;
296
+ // A migration writes its own SQL and takes no request input; a seed file
297
+ // is the same. Both are full of raw SQL and neither is reachable.
298
+ if (/(^|\/)(migrations?|seeds?|db\/migrate)\//.test(f.path)) continue;
299
+
300
+ const code = codeOnly(f.text);
301
+ const hits = [];
302
+ if (isJs) scanJs(f.path, code, hits);
303
+ if (isPy) scanPy(f.path, code, hits);
304
+ if (isPhp) scanPhp(f.path, code, hits);
305
+ if (isRb) scanRb(f.path, code, hits);
306
+
307
+ // One finding per file. Somebody who builds queries this way builds them
308
+ // this way in twenty places, and twenty identical CRITICALs is not twenty
309
+ // times the information - it is one piece of information, shouted.
310
+ if (!hits.length) continue;
311
+ const h = hits[0];
312
+ const more = hits.length > 1 ? ` The same pattern appears ${hits.length} times in this file.` : '';
313
+ out.push(finding('DATA-015',
314
+ 'Anyone can read or delete your whole database by typing into a form', 'critical',
315
+ f.path, h.line,
316
+ `A value that came straight from the visitor is pasted into a database query through ${h.how} — here it is \`${h.expr}\`. Whatever they type is not treated as data, it is treated as part of the instruction. Typing the right thing into that field reads every table, changes any row, or deletes the lot, and nothing in the code stops it.${more}`,
317
+ 'Never build the query by joining strings. Leave a placeholder where the value goes and pass the value alongside it — `db.query("select * from users where id = $1", [id])` — so the database treats it as data no matter what it contains.'));
318
+ }
319
+ return out;
320
+ }},
321
+
322
+ ];
package/src/checks.mjs ADDED
@@ -0,0 +1,221 @@
1
+ // Tier-1 checks: deterministic, no LLM, no network. Each returns findings with
2
+ // a file:line so the user can go straight to it.
3
+ import { AUTHZ_CHECKS } from './checks-authz.mjs';
4
+ import { INJECTION_CHECKS } from './checks-injection.mjs';
5
+ import { AI_CHECKS } from './checks-ai.mjs';
6
+ import { DEPLOY_CHECKS } from './checks-deploy.mjs';
7
+ import { AUTH_CHECKS } from './checks-auth.mjs';
8
+ import { FRAMEWORK_CHECKS } from './checks-frameworks.mjs';
9
+ import { BATCH2_CHECKS } from './checks-batch2.mjs';
10
+ import { BATCH3_CHECKS } from './checks-batch3.mjs';
11
+ import { BATCH4_CHECKS } from './checks-batch4.mjs';
12
+
13
+ const finding = (id, title, severity, file, line, detail, fix) =>
14
+ ({ id, title, severity, file, line, detail, fix });
15
+
16
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
17
+
18
+ const BASE_CHECKS = [
19
+
20
+ { id:'SEC-002', run(repo){
21
+ const gi = repo.read('.gitignore');
22
+ if (gi === null) return [];
23
+ if (/^\s*\.env/m.test(gi)) return [];
24
+ return [finding('SEC-002','.env is not in .gitignore','high','.gitignore',1,
25
+ 'Nothing stops a future commit from publishing your secrets.',
26
+ 'Add a line containing .env* to .gitignore')];
27
+ }},
28
+
29
+ { id:'SEC-004', run(repo){
30
+ // Many NEXT_PUBLIC_* keys are public BY DESIGN. Stripe's publishable key and
31
+ // Supabase's anon key belong in the browser; flagging them as CRITICAL is the
32
+ // fastest way to lose a user's trust in every other finding.
33
+ const PUBLIC_BY_DESIGN = /(PUBLISHABLE|ANON_KEY|PUBLIC_KEY|SITE_KEY|VAPID|POSTHOG|SENTRY_DSN|MEASUREMENT_ID|^(GA|GTM)_|TURNSTILE|RECAPTCHA_SITE|HCAPTCHA|MAPBOX|GOOGLE_MAPS|ALGOLIA_SEARCH|AMPLITUDE|MIXPANEL_TOKEN)/;
34
+ // names that are settings, not credentials
35
+ const NOT_A_CREDENTIAL = /(ENABLE|DISABLE|REQUIRE|ALLOW|SHOW|USE)_/;
36
+ // unambiguous: these must never be public
37
+ const DEFINITELY_SECRET = /(SECRET|SERVICE_ROLE|PRIVATE_KEY|_SK_|OPENAI|ANTHROPIC|CLAUDE|GROQ|SENDGRID|RESEND|TWILIO|AWS_SECRET|DATABASE_URL|CONNECTION_STRING)/;
38
+
39
+ const out=[];
40
+ for (const f of repo.files){
41
+ if (!f.text || !/\.(ts|tsx|js|jsx|mjs|env)$/.test(f.path)) continue;
42
+ if (/\.(example|sample|template)$/i.test(f.path)) continue;
43
+ const re=/\b(NEXT_PUBLIC_|VITE_|REACT_APP_|PUBLIC_)([A-Z0-9_]*(KEY|SECRET|TOKEN|PASSWORD)[A-Z0-9_]*)/g;
44
+ let m;
45
+ while ((m=re.exec(f.text))){
46
+ const full = m[1]+m[2];
47
+ if (PUBLIC_BY_DESIGN.test(m[2]) || NOT_A_CREDENTIAL.test(m[2])) continue;
48
+
49
+ const certain = DEFINITELY_SECRET.test(m[2]);
50
+ out.push(finding('SEC-004',
51
+ certain ? 'Server secret exposed to the browser' : 'Possible secret exposed to the browser',
52
+ certain ? 'critical' : 'medium',
53
+ f.path, lineOf(f.text,m.index),
54
+ certain
55
+ ? `${full} — the ${m[1]} prefix ships this value to every visitor's browser, and this is a credential that must stay on your server.`
56
+ : `${full} — the ${m[1]} prefix makes this readable by anyone who opens your site. If it is a provider's publishable key that is fine; if it grants write access it is not.`,
57
+ certain
58
+ ? 'Remove the public prefix, read it only on the server, and rotate the key.'
59
+ : 'Confirm this key is meant to be public. If it is not, drop the prefix and rotate it.'));
60
+ }
61
+ }
62
+ return out;
63
+ }},
64
+
65
+ { id:'SEC-003', run(repo){
66
+ const out=[];
67
+ const PATTERNS=[
68
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}/g,'Anthropic API key'],
69
+ [/\bsk-[A-Za-z0-9]{32,}/g,'OpenAI API key'],
70
+ [/\bAKIA[0-9A-Z]{16}\b/g,'AWS access key'],
71
+ [/\bghp_[A-Za-z0-9]{30,}/g,'GitHub token'],
72
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,'Slack token'],
73
+ ];
74
+ for (const f of repo.files){
75
+ // placeholder/sample env files are meant to be committed - never a secret finding
76
+ if (!f.text || /\.(md|lock)$/.test(f.path)) continue;
77
+ if (/\.(example|sample|template|dist)$/i.test(f.path) ||
78
+ /\.env\.(example|sample|template)/i.test(f.path)) continue;
79
+ // A key in a .env file is not hardcoded - .env IS the environment
80
+ // variable, and telling someone to "move it to an environment variable"
81
+ // is advice to do the thing they already did. Whether that file is safe
82
+ // is a question about .gitignore, and SEC-001 is the check that knows
83
+ // how to ask it.
84
+ if (/(^|\/)\.env(\.[A-Za-z0-9_-]+)?$/.test(f.path)) continue;
85
+ for (const [re,label] of PATTERNS){
86
+ let m; re.lastIndex=0;
87
+ while ((m=re.exec(f.text))){
88
+ out.push(finding('SEC-003',`Hardcoded ${label}`,'critical',
89
+ f.path, lineOf(f.text,m.index),
90
+ `A live ${label} is written directly into this file.`,
91
+ 'Move it to an environment variable and rotate the key — it must be treated as leaked.'));
92
+ }
93
+ }
94
+ }
95
+ return out;
96
+ }},
97
+
98
+ { id:'AUTH-001', run(repo,profile){
99
+ if (!profile.has_accounts) return [];
100
+ const out=[];
101
+ for (const f of repo.files){
102
+ if (!f.text || !/\.(ts|tsx|js|jsx)$/.test(f.path)) continue;
103
+ const re=/(localStorage|sessionStorage)\.setItem\(\s*['"`][^'"`]*(token|jwt|auth|session)/gi;
104
+ let m;
105
+ while ((m=re.exec(f.text))){
106
+ out.push(finding('AUTH-001','Session token stored in browser storage','high',
107
+ f.path, lineOf(f.text,m.index),
108
+ 'Anything in localStorage is readable by any script on the page, so one XSS steals the login.',
109
+ 'Store the session in an httpOnly, Secure, SameSite cookie instead.'));
110
+ }
111
+ }
112
+ return out;
113
+ }},
114
+
115
+ { id:'AI-003', run(repo,profile){
116
+ if (!profile.calls_llm) return [];
117
+ const out=[];
118
+ for (const f of repo.files){
119
+ if (!f.text || !/\.(ts|tsx|js|mjs|py)$/.test(f.path)) continue;
120
+ const re=/\.messages\.create\s*\(|\.chat\.completions\.create\s*\(/g;
121
+ let m;
122
+ while ((m=re.exec(f.text))){
123
+ const window_=f.text.slice(m.index, m.index+600);
124
+ if (/max_tokens|maxTokens|max_output_tokens/.test(window_)) continue;
125
+ out.push(finding('AI-003','AI call with no token ceiling','high',
126
+ f.path, lineOf(f.text,m.index),
127
+ 'Without max_tokens a single request can generate — and bill — without limit.',
128
+ 'Set max_tokens on every completion call.'));
129
+ }
130
+ }
131
+ return out;
132
+ }},
133
+
134
+ { id:'DEP-010', run(repo){
135
+ if (!repo.exists('package.json')) return [];
136
+ const has = ['package-lock.json','pnpm-lock.yaml','yarn.lock','bun.lockb'].some(l=>repo.exists(l));
137
+ return has ? [] : [finding('DEP-010','No lockfile committed','medium','package.json',1,
138
+ 'Without a lockfile your production build can install different versions than you tested.',
139
+ 'Commit the lockfile your package manager generates.')];
140
+ }},
141
+
142
+ { id:'API-002', run(repo,profile){
143
+ if (!profile.is_public) return [];
144
+ const out=[];
145
+ for (const f of repo.files){
146
+ if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
147
+ // \b around the backreference, and it is not cosmetic. The catch
148
+ // variable is usually `e`, and without boundaries that `e` matched the
149
+ // one inside the word "error" - so `res.json({ error: 'internal', ref })`,
150
+ // which is the CORRECT handling, was reported as leaking internals.
151
+ // Found by writing the test pair this check shipped without.
152
+ const re=/catch\s*\(\s*(\w+)\s*\)\s*\{[^}]{0,200}?(?:json|send)\s*\(\s*\{[^}]{0,120}?\b\1\b(?:\.message|\.stack)?/gs;
153
+ let m;
154
+ while ((m=re.exec(f.text))){
155
+ out.push(finding('API-002','Internal error details returned to the client','high',
156
+ f.path, lineOf(f.text,m.index),
157
+ 'Raw error objects leak file paths, query fragments and library versions to anyone who can trigger them.',
158
+ 'Log the real error server-side; return a generic message and a reference id.'));
159
+ }
160
+ }
161
+ return out;
162
+ }},
163
+
164
+ { id:'UP-002', run(repo,profile){
165
+ if (!profile.has_file_uploads) return [];
166
+ const hasLimit = repo.grep(/limits\s*:\s*\{[^}]*fileSize|maxFileSize|MAX_FILE_SIZE/).length;
167
+ if (hasLimit) return [];
168
+ const site = repo.grep(/multer\s*\(|formidable\s*\(|\.upload\s*\(/)[0];
169
+ return site ? [finding('UP-002','File uploads have no size limit','high',
170
+ site.path, 1,
171
+ 'One user can upload a file large enough to fill your disk or exhaust memory.',
172
+ 'Set an explicit maximum file size on the upload handler.')] : [];
173
+ }},
174
+ ];
175
+
176
+ // AI checks supersede the earlier inline AI-003
177
+ const BASE = BASE_CHECKS.filter(c => c.id !== 'AI-003');
178
+ // the fuller AUTH_CHECKS version supersedes the early inline SEC-001
179
+ const B2 = BASE.filter(c => c.id !== 'SEC-001');
180
+ export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS, ...INJECTION_CHECKS];
181
+
182
+ // Test and fixture files are not deployed. A "vulnerability" in a spec file is
183
+ // noise, and noise is what makes people stop reading findings.
184
+ const IS_TEST = /(^|\/)(tests?|__tests__|__mocks__|spec|e2e|fixtures?|examples?)\/|\.(test|spec)\.[jt]sx?$|\.stories\.[jt]sx?$/;
185
+
186
+ function withoutTests(repo){
187
+ const files = repo.files.filter(f => !IS_TEST.test(f.path));
188
+ return { ...repo, files,
189
+ has: (re) => files.some(f => re.test(f.path)),
190
+ find: (re) => files.filter(f => re.test(f.path)),
191
+ grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|sql|prisma)$/) =>
192
+ files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
193
+ };
194
+ }
195
+
196
+ // Some facts live at the repo root and nowhere else - the lockfile, the CI
197
+ // config, .gitignore. In a monorepo every workspace correctly lacks them, so a
198
+ // check that looks for one from inside apps/web fires on every package and is
199
+ // wrong every time. Those declare scope:'root' and run once, against the root.
200
+ export function runRootChecks(repo, profile, applicableIds){
201
+ repo = withoutTests(repo);
202
+ const out=[];
203
+ for (const c of CHECKS){
204
+ if (c.scope !== 'root') continue;
205
+ if (applicableIds && !applicableIds.has(c.id)) continue;
206
+ try { out.push(...c.run(repo, profile)); } catch {}
207
+ }
208
+ return out;
209
+ }
210
+
211
+ export function runChecks(repo, profile, applicableIds){
212
+ repo = withoutTests(repo);
213
+ const out=[];
214
+ for (const c of CHECKS){
215
+ if (c.scope === 'root') continue; // runRootChecks owns these
216
+ if (applicableIds && !applicableIds.has(c.id)) continue;
217
+ try { out.push(...c.run(repo, profile)); } catch {}
218
+ }
219
+ const rank={critical:0,high:1,medium:2,low:3};
220
+ return out.sort((a,b)=>rank[a.severity]-rank[b.severity]);
221
+ }