launchprep 0.1.0 → 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.
@@ -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,10 @@ 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
+ }
97
102
  if (r.status === 402) {
98
103
  console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
99
104
  process.exit(1);
@@ -147,6 +152,11 @@ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
147
152
  `);
148
153
  }
149
154
 
155
+ const backoff = (r) => {
156
+ const s = Number(r.data?.retryAfter) || 60;
157
+ return s < 90 ? `${s} seconds` : `${Math.ceil(s / 60)} minutes`;
158
+ };
159
+
150
160
  export async function run(cmd, args) {
151
161
  if (cmd === 'login') return login(args);
152
162
  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.2.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": {
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
+ ];
package/src/checks.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Tier-1 checks: deterministic, no LLM, no network. Each returns findings with
2
2
  // a file:line so the user can go straight to it.
3
3
  import { AUTHZ_CHECKS } from './checks-authz.mjs';
4
+ import { INJECTION_CHECKS } from './checks-injection.mjs';
4
5
  import { AI_CHECKS } from './checks-ai.mjs';
5
6
  import { DEPLOY_CHECKS } from './checks-deploy.mjs';
6
7
  import { AUTH_CHECKS } from './checks-auth.mjs';
@@ -75,6 +76,12 @@ const BASE_CHECKS = [
75
76
  if (!f.text || /\.(md|lock)$/.test(f.path)) continue;
76
77
  if (/\.(example|sample|template|dist)$/i.test(f.path) ||
77
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;
78
85
  for (const [re,label] of PATTERNS){
79
86
  let m; re.lastIndex=0;
80
87
  while ((m=re.exec(f.text))){
@@ -137,7 +144,12 @@ const BASE_CHECKS = [
137
144
  const out=[];
138
145
  for (const f of repo.files){
139
146
  if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
140
- const re=/catch\s*\(\s*(\w+)\s*\)\s*\{[^}]{0,200}?(?:json|send)\s*\(\s*\{[^}]{0,120}?\1(?:\.message|\.stack)?/gs;
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;
141
153
  let m;
142
154
  while ((m=re.exec(f.text))){
143
155
  out.push(finding('API-002','Internal error details returned to the client','high',
@@ -165,7 +177,7 @@ const BASE_CHECKS = [
165
177
  const BASE = BASE_CHECKS.filter(c => c.id !== 'AI-003');
166
178
  // the fuller AUTH_CHECKS version supersedes the early inline SEC-001
167
179
  const B2 = BASE.filter(c => c.id !== 'SEC-001');
168
- export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS];
180
+ export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS, ...INJECTION_CHECKS];
169
181
 
170
182
  // Test and fixture files are not deployed. A "vulnerability" in a spec file is
171
183
  // noise, and noise is what makes people stop reading findings.
package/src/fs-scan.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Cheap repo walk. Everything downstream reads from this one pass so we never
2
2
  // hit the disk twice for the same file.
3
3
  import { readdirSync, readFileSync, statSync, existsSync, lstatSync, realpathSync } from 'node:fs';
4
- import { join, relative, extname, resolve, sep } from 'node:path';
4
+ import { join, relative, extname, resolve, sep, dirname, posix } from 'node:path';
5
5
 
6
6
  const SKIP = new Set([
7
7
  'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
@@ -34,6 +34,98 @@ function containedRealPath(root, full) {
34
34
  } catch { return null; }
35
35
  }
36
36
 
37
+
38
+ // ---- the whole ignore chain, not just the file at the scan root ------------
39
+ // .gitignore is resolved against the GIT repository root, which is often above
40
+ // the directory being scanned. Reading only `<scanroot>/.gitignore` told us our
41
+ // own api/.env was unprotected when the rule excluding it sits one level up, in
42
+ // the repo root - a CRITICAL finding that was simply wrong. Same shape as every
43
+ // other false positive we have had: it judged the file without the context that
44
+ // decides it.
45
+ //
46
+ // Read-only, and it reads .git directly rather than shelling out to git, which
47
+ // verify-readonly.mjs forbids.
48
+ function gitRoot(from) {
49
+ let d = resolve(from);
50
+ for (let i = 0; i < 40; i++) {
51
+ if (existsSync(join(d, '.git'))) return d;
52
+ const up = dirname(d);
53
+ if (up === d) return null;
54
+ d = up;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ // Enough of the gitignore syntax to answer "is this path excluded": globs,
60
+ // anchoring, directory-only rules and negation. Not the whole spec - but the
61
+ // alternative was a regex that guessed, and guessing is what produced the bug.
62
+ function toRegExp(pattern) {
63
+ let p = pattern;
64
+ const anchored = p.startsWith('/') || p.slice(0, -1).includes('/');
65
+ if (p.startsWith('/')) p = p.slice(1);
66
+ if (p.endsWith('/')) p = p.slice(0, -1);
67
+ let re = '';
68
+ for (let i = 0; i < p.length; i++) {
69
+ const c = p[i];
70
+ if (c === '*') {
71
+ if (p[i + 1] === '*') { re += '.*'; i++; if (p[i + 1] === '/') i++; }
72
+ else re += '[^/]*';
73
+ }
74
+ else if (c === '?') re += '[^/]';
75
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
76
+ }
77
+ return new RegExp('^' + (anchored ? '' : '(?:.*/)?') + re + '(?:/.*)?$');
78
+ }
79
+
80
+ // Each rule is kept with the directory its .gitignore was written in, because
81
+ // that is what its paths are relative to. Exported so the test shim builds its
82
+ // matcher the same way rather than approximating it.
83
+ export function buildIgnore(rootAbs, sources) {
84
+ const rules = [];
85
+ for (const { dir, text } of sources) {
86
+ for (const raw of String(text ?? '').split('\n')) {
87
+ const line = raw.trim();
88
+ if (!line || line.startsWith('#')) continue;
89
+ const negate = line.startsWith('!');
90
+ const pattern = negate ? line.slice(1) : line;
91
+ if (!pattern) continue;
92
+ rules.push({ dir, re: toRegExp(pattern), negate });
93
+ }
94
+ }
95
+ // last matching rule wins, which is what git does
96
+ return (relPath) => {
97
+ const abs = resolve(rootAbs, relPath);
98
+ let ignored = false;
99
+ for (const r of rules) {
100
+ const rel = relative(r.dir, abs);
101
+ if (!rel || rel.startsWith('..')) continue;
102
+ if (r.re.test(rel.split(sep).join(posix.sep))) ignored = !r.negate;
103
+ }
104
+ return ignored;
105
+ };
106
+ }
107
+
108
+ // Every .gitignore from the git root down to the scan root, plus .git/info/exclude.
109
+ export function ignoreChain(root) {
110
+ const rootAbs = resolve(root);
111
+ const gr = gitRoot(rootAbs);
112
+ const dirs = [];
113
+ if (gr) { let d = rootAbs; while (true) { dirs.unshift(d); if (d === gr) break; const up = dirname(d); if (up === d) break; d = up; } }
114
+ else dirs.push(rootAbs);
115
+
116
+ const sources = [];
117
+ const readAt = (dir, rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } };
118
+ for (const d of dirs) {
119
+ const t = readAt(d, '.gitignore');
120
+ if (t !== null) sources.push({ dir: d, text: t });
121
+ }
122
+ if (gr) {
123
+ const t = readAt(gr, join('.git', 'info', 'exclude'));
124
+ if (t !== null) sources.push({ dir: gr, text: t });
125
+ }
126
+ return buildIgnore(rootAbs, sources);
127
+ }
128
+
37
129
  export function scanRepo(root, { maxFiles = 6000 } = {}) {
38
130
  const files = [];
39
131
  const rootAbs = resolve(root);
@@ -74,6 +166,7 @@ export function scanRepo(root, { maxFiles = 6000 } = {}) {
74
166
  files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
75
167
  exists: (rel) => existsSync(join(root, rel)),
76
168
  read: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
169
+ isIgnored: ignoreChain(root),
77
170
  };
78
171
  }
79
172
 
package/src/rules.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.1",
3
- "count": 288,
3
+ "count": 289,
4
4
  "rules": [
5
5
  {
6
6
  "id": "SEC-001",
@@ -1013,6 +1013,16 @@
1013
1013
  "category": "data-and-scale"
1014
1014
  },
1015
1015
  {
1016
+ "id": "DATA-015",
1017
+ "title": "User input concatenated into a SQL query",
1018
+ "severity": "critical",
1019
+ "tier": 1,
1020
+ "check": "a request value interpolated into a SQL string instead of passed as a parameter",
1021
+ "note": "the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.",
1022
+ "origin": "gap",
1023
+ "category": "data-and-scale"
1024
+ },
1025
+ {
1016
1026
  "id": "INF-001",
1017
1027
  "title": "Single instance with no redundancy",
1018
1028
  "severity": "medium",