launchprep 0.1.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,31 @@
1
+ Launchprep Licence
2
+
3
+ Copyright (c) 2026 Bc. Bektur Aibekov, IČO 29522471, Prague, Czech Republic.
4
+ All rights reserved.
5
+
6
+ You may:
7
+
8
+ 1. Install and run this software to scan source code that you own or are
9
+ authorised to scan.
10
+ 2. Make copies as reasonably necessary for that use (for example, inside a
11
+ CI pipeline or a container image you operate).
12
+
13
+ You may not:
14
+
15
+ 3. Copy, redistribute, sublicense, sell, or publish this software or any
16
+ part of it, including the rule and check definitions it contains.
17
+ 4. Extract, reproduce, or adapt the rule and check definitions for use in
18
+ any other product or service, commercial or not.
19
+ 5. Remove or alter this notice.
20
+
21
+ Versions 0.1.0 and 0.2.0 of the "launchprep" npm package were published under
22
+ the MIT licence; that grant stands for those versions. This licence applies to
23
+ this version and all later ones.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR
27
+ OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE. Findings are produced
28
+ by automated analysis and may be incomplete or wrong; they are information,
29
+ not a guarantee of security or fitness for launch.
30
+
31
+ Questions and permissions: hello@launchprep.dev
package/README.md CHANGED
@@ -13,7 +13,7 @@ No signup. No dashboard. Nothing added to your repo.
13
13
 
14
14
  ## What it does
15
15
 
16
- 288 checks — but it works out what you built **before** running any of them, so
16
+ 289 checks — but it works out what you built **before** running any of them, so
17
17
  you are not told about problems you cannot have. A static site is asked 48
18
18
  questions; a multi-tenant SaaS with payments and AI is asked 158.
19
19
 
@@ -69,6 +69,19 @@ 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
+ ## In CI
73
+
74
+ ```bash
75
+ npx launchprep . --fail-on critical # exit 1 if a critical is found
76
+ npx launchprep deep . --yes --fail-on high # the paid scan as a gate
77
+ ```
78
+
79
+ Without `--fail-on` the exit code stays 0 — a report, not a verdict. With it,
80
+ findings at or above the threshold (`critical`, `high`, `medium`, `low`, `any`)
81
+ fail the build. On a deep scan, checks that did not complete fail it too — a
82
+ check that could not run has not passed. A path that does not exist always
83
+ exits 2: a scan of nothing must never look like a clean scan of something.
84
+
72
85
  ## Free and paid
73
86
 
74
87
  Everything above is free, unlimited, forever — every check a program can make on
@@ -81,4 +94,6 @@ delete. See <https://launchprep.dev>.
81
94
 
82
95
  ## Licence
83
96
 
84
- MIT
97
+ Proprietary — see the LICENSE file. Run it on anything you are authorised to
98
+ scan; the rules and checks may not be copied into other products. Versions
99
+ 0.1.0 and 0.2.0 were published under MIT and remain so.
@@ -15,10 +15,24 @@ const argv = process.argv.slice(2);
15
15
  const cmd = argv[0];
16
16
  const KNOWN = new Set(['login', 'logout', 'deep', 'whoami', 'help', '--help', '-h']);
17
17
 
18
- if (!KNOWN.has(cmd)) {
19
- // free scan - src/ only, nothing from net/ is loaded at all
20
- await import('../src/index.mjs');
21
- } else {
22
- const { run } = await import('../net/commands.mjs');
23
- await run(cmd, argv.slice(1));
18
+ // Anything that reaches here uncaught would otherwise print a Node stack
19
+ // trace at someone who paid $29 and cannot read one. A network that is down
20
+ // is not a bug in their project, and it should not look like one.
21
+ try {
22
+ if (!KNOWN.has(cmd)) {
23
+ // free scan - src/ only, nothing from net/ is loaded at all
24
+ await import('../src/index.mjs');
25
+ } else {
26
+ const { run } = await import('../net/commands.mjs');
27
+ await run(cmd, argv.slice(1));
28
+ }
29
+ } catch (e) {
30
+ const msg = e?.message || String(e);
31
+ process.stderr.write(`\n \x1b[31m${msg}\x1b[0m\n`);
32
+ if (/could not reach|did not answer/i.test(msg)) {
33
+ process.stderr.write(` \x1b[2mThe free checks do not need the network. Run \x1b[0mnpx ${BRAND.slug}\x1b[2m on its own.\x1b[0m\n`);
34
+ process.stderr.write(` \x1b[2mIf this keeps happening, mail ${BRAND.email} — your scans are not spent unless a scan runs.\x1b[0m\n`);
35
+ }
36
+ process.stderr.write('\n');
37
+ process.exit(1);
24
38
  }
package/net/client.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Talking to launchprep.dev. The only file in the CLI that opens a connection.
2
- const BASE = process.env.LAUNCHPREP_API || 'https://api.launchprep.dev';
2
+ import { BRAND } from '../src/brand.mjs';
3
+ const BASE = process.env.LAUNCHPREP_API || BRAND.api;
3
4
 
4
5
  async function call(path, { key, body, method = 'POST' } = {}) {
5
6
  let res;
package/net/commands.mjs CHANGED
@@ -32,6 +32,7 @@ async function login(args) {
32
32
  if (!/^lp_/.test(key)) { console.error(`\n ${C.red}That does not look like a licence key.${C.off} They start with lp_\n`); process.exit(1); }
33
33
 
34
34
  const r = await validate(key);
35
+ if (r.status === 429) { console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}.${C.off}\n`); process.exit(1); }
35
36
  if (r.status === 401) { console.error(`\n ${C.red}That key is not recognised.${C.off}\n`); process.exit(1); }
36
37
  if (r.status === 403) { console.error(`\n ${C.red}That key has been revoked.${C.off}\n`); process.exit(1); }
37
38
  if (!r.ok) { console.error(`\n ${C.red}Could not check the key${C.off} ${C.d}(${r.status})${C.off}\n`); process.exit(1); }
@@ -94,6 +95,16 @@ async function deep(args) {
94
95
  const r = await deepScan({ key: c.key, digest, profile,
95
96
  ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
96
97
 
98
+ if (r.status === 429) {
99
+ console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}. No scan was used.${C.off}\n`);
100
+ process.exit(1);
101
+ }
102
+ if (r.status === 402 && (r.data?.error === 'attempt_ceiling' || r.data?.error === 'spend_ceiling')) {
103
+ console.error(`\n ${C.red}This key has run too many scans that did not finish.${C.off}`);
104
+ console.error(` ${C.d}${r.data.attempts} attempts against a ${r.data.limit}-scan licence. Something is going`);
105
+ console.error(` wrong on our side rather than yours — mail ${BRAND.email} and we will sort it out.${C.off}\n`);
106
+ process.exit(1);
107
+ }
97
108
  if (r.status === 402) {
98
109
  console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
99
110
  process.exit(1);
@@ -122,12 +133,47 @@ async function deep(args) {
122
133
  console.log(` ${f.detail}`);
123
134
  console.log(` ${C.grn}Fix${C.off} ${f.fix}\n`);
124
135
  }
136
+ // Printed BEFORE the report link, and before the scans-left line, because a
137
+ // reader who stops at the findings must still have seen this. A check that
138
+ // did not run is a hole in the answer, not a footnote to it.
139
+ const missed = r.data.incomplete || [];
140
+ if (missed.length) {
141
+ console.log(`${C.red}${C.b}${missed.length} check${missed.length === 1 ? '' : 's'} did not complete${C.off}`);
142
+ console.log(` ${C.d}These are not a pass. Nothing below was ruled out.${C.off}`);
143
+ for (const m of missed.slice(0, 8)) {
144
+ console.log(` ${C.red}~${C.off} ${C.g}${m.id.padEnd(12)}${C.off}${C.d}${(m.title || '').slice(0, 58)}${C.off}`);
145
+ }
146
+ if (missed.length > 8) console.log(` ${C.d}and ${missed.length - 8} more${C.off}`);
147
+ if (r.data.coverage !== undefined) {
148
+ console.log(` ${C.d}${Math.round(r.data.coverage * 100)}% of the ${r.data.rulesAsked} checks in this scan answered.${C.off}`);
149
+ }
150
+ if (r.data.refunded) {
151
+ console.log(` ${C.grn}This scan did not finish, so it does not count against your five.${C.off}`);
152
+ }
153
+ console.log(` ${C.d}Run it again and they usually complete.${C.off}\n`);
154
+ }
155
+
125
156
  if (r.data.report?.url) {
126
157
  console.log(`${C.b}Your report${C.off}`);
127
158
  console.log(` ${C.o}${r.data.report.url}${C.off}`);
128
159
  console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
129
160
  }
130
161
  console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
162
+
163
+ // --fail-on <severity>: the CI gate, same contract as the free scan. The
164
+ // human report above has fully printed; the pipeline now gets its verdict.
165
+ // A scan with holes trips the gate too — a check that did not run has not
166
+ // passed, and a gate that shrugs at that is a gate that waves through the
167
+ // exact case it exists for.
168
+ const failOn = args[args.indexOf('--fail-on') + 1];
169
+ if (args.includes('--fail-on')) {
170
+ if (!(failOn in rank) && failOn !== 'any') {
171
+ console.error(' --fail-on must be one of: critical, high, medium, low, any');
172
+ process.exit(2);
173
+ }
174
+ const limit = failOn === 'any' ? 3 : rank[failOn];
175
+ if (findings.some(f => rank[f.severity] <= limit) || missed.length) process.exit(1);
176
+ }
131
177
  }
132
178
 
133
179
  function help() {
@@ -140,13 +186,20 @@ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
140
186
  ${C.b}npx ${BRAND.slug} logout${C.off} forget the key
141
187
  ${C.b}npx ${BRAND.slug} deep${C.off} ${C.d}[path]${C.off} the paid scan — asks before sending anything
142
188
 
143
- ${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
144
- ${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
189
+ ${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
190
+ ${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
191
+ ${C.d}--fail-on${C.off} ${C.d}<sev>${C.off} exit 1 if findings reach this severity — fails a CI build
192
+ ${C.d}critical | high | medium | low | any${C.off}
145
193
 
146
194
  ${C.d}https://${BRAND.slug}.dev${C.off}
147
195
  `);
148
196
  }
149
197
 
198
+ const backoff = (r) => {
199
+ const s = Number(r.data?.retryAfter) || 60;
200
+ return s < 90 ? `${s} seconds` : `${Math.ceil(s / 60)} minutes`;
201
+ };
202
+
150
203
  export async function run(cmd, args) {
151
204
  if (cmd === 'login') return login(args);
152
205
  if (cmd === 'logout') return logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {
@@ -11,12 +11,15 @@
11
11
  "net",
12
12
  "bin",
13
13
  "scripts/verify-readonly.mjs",
14
- "README.md"
14
+ "README.md",
15
+ "LICENSE"
15
16
  ],
16
17
  "scripts": {
17
18
  "verify": "node scripts/verify-readonly.mjs",
18
19
  "pretest": "node scripts/verify-readonly.mjs",
19
- "test": "node test/run.mjs"
20
+ "test": "node test/run.mjs && node test/gate.mjs",
21
+ "prepack": "node scripts/prepare-publish.mjs",
22
+ "postpack": "node scripts/restore-after-publish.mjs"
20
23
  },
21
24
  "keywords": [
22
25
  "security",
@@ -29,13 +32,11 @@
29
32
  "checklist"
30
33
  ],
31
34
  "homepage": "https://launchprep.dev",
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/312labs/launchprep-app.git",
35
- "directory": "cli"
36
- },
37
- "license": "MIT",
35
+ "license": "SEE LICENSE IN LICENSE",
38
36
  "engines": {
39
37
  "node": ">=18"
38
+ },
39
+ "bugs": {
40
+ "email": "hello@launchprep.dev"
40
41
  }
41
42
  }
package/src/brand.mjs CHANGED
@@ -8,6 +8,8 @@ export const BRAND = {
8
8
  name: 'Launchprep', // shown to humans
9
9
  slug: 'launchprep', // npm package, plugin id, slash command
10
10
  tagline: 'readiness scan',
11
+ email: 'hello@launchprep.dev', // the address that actually receives
12
+ api: 'https://api.launchprep.dev',
11
13
  };
12
14
 
13
15
  export const CMD = '/' + BRAND.slug;
@@ -13,8 +13,12 @@ export const AUTH_CHECKS = [
13
13
  { id: 'SEC-001', run(repo) {
14
14
  const envs = repo.files.filter(f => /(^|\/)\.env(\.local|\.production|\.development)?$/.test(f.path));
15
15
  if (!envs.length) return [];
16
- const gi = repo.read('.gitignore') || '';
17
- if (/^\s*\*?\.?env/m.test(gi) || /^\s*\.env\*/m.test(gi)) 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);
18
22
 
19
23
  // A committed .env full of ChangeMe and localhost is a template, not a leak.
20
24
  // Only the values decide - flagging a placeholder file as CRITICAL is how a
@@ -33,7 +37,7 @@ export const AUTH_CHECKS = [
33
37
 
34
38
  const out = [];
35
39
  for (const f of envs) {
36
- if (!f.text) continue;
40
+ if (!f.text || ignored(f.path)) continue;
37
41
  const hits = [];
38
42
  for (const line of f.text.split('\n')) {
39
43
  const eq = line.indexOf('=');
@@ -3,6 +3,23 @@ const finding = (id, title, severity, file, line, detail, fix) =>
3
3
  ({ id, title, severity, file, line, detail, fix });
4
4
  const lineOf = (t, i) => t.slice(0, i).split('\n').length;
5
5
 
6
+ // Comments blanked to spaces - offsets and line numbers survive, the words do
7
+ // not. Our own server.mjs opens by explaining that it does NOT use
8
+ // express.json(), and DATA-008 read that sentence as the call itself. Twelfth
9
+ // time this shape has bitten: it matched a name where no code was.
10
+ const codeOnly = (t) => {
11
+ let out = '', i = 0;
12
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
13
+ while (i < t.length) {
14
+ const two = t.slice(i, i + 2);
15
+ if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
16
+ 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; }
17
+ 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; }
18
+ out += t[i]; i++;
19
+ }
20
+ return out;
21
+ };
22
+
6
23
  export const DEPLOY_CHECKS = [
7
24
 
8
25
  // ---- a credential pasted into a CI workflow ------------------------------
@@ -157,13 +174,16 @@ export const DEPLOY_CHECKS = [
157
174
  // ---- no body size limit ---------------------------------------------------
158
175
  { id: 'DATA-008', run(repo, profile) {
159
176
  if (!profile.is_public) return [];
160
- const express = repo.grep(/express\.json\s*\(|bodyParser\.json\s*\(/);
177
+ const CALL = /express\.json\s*\(|bodyParser\.json\s*\(/;
178
+ const express = repo.grep(CALL)
179
+ .map(f => ({ ...f, code: codeOnly(f.text) }))
180
+ .filter(f => CALL.test(f.code));
161
181
  if (!express.length) return [];
162
- const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.text));
182
+ const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.code));
163
183
  if (limited) return [];
164
184
  const f = express[0];
165
185
  return [finding('DATA-008', 'No limit on request body size', 'medium',
166
- f.path, lineOf(f.text, f.text.search(/express\.json|bodyParser\.json/)),
186
+ f.path, lineOf(f.code, f.code.search(CALL)),
167
187
  'A single request can send a body large enough to exhaust memory. No account needed.',
168
188
  'Pass a limit, e.g. express.json({ limit: "1mb" }).')];
169
189
  }},
@@ -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
+ ];