launchprep 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,39 @@
1
1
  # launchprep
2
2
 
3
- Reads your code and tells you what will break before your users find out.
3
+ **Is your AI-built app safe to launch?** Launchprep reads your codebase, works
4
+ out what you actually built, and reports what will break when real users arrive
5
+ — leaked keys, an exposed database, runaway AI spend, GDPR gaps. Every finding
6
+ names the consequence in plain English, with a file, a line, and the fix.
7
+
8
+ ```bash
9
+ npx launchprep
10
+ ```
11
+
12
+ No signup, no dashboard, nothing added to your repo. The free scan runs entirely
13
+ on your machine and uploads nothing.
14
+
15
+ ## Built with Cursor, Claude, Lovable, v0, Bolt or Replit?
16
+
17
+ That is who this is for. The code works — that is not the same as being safe to
18
+ put in front of people. Launchprep is the pre-launch check for founders who are
19
+ not security engineers: it detects your stack (Next.js, Django, Rails, Express,
20
+ Supabase, Postgres), runs only the checks that can apply to it, and tells you
21
+ what it skipped and why.
22
+
23
+ ## A finding looks like this
24
+
25
+ ```
26
+ CRITICAL Server-only secret exposed to the client bundle
27
+ apps/web/.env.local:4
28
+ The NEXT_PUBLIC_ prefix makes this key PUBLIC — it ships in the
29
+ JavaScript every visitor downloads. The name reads like it is
30
+ protected. It is not.
31
+ Fix Rename it without the prefix and read it only on the server.
32
+ ```
33
+
34
+ Not "missing authorization predicate". The consequence, the file, the line, the fix.
35
+
36
+ ## Commands
4
37
 
5
38
  ```bash
6
39
  npx launchprep # the free checks — runs here, uploads nothing
@@ -9,11 +42,9 @@ npx launchprep deep # the paid scan — asks before sending anything
9
42
  npx launchprep whoami # scans remaining
10
43
  ```
11
44
 
12
- No signup. No dashboard. Nothing added to your repo.
13
-
14
45
  ## What it does
15
46
 
16
- 289 checks — but it works out what you built **before** running any of them, so
47
+ 290 checks — but it works out what you built **before** running any of them, so
17
48
  you are not told about problems you cannot have. A static site is asked 48
18
49
  questions; a multi-tenant SaaS with payments and AI is asked 158.
19
50
 
@@ -103,6 +134,14 @@ fail the build. On a deep scan, checks that did not complete fail it too — a
103
134
  check that could not run has not passed. A path that does not exist always
104
135
  exits 2: a scan of nothing must never look like a clean scan of something.
105
136
 
137
+ ## Mapped to published standards
138
+
139
+ Every check carries its place in the **OWASP Top 10**, the **OWASP API Security
140
+ Top 10**, the **OWASP Top 10 for LLM Applications**, **ASVS** chapters and
141
+ **CWE** — and to the privacy law that reaches your users (**GDPR**, UK GDPR,
142
+ **CCPA**). Not name-dropped: the mapping is per rule, and a build step fails if
143
+ any rule is unmapped.
144
+
106
145
  ## Free and paid
107
146
 
108
147
  Everything above is free, unlimited, forever — every check a program can make on
@@ -118,3 +157,14 @@ delete. See <https://launchprep.dev>.
118
157
  Proprietary — see the LICENSE file. Run it on anything you are authorised to
119
158
  scan; the rules and checks may not be copied into other products. Versions
120
159
  0.1.0 and 0.2.0 were published under MIT and remain so.
160
+
161
+ ---
162
+
163
+ **Scan your app before your users do:**
164
+
165
+ ```bash
166
+ npx launchprep
167
+ ```
168
+
169
+ Docs and the full check catalogue: <https://launchprep.dev> ·
170
+ Questions: <https://launchprep.dev/faq>
package/net/client.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { BRAND } from '../src/brand.mjs';
3
3
  const BASE = process.env.LAUNCHPREP_API || BRAND.api;
4
4
 
5
- async function call(path, { key, body, method = 'POST' } = {}) {
5
+ async function call(path, { key, body, method = 'POST', timeoutMs } = {}) {
6
6
  let res;
7
7
  try {
8
8
  res = await fetch(BASE + path, {
@@ -15,7 +15,7 @@ async function call(path, { key, body, method = 'POST' } = {}) {
15
15
  // A deep scan is minutes of model time, not seconds. But it is not
16
16
  // forever either - API-010, our own rule: nothing outbound should be able
17
17
  // to hang indefinitely.
18
- signal: AbortSignal.timeout(Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
18
+ signal: AbortSignal.timeout(timeoutMs || Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
19
19
  });
20
20
  } catch (e) {
21
21
  if (e.name === 'TimeoutError') throw new Error('launchprep.dev did not answer in time');
@@ -31,6 +31,13 @@ export const validate = (key) => call('/v1/validate', { key });
31
31
 
32
32
  export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKey }) =>
33
33
  call('/v1/scan', { key, body: {
34
+ // Ask for a job rather than an answer. A deep scan takes minutes and a
35
+ // hosting proxy will cut a request long before it finishes - which is
36
+ // exactly what happened: the scan ran, the money was spent, and the customer
37
+ // got a 502. The server now replies with an id in under a second and we ask
38
+ // for the result afterwards. Older servers ignore this field and answer the
39
+ // old way, so this is safe to send at any of them.
40
+ async: true,
34
41
  digest: digest.text,
35
42
  profile,
36
43
  rule_ids: ruleIds,
@@ -45,4 +52,9 @@ export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKe
45
52
  files_skipped: digest.coverage?.filesSkipped ?? null,
46
53
  }});
47
54
 
55
+ // One poll. Short timeout on purpose: this is a small GET, and if it fails we
56
+ // simply ask again on the next tick rather than giving up on the whole scan.
57
+ export const scanStatus = ({ key, scanId }) =>
58
+ call(`/v1/scan/${scanId}`, { key, method: 'GET', timeoutMs: 30_000 });
59
+
48
60
  export { BASE };
package/net/commands.mjs CHANGED
@@ -6,11 +6,16 @@ import { basename, resolve } from 'node:path';
6
6
  import { BRAND } from '../src/brand.mjs';
7
7
  import { scanRepo } from '../src/fs-scan.mjs';
8
8
  import { detectProfile, toGateProfile } from '../src/detect.mjs';
9
- import { gate } from '../src/gate.mjs';
9
+ import { gate, allRules } from '../src/gate.mjs';
10
10
  import { buildDigest } from '../src/digest.mjs';
11
11
  import { save, load, clear, FILE } from './credentials.mjs';
12
12
  import { confirmUpload } from './consent.mjs';
13
- import { validate, deepScan, BASE } from './client.mjs';
13
+ import { validate, deepScan, scanStatus, BASE } from './client.mjs';
14
+
15
+ // Waiting between polls. setTimeout only - the read-only guard forbids the
16
+ // scanner opening anything, and this file is the paid half where a network
17
+ // call is allowed, but a timer is all this needs.
18
+ const sleep = (ms) => new Promise(done => setTimeout(done, ms));
14
19
 
15
20
  const C = { b:'\x1b[1m', d:'\x1b[2m', g:'\x1b[90m', o:'\x1b[38;5;209m',
16
21
  grn:'\x1b[32m', red:'\x1b[31m', yel:'\x1b[33m', off:'\x1b[0m' };
@@ -80,7 +85,11 @@ async function deep(args) {
80
85
  const digest = buildDigest(repo, { maxTokens: 150_000, profile });
81
86
 
82
87
  console.log(` ${C.g}what it is ${C.off}${profile.surface} · ${[profile.stack.framework, profile.stack.database, profile.stack.host].filter(Boolean).join(' · ') || '—'}`);
83
- console.log(` ${C.g}deep checks ${C.off}${tier2.length} of ${g.evaluated.length + g.skipped.length} apply`);
88
+ // The denominator must be the DEEP total. evaluated+skipped mixes tier 1 and
89
+ // tier 2 and printed "76 of 250" - a number matching nothing the customer has
90
+ // been told (290 total, 173 deep, 117 free).
91
+ const deepTotal = allRules().filter(r => r.tier === 2).length;
92
+ console.log(` ${C.g}deep checks ${C.off}${tier2.length} of ${deepTotal} apply`);
84
93
 
85
94
  const okToSend = await confirmUpload({ digest, host: new URL(BASE).host, assumeYes: flag(args, 'yes') });
86
95
  if (!okToSend) process.exit(1);
@@ -92,9 +101,48 @@ async function deep(args) {
92
101
  .update(digest.text).update(tier2.map(r => r.id).join(',')).digest('hex').slice(0, 32);
93
102
 
94
103
  console.log(` ${C.d}scanning — this takes a few minutes${C.off}`);
95
- const r = await deepScan({ key: c.key, digest, profile,
104
+ let r = await deepScan({ key: c.key, digest, profile,
96
105
  ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
97
106
 
107
+ // 202 means the server took the job and is holding the answer for us. Poll for
108
+ // it rather than keep one request open for ten minutes: a hosting proxy cuts a
109
+ // long request and returns 502, which cost a real customer a real scan. It also
110
+ // means the wait is no longer silent - there is a count to show.
111
+ if (r.status === 202 && r.data?.scanId) {
112
+ const scanId = r.data.scanId;
113
+ const started = Date.now();
114
+ const tty = process.stdout.isTTY;
115
+ let shown = '';
116
+ for (;;) {
117
+ await sleep(4000);
118
+ const p = await scanStatus({ key: c.key, scanId });
119
+ // A failed poll is not a failed scan - the scan runs on the server whatever
120
+ // this one request did. Keep asking, and only give up after a long while.
121
+ if (!p.ok || !p.data) {
122
+ if (Date.now() - started > 20 * 60_000) {
123
+ if (tty && shown) process.stdout.write('\r\x1b[2K');
124
+ console.error(`\n ${C.red}Lost contact with the scan.${C.off}`);
125
+ console.error(` ${C.d}It may still finish. Run this again in a minute — if it did,${C.off}`);
126
+ console.error(` ${C.d}you get its report without spending another scan.${C.off}\n`);
127
+ process.exit(1);
128
+ }
129
+ continue;
130
+ }
131
+ const st = p.data.status;
132
+ if (st === 'running') {
133
+ const pr = p.data.progress;
134
+ const line = pr && pr.total
135
+ ? ` ${C.d}checking — ${pr.done} of ${pr.total} groups done${C.off}`
136
+ : ` ${C.d}checking — reading your code${C.off}`;
137
+ if (tty && line !== shown) { process.stdout.write('\r\x1b[2K' + line); shown = line; }
138
+ continue;
139
+ }
140
+ if (tty && shown) process.stdout.write('\r\x1b[2K');
141
+ r = { status: st === 'done' ? 200 : 500, ok: st === 'done', data: p.data };
142
+ break;
143
+ }
144
+ }
145
+
98
146
  if (r.status === 429) {
99
147
  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
148
  process.exit(1);
@@ -127,7 +175,18 @@ async function deep(args) {
127
175
  }
128
176
  if (!r.ok) {
129
177
  console.error(`\n ${C.red}The scan did not run${C.off} ${C.d}(${r.status})${C.off} ${r.data?.detail || r.data?.error || ''}`);
130
- if (r.data?.refunded) console.error(` ${C.grn}Your scan was not used.${C.off}`);
178
+ // A proxy 5xx carries NO body, so r.data is undefined and this reassurance
179
+ // never printed - the customer was left wondering whether one of their five
180
+ // had just been spent on nothing. Say it either way, and say how to check:
181
+ // that is the only question they have at this moment.
182
+ if (r.data?.refunded) {
183
+ console.error(` ${C.grn}Your scan was not used.${C.off}`);
184
+ } else if (r.status >= 500 || r.status === 0) {
185
+ console.error(` ${C.d}A scan only counts once it finishes. One that was started${C.off}`);
186
+ console.error(` ${C.d}but never returned is released, and comes back to you.${C.off}`);
187
+ console.error(` ${C.d}Check with${C.off} npx ${BRAND.slug} whoami${C.d} — and run this${C.off}`);
188
+ console.error(` ${C.d}again: if the scan did finish, you get its report free.${C.off}`);
189
+ }
131
190
  console.error('');
132
191
  process.exit(1);
133
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Read-only CLI that scans your AI-built app and reports what will break before real users hit it — auth, tenant isolation, data, payments, GDPR. 117 checks run free on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -264,9 +264,18 @@ export const AUTH_CHECKS = [
264
264
  if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
265
265
  const all = repo.files.filter(f => f.text).map(f => f.text).join('\n');
266
266
  const missing = [];
267
- if (!/Content-Security-Policy/i.test(all)) missing.push('Content-Security-Policy');
268
- if (!/X-Content-Type-Options/i.test(all)) missing.push('X-Content-Type-Options');
269
- if (!/(X-Frame-Options|frame-ancestors)/i.test(all)) missing.push('X-Frame-Options');
267
+ // Match the MIDDLEWARE that sets each header, not only the literal header
268
+ // string. helmet.noSniff() IS X-Content-Type-Options; helmet.frameguard() IS
269
+ // X-Frame-Options; a bare helmet() sets all three. Grepping for the literal
270
+ // only meant this fired on apps that had done it the standard way - i.e. it
271
+ // misfired precisely on the people who got it right.
272
+ const helmetAll = /\bhelmet\s*\(\s*\)|\bapp\.use\s*\(\s*helmet\s*[,)]|require\(['"]helmet['"]\)\s*\(\s*\)/i.test(all);
273
+ if (!/Content-Security-Policy|helmet\.contentSecurityPolicy|CSP_DEFAULT_SRC|content_security_policy/i.test(all) && !helmetAll)
274
+ missing.push('Content-Security-Policy');
275
+ if (!/X-Content-Type-Options|helmet\.noSniff|SECURE_CONTENT_TYPE_NOSNIFF|secure_headers/i.test(all) && !helmetAll)
276
+ missing.push('X-Content-Type-Options');
277
+ if (!/(X-Frame-Options|frame-ancestors|helmet\.frameguard|X_FRAME_OPTIONS|secure_headers)/i.test(all) && !helmetAll)
278
+ missing.push('X-Frame-Options');
270
279
  if (missing.length < 2) return [];
271
280
  const f = repo.files.find(x => /(next\.config|middleware\.[tj]s|server\.[tj]s|vercel\.json|netlify\.toml)/.test(x.path));
272
281
  if (!f) return [];
@@ -69,9 +69,22 @@ export const BATCH3_CHECKS = [
69
69
  if (!profile.has_migrations || profile.stage !== 'production') return [];
70
70
  const out = [];
71
71
  const dirs = repo.find(/migrations?\//);
72
- const hasDown = dirs.some(f => /down|rollback|revert/i.test(f.path)) ||
73
- repo.grep(/def downgrade|\.down\s*=|DROP.*-- rollback|def self\.down/i, /\.(py|rb|ts|js|sql)$/).length;
74
- if (hasDown || !dirs.length) return [];
72
+ if (!dirs.length) return [];
73
+ // Django reverses CreateModel/AddField/AlterField itself - there is no
74
+ // "down" to write, and looking for Alembic's `def downgrade` or Rails'
75
+ // `def self.down` in a Django project is looking for a convention that
76
+ // does not exist there. It fired on 100% of Django repos. Only a
77
+ // RunPython/RunSQL without its reverse is genuinely irreversible.
78
+ const isDjango = dirs.some(f => /\.py$/.test(f.path));
79
+ if (isDjango) {
80
+ const irreversible = repo.grep(
81
+ /RunPython\((?![^)]*reverse_code)|RunSQL\((?![^)]*reverse_sql)/, /\.py$/).length;
82
+ if (!irreversible) return [];
83
+ } else {
84
+ const hasDown = dirs.some(f => /down|rollback|revert/i.test(f.path)) ||
85
+ repo.grep(/def downgrade|\.down\s*=|DROP.*-- rollback|def self\.down/i, /\.(py|rb|ts|js|sql)$/).length;
86
+ if (hasDown) return [];
87
+ }
75
88
  return [finding('DEP-005', 'Migrations have no rollback path', 'medium',
76
89
  dirs[0].path, 1,
77
90
  'A migration that fails halfway through production leaves the schema in a state nothing knows how to undo — under pressure, at the worst possible moment.',
@@ -236,8 +249,15 @@ export const BATCH3_CHECKS = [
236
249
  { id: 'RU-010', scope: 'root', run(repo, profile) {
237
250
  if (!profile.is_public || profile.stage !== 'production') return [];
238
251
  if (profile.surface === 'library') return [];
239
- const has = repo.grep(/mailto:|support@|help@|contact@|\/contact\b/i,
240
- /\.(tsx|jsx|html|erb|vue|svelte|md|py|rb)$/).length;
252
+ // A contact route is a contact route. Grepping only for role aliases
253
+ // (support@/help@/contact@) denied that a project had any way to reach it
254
+ // while a real address sat in its README - which is how most founders
255
+ // actually do it, with their own name. Accept any address, a Slack/Discord
256
+ // invite, or a Support/Contact heading. `git@github.com` is a remote, not a
257
+ // contact, so it is excluded.
258
+ const has = repo.grep(
259
+ /mailto:|(?!git@)[\w.+-]+@[\w-]+\.[a-z]{2,}|slack\.com\/(?:archives|invite)|discord\.gg|\/contact\b|#{1,3}\s*(?:Support|Contact)\b/i,
260
+ /\.(tsx|jsx|html|erb|vue|svelte|md|py|rb|txt)$/).length;
241
261
  if (has) return [];
242
262
  return [finding('RU-010', 'No way for a customer to reach you', 'medium',
243
263
  'contact details', 1,
@@ -110,10 +110,25 @@ export const BATCH4_CHECKS = [
110
110
  const cols = []; let total = 0, at = null;
111
111
  for (const f of sql) {
112
112
  let m;
113
- const fk = /(?:FOREIGN\s+KEY\s*\(\s*"?([A-Za-z0-9_]+)"?\s*\)|^\s*"?([A-Za-z0-9_]+)"?\s+[A-Za-z]+[^,\n]*\bREFERENCES\b)/gim;
113
+ // `ALTER TABLE x ADD COLUMN IF NOT EXISTS col uuid REFERENCES y(id)` puts SQL
114
+ // keywords at the start of the line, so "^\s*(word)" captured ADD and the
115
+ // finding read "foreign keys ... add, fact_id". Skip the DDL preamble before
116
+ // taking the column name.
117
+ // TWO passes, and the order matters. An explicit `FOREIGN KEY (col)` clause is
118
+ // unambiguous, so take those FIRST and only fall back to the inline
119
+ // `col type REFERENCES other(id)` form on lines that have no such clause.
120
+ // Running one combined regex let the inline branch match earlier in the string
121
+ // and win on Drizzle output - `ALTER TABLE "t" ADD CONSTRAINT "..." FOREIGN KEY
122
+ // ("org_id") REFERENCES ...` reported the column as "ALTER". Same class of bug
123
+ // as the ADD COLUMN one: the line starts with SQL keywords, not a column.
124
+ const fkExplicit = /FOREIGN\s+KEY\s*\(\s*"?([A-Za-z0-9_]+)"?\s*\)/gi;
125
+ const fkInline = /^\s*(?:ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?)?"?([A-Za-z0-9_]+)"?\s+[A-Za-z]+[^,\n]*\bREFERENCES\b/gim;
126
+ const fk = /FOREIGN\s+KEY\s*\(/i.test(f.text) ? fkExplicit : fkInline;
114
127
  while ((m = fk.exec(f.text))) {
115
128
  const col = (m[1] || m[2] || '').toLowerCase();
116
- if (!col || indexed.has(col) || cols.includes(col)) continue;
129
+ // belt and braces: a SQL keyword is never a column name
130
+ const SQL_KW = new Set(['add','column','constraint','table','alter','create','if','not','exists','key','foreign','primary','unique']);
131
+ if (!col || SQL_KW.has(col) || indexed.has(col) || cols.includes(col)) continue;
117
132
  if (!at) at = { path: f.path, line: lineOf(f.text, m.index) };
118
133
  total++; cols.push(col);
119
134
  }
@@ -241,7 +256,11 @@ export const BATCH4_CHECKS = [
241
256
  if (!t || !/\.(ts|tsx|js|jsx|mjs|py|rb|php)$/.test(f.path)) continue;
242
257
  const m = t.match(/(?:image\/jpe?g|image\/heic|\.jpe?g['"]|\.heic['"])/i);
243
258
  if (!m) continue;
244
- if (!/upload|multer|putObject|createReadStream|\.save\(|storage/i.test(t)) continue;
259
+ // Require evidence of a USER upload, not merely a file that writes jpegs.
260
+ // `storage` / `.save(` / `createReadStream` matched a video frame writer that
261
+ // emits f000001.jpg - no user, no upload, no EXIF to leak. Read the context,
262
+ // not the extension.
263
+ if (!/\b(?:multer|formidable|busboy|uploadedFile|req\.files?\b|FormData|multipart\/form-data|putObject|presigned|\.upload\s*\()/i.test(t)) continue;
245
264
  return [finding('UP-008', 'Photo uploads keep their GPS coordinates', 'medium',
246
265
  f.path, lineOf(t, t.indexOf(m[0])),
247
266
  'A photo taken on a phone carries the exact place and time it was taken. Stored and served as-is, anyone who downloads a user\'s picture learns where they live.',
@@ -168,18 +168,27 @@ export const DEPLOY_CHECKS = [
168
168
  // ---- no body size limit ---------------------------------------------------
169
169
  { id: 'DATA-008', run(repo, profile) {
170
170
  if (!profile.is_public) return [];
171
- const CALL = /express\.json\s*\(|bodyParser\.json\s*\(/;
172
- const express = repo.grep(CALL)
171
+ // `express.json()` and `bodyParser.json()` ALREADY default to 100kb. Flagging
172
+ // a bare call was wrong on essentially every Express app - and the fix this
173
+ // check used to suggest, limit: "1mb", LOOSENED the cap tenfold. Advice that
174
+ // makes a reader less safe is worse than no check.
175
+ //
176
+ // The genuinely unbounded sink is a file-upload middleware with no limits:
177
+ // express-fileupload, multer and busboy all accept an unlimited body unless
178
+ // told otherwise.
179
+ const UPLOAD = /\b(?:fileUpload|multer|busboy|Busboy)\s*\(/;
180
+ const hits = repo.grep(UPLOAD)
173
181
  .map(f => ({ ...f, code: codeOnly(f.text) }))
174
- .filter(f => CALL.test(f.code));
175
- if (!express.length) return [];
176
- const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.code));
177
- if (limited) return [];
178
- const f = express[0];
179
- return [finding('DATA-008', 'No limit on request body size', 'medium',
180
- f.path, lineOf(f.code, f.code.search(CALL)),
181
- 'A single request can send a body large enough to exhaust memory. No account needed.',
182
- 'Pass a limit, e.g. express.json({ limit: "1mb" }).')];
182
+ .filter(f => UPLOAD.test(f.code));
183
+ if (!hits.length) return [];
184
+ // a limits: / fileSize: / maxFileSize option anywhere in the same file
185
+ const capped = hits.some(f => /\blimits\s*:|\bfileSize\s*:|\bmaxFileSize\s*:/.test(f.code));
186
+ if (capped) return [];
187
+ const f = hits[0];
188
+ return [finding('DATA-008', 'File uploads with no size limit', 'medium',
189
+ f.path, lineOf(f.code, f.code.search(UPLOAD)),
190
+ 'This upload middleware accepts a body of any size. One request can fill the disk or exhaust memory, and no account is needed to send it.',
191
+ 'Pass a ceiling — multer({ limits: { fileSize: 5 * 1024 * 1024 } }), or fileUpload({ limits: { fileSize: 5e6 } }).')];
183
192
  }},
184
193
 
185
194
  // ---- SQL built by string concatenation -----------------------------------
@@ -119,7 +119,13 @@ const usesTainted = (expr, names) => {
119
119
 
120
120
  // Wrapped in a numeric coercion it cannot carry a quote, a comment marker or a
121
121
  // semicolon. Flagging it would be flagging arithmetic.
122
- const COERCED = /^\s*(Number|parseInt|parseFloat|int|float|Integer\.parseInt|to_i|intval)\s*\(|^\s*\+\s*[A-Za-z_$]/;
122
+ // A value that cannot carry a quote cannot break out of a string. Numeric
123
+ // coercion was already here; hashing belongs with it - md5/sha/digest return
124
+ // hex, and a uuid is hex and dashes. Without this, `${security.hash(req.body
125
+ // .password)}` inside an otherwise-parameterised query reported a CRITICAL
126
+ // SQL injection, which is the most damaging false positive the tool can make:
127
+ // maximum severity, on ordinary code.
128
+ const COERCED = /^\s*(?:Number|parseInt|parseFloat|int|float|Integer\.parseInt|to_i|intval|Boolean|BigInt)\s*\(|^\s*\+\s*[A-Za-z_$]|^\s*[\w.$]*\b(?:hash|md5|sha1|sha256|sha512|sha\d*|digest|hexdigest|hashSync|bcrypt|scrypt|uuid|uuidv4|randomUUID)\w*\s*\(/i;
123
129
 
124
130
  // Tagged templates that parameterise. These look EXACTLY like the dangerous
125
131
  // form and are the reason a naive version of this check would be unusable:
package/src/checks.mjs CHANGED
@@ -47,10 +47,38 @@ const BASE_CHECKS = [
47
47
  { id:'SEC-002', run(repo){
48
48
  const gi = repo.read('.gitignore');
49
49
  if (gi === null) return [];
50
- if (/^\s*\.env/m.test(gi)) return [];
51
- return [finding('SEC-002','.env is not in .gitignore','high','.gitignore',1,
50
+ // Not every project's env file is called ".env". docker-compose `env_file:`
51
+ // and `--env-file vars.env` are ordinary, and a project keeping secrets in
52
+ // vars.env - with vars.env correctly ignored - was told to protect a file it
53
+ // does not have. Work out which env files this project ACTUALLY has, then
54
+ // check those names. Say nothing when there is no env file at all.
55
+ const envFiles = repo.find(/(^|\/)[\w.-]*\.env(\.[\w-]+)?$/)
56
+ .map(f => f.path.split('/').pop())
57
+ .filter(n => !/\.example$|\.sample$|\.template$/i.test(n));
58
+ // A correctly-ignored env file is INVISIBLE to us - git never committed it,
59
+ // so it is not on disk to find. The .gitignore naming one is therefore the
60
+ // evidence that the project handled this, whatever the file is called.
61
+ const ignoresSomeEnv = gi.split('\n')
62
+ .map(l => l.trim())
63
+ .some(l => l && !l.startsWith('#') && /(^|[\w.*-])\.env(\*|$)|^\*\.env$/.test(l));
64
+ if (!envFiles.length && ignoresSomeEnv) return [];
65
+ const names = [...new Set(envFiles.length ? envFiles : ['.env'])];
66
+ const ignored = (name) => gi.split('\n').some(line => {
67
+ const l = line.trim();
68
+ if (!l || l.startsWith('#')) return false;
69
+ if (l === name) return true; // vars.env
70
+ if (l.replace(/\*/g, '') === '' ) return false;
71
+ if (/^\.env/.test(l) && /^\.env/.test(name)) return true; // .env / .env*
72
+ if (l.endsWith('*') && name.startsWith(l.slice(0, -1))) return true;
73
+ if (l.startsWith('*') && name.endsWith(l.slice(1))) return true; // *.env
74
+ return false;
75
+ });
76
+ const exposed = names.filter(n => !ignored(n));
77
+ if (!exposed.length) return [];
78
+ const which = exposed[0];
79
+ return [finding('SEC-002', which + ' is not in .gitignore','high','.gitignore',1,
52
80
  'Nothing stops a future commit from publishing your secrets.',
53
- 'Add a line containing .env* to .gitignore')];
81
+ 'Add a line containing ' + which + ' to .gitignore')];
54
82
  }},
55
83
 
56
84
  { id:'SEC-004', run(repo){
package/src/detect.mjs CHANGED
@@ -254,7 +254,20 @@ function detectSurface(repo, framework, deps) {
254
254
  if (['flutter','react-native'].includes(f)) return fact('mobile-android','low',['cross-platform mobile']);
255
255
  if (['django','rails','laravel','symfony','flask'].includes(f))
256
256
  return fact('web-app','high',[`${f} application`]);
257
- if (['express','fastify','nest','fastapi','sinatra'].includes(f)) return fact('api-only','low',['server framework, no UI framework']);
257
+ if (['express','fastify','nest','fastapi','sinatra'].includes(f)) {
258
+ // The evidence said "no UI framework" without ever looking for one. A very
259
+ // common full-stack shape - Express serving an API next to a Vite/React
260
+ // client/ folder - was called api-only, which SKIPPED 27 checks including
261
+ // the UI, privacy and social ones the site actually needs. Look before
262
+ // asserting: a UI dependency plus real component files means a web app.
263
+ const uiDep = ['react','react-dom','vue','svelte','preact','solid-js','@angular/core']
264
+ .some(d => deps[d] || deps['dependencies']?.[d]);
265
+ const uiFiles = repo.find(/\.(tsx|jsx|vue|svelte)$/).length;
266
+ const hasHtml = repo.has(/(^|\/)index\.html$/);
267
+ if (uiDep && (uiFiles >= 3 || hasHtml))
268
+ return fact('web-app','low',[`${f} api with a browser front end`]);
269
+ return fact('api-only','low',['server framework, no UI framework']);
270
+ }
258
271
  if (['next','remix','nuxt','sveltekit'].includes(f)) {
259
272
  const dynamic = repo.has(/\/(api|actions)\//) || repo.grep(/'use server'/).length;
260
273
  return fact(dynamic ? 'web-app' : 'web-site','low',[dynamic?'server routes present':'no server routes found']);