launchprep 0.5.3 → 0.5.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.5.3",
4
- "description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
3
+ "version": "0.5.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": {
7
7
  "launchprep": "bin/launchprep.mjs"
@@ -17,18 +17,26 @@
17
17
  "scripts": {
18
18
  "verify": "node scripts/verify-readonly.mjs",
19
19
  "pretest": "node scripts/verify-readonly.mjs",
20
- "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs && node test/interactive.mjs",
20
+ "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs && node test/interactive.mjs && node test/detect.mjs",
21
21
  "prepack": "node scripts/prepare-publish.mjs",
22
22
  "postpack": "node scripts/restore-after-publish.mjs"
23
23
  },
24
24
  "keywords": [
25
+ "pre-launch",
26
+ "launch-readiness",
25
27
  "security",
26
- "audit",
27
- "readiness",
28
- "scanner",
29
- "prelaunch",
28
+ "owasp",
29
+ "gdpr",
30
+ "static-analysis",
31
+ "code-review",
32
+ "vibe-coding",
33
+ "ai",
34
+ "cursor",
35
+ "lovable",
36
+ "bolt",
37
+ "saas",
30
38
  "cli",
31
- "launch",
39
+ "scanner",
32
40
  "checklist"
33
41
  ],
34
42
  "homepage": "https://launchprep.dev",
@@ -274,7 +274,7 @@ export const BATCH2_CHECKS = [
274
274
  }},
275
275
 
276
276
  // ---- observability & legal ----------------------------------------------
277
- { id: 'OBS-001', run(repo, profile) {
277
+ { id: 'OBS-001', scope: 'root', run(repo, profile) {
278
278
  if (profile.stage !== 'production') return [];
279
279
  if (profile.surface === 'library') return [];
280
280
  const has = repo.grep(/@sentry|bugsnag|rollbar|honeybadger|datadog|new relic|appsignal|@opentelemetry/i,
@@ -288,7 +288,7 @@ export const BATCH2_CHECKS = [
288
288
  'Add an error reporter. Sentry\'s free tier covers a small product entirely.')];
289
289
  }},
290
290
 
291
- { id: 'LEG-001', run(repo, profile) {
291
+ { id: 'LEG-001', scope: 'root', run(repo, profile) {
292
292
  if (profile.data_sensitivity === 'none' || !profile.is_public) return [];
293
293
  if (!profile.has_accounts) return []; // a real product with users, not a template
294
294
  const has = repo.has(/privacy[-_]?policy|privacy\.(html|tsx|jsx|md|erb|vue)|\/privacy\//i) ||
@@ -233,7 +233,7 @@ export const BATCH3_CHECKS = [
233
233
  'Set an expiry, and refresh it while the user is active.')];
234
234
  }},
235
235
 
236
- { id: 'RU-010', run(repo, profile) {
236
+ { id: 'RU-010', scope: 'root', run(repo, profile) {
237
237
  if (!profile.is_public || profile.stage !== 'production') return [];
238
238
  if (profile.surface === 'library') return [];
239
239
  const has = repo.grep(/mailto:|support@|help@|contact@|\/contact\b/i,
@@ -313,7 +313,12 @@ export const BATCH3_CHECKS = [
313
313
  'Add og:image and twitter:image tags with a 1200x630 image.')];
314
314
  }},
315
315
 
316
- { id: 'INC-004', run(repo, profile) {
316
+ { id: 'INC-004', scope: 'root', run(repo, profile) {
317
+ // scope:'root'. A security contact is ONE thing per repository - a
318
+ // SECURITY.md and a /.well-known/security.txt at the root. Run per
319
+ // workspace it fired on every package of a monorepo that has the file at
320
+ // the root (997 times across the corpus), because each workspace view
321
+ // cannot see the root. Root-scoped, it looks once, at the whole repo.
317
322
  if (!profile.is_public || profile.stage !== 'production') return [];
318
323
  if (repo.has(/security\.txt$|SECURITY\.md$/i)) return [];
319
324
  if (repo.grep(/security@/i).length) return [];
@@ -294,6 +294,14 @@ export const BATCH4_CHECKS = [
294
294
  const line = lineAt(t, m.index);
295
295
  if (/console\.|logger\.|log\(|logging\.|capture|Sentry/.test(line)) continue; // logging is right
296
296
  if (!/(res\.|Response\.json|json\(|send\(|return\s*\{|jsonify|content\s*:|message\s*:)/.test(line)) continue;
297
+ // The caught error must be from an AI call, not any error in a file that
298
+ // merely mentions an AI SDK. On real code this fired on a Wildberries
299
+ // connection-error handler because the module imported the AI client
300
+ // elsewhere. Tie the leak to an actual model call in the try block this
301
+ // catch belongs to, or the rationale ("names the model, your org id,
302
+ // your rate limits") is simply not true of this error.
303
+ const beforeCatch = t.slice(Math.max(0, m.index - 600), m.index);
304
+ if (!/messages\.create|chat\.completions|\.(responses|embeddings)\.create|\banthropic\.|\bopenai\.|new (Anthropic|OpenAI)/i.test(beforeCatch)) continue;
297
305
  out.push(finding('AIOP-016', 'The provider\'s error text is shown to the user', 'medium',
298
306
  f.path, lineOf(t, m.index),
299
307
  'Provider errors name the model, your organisation id, your rate limits and sometimes your account tier. Your user sees your bill, and a stranger learns which vendor to attack.',
@@ -309,7 +317,12 @@ export const BATCH4_CHECKS = [
309
317
  const out = [];
310
318
  for (const f of repo.find(/app\/.*\/route\.(ts|js)$|pages\/api\/.*\.(ts|js)$/)) {
311
319
  const t = f.text; if (!t) continue;
312
- if (!/messages\.create|chat\.completions|generateText|streamText|anthropic|openai/i.test(t)) continue;
320
+ // Require an actual model INVOCATION, not just the library name. Matching
321
+ // the bare words "openai"/"anthropic" fired on every route that merely
322
+ // imported the SDK or constructed a client - `import OpenAI from 'openai'`,
323
+ // `new OpenAI()` - with no call at all. That was 66 false positives across
324
+ // two SDK example monorepos. Read the call, not the identifier.
325
+ if (!/\.(?:messages|chat\.completions|completions|responses)\.(?:create|stream)\s*\(|\b(?:generateText|streamText|generateObject|streamObject)\s*\(/i.test(t)) continue;
313
326
  // Two routers, two spellings. App Router exports maxDuration/runtime at the
314
327
  // top level; Pages Router puts them inside `export const config = {...}`.
315
328
  // Only handling the first flagged 77 supabase routes that WERE configured.
@@ -331,8 +344,22 @@ export const BATCH4_CHECKS = [
331
344
  const t = f.text; if (!t) continue;
332
345
  if (!/export\s+default\s+async\s+function|await\s+(?:fetch|db|prisma|sql|supabase)/.test(t)) continue;
333
346
  if (/<Suspense/.test(t)) continue;
334
- const dir = f.path.replace(/[^/]+$/, '');
335
- if (repo.files.some(x => x.path.startsWith(dir) && /loading\.(tsx|jsx|ts|js)$/.test(x.path))) continue;
347
+ // Next App Router inheritance: a loading.tsx covers its own segment AND
348
+ // every nested segment that does not define its own. The old check looked
349
+ // only in the page's OWN directory, so one app/(app)/loading.tsx that
350
+ // legitimately covers a dozen nested pages was missed and every one flagged
351
+ // - a false positive on real code. Walk from the page's directory up to the
352
+ // app/ root and accept a loading file at any level.
353
+ const segs = f.path.split('/');
354
+ const appIdx = segs.indexOf('app');
355
+ if (appIdx < 0) continue;
356
+ let covered = false;
357
+ for (let len = segs.length - 1; len >= appIdx + 1; len--) {
358
+ const prefix = segs.slice(0, len).join('/') + '/';
359
+ if (repo.files.some(x => x.path.startsWith(prefix) &&
360
+ /^loading\.(tsx|jsx|ts|js)$/.test(x.path.slice(prefix.length)))) { covered = true; break; }
361
+ }
362
+ if (covered) continue;
336
363
  out.push(finding('NEXT-012', 'Nothing shown while this page loads its data', 'low',
337
364
  f.path, 1,
338
365
  'The browser stays on the previous page with no sign anything happened. On a slow connection people press the link again, or decide it is broken and leave.',
@@ -408,7 +435,7 @@ export const BATCH4_CHECKS = [
408
435
  }},
409
436
 
410
437
  // ── LEG-003 ─────────────────────────────────────────────────────────────────
411
- { id: 'LEG-003', run(repo, profile) {
438
+ { id: 'LEG-003', scope: 'root', run(repo, profile) {
412
439
  if (!profile.is_public || !profile.has_accounts) return [];
413
440
  if (repo.has(/terms|\/tos[./]|conditions/i)) return [];
414
441
  if (repo.grep(/terms of (?:service|use)/i, /\.(tsx|jsx|html|erb|md|vue|svelte)$/).length) return [];
@@ -431,6 +458,12 @@ export const BATCH4_CHECKS = [
431
458
  while ((m = re.exec(t))) {
432
459
  const tag = m[0];
433
460
  if (/\balt\s*=/.test(tag) || /\baria-hidden\s*=\s*["'{]?true/.test(tag)) continue;
461
+ // Not every <img> in source is a UI image. Skip XSS test payloads a
462
+ // sanitiser keeps inline (<img src=x onerror=alert(...)>) and empty
463
+ // placeholder tags with no image at all - neither is a screen-reader
464
+ // concern, and both were false positives in the corpus.
465
+ if (/\bon(?:error|load)\s*=\s*["']?\s*(?:alert|eval|javascript:)/i.test(tag)) continue;
466
+ if (!/\bsrc\s*=\s*["'{]?\s*\S/.test(tag)) continue;
434
467
  out.push(finding('LEG-006', 'Image with no alt attribute', 'medium',
435
468
  f.path, lineOf(t, m.index),
436
469
  'A screen reader reads the file name instead, which tells a blind visitor nothing. In the US and Australia this is the single most commonly litigated accessibility failure, and the fix is one attribute.',
package/src/detect.mjs CHANGED
@@ -125,14 +125,23 @@ function detectAuth(repo, deps) {
125
125
  return fact('none','low',[]);
126
126
  }
127
127
 
128
- function detectHost(repo, deps) {
129
- if (repo.exists('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
130
- if (repo.exists('netlify.toml')) return fact('netlify','high',['netlify.toml']);
131
- if (repo.exists('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
132
- if (repo.exists('fly.toml')) return fact('fly','high',['fly.toml']);
133
- if (repo.exists('railway.json')) return fact('railway','high',['railway.json']);
134
- if (repo.exists('render.yaml')) return fact('render','high',['render.yaml']);
135
- if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/)) return fact('vps','low',['Dockerfile — host inferred']);
128
+ function detectHost(repo, deps, root = null) {
129
+ // Deploy config lives at the REPO ROOT. In a monorepo the app being profiled
130
+ // is a workspace (apps/web), which does not contain railway.json / fly.toml /
131
+ // vercel.json - those sit one level up. Checking the workspace only reported
132
+ // "no host", which made detectStage below call a live app a prototype and
133
+ // skip every production check. Look at the root too, exactly as detectDatabase
134
+ // already does.
135
+ const has = (f) => repo.exists(f) || Boolean(root?.exists(f));
136
+ if (has('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
137
+ if (has('netlify.toml')) return fact('netlify','high',['netlify.toml']);
138
+ if (has('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
139
+ if (has('fly.toml')) return fact('fly','high',['fly.toml']);
140
+ if (has('railway.json') || has('railway.toml')) return fact('railway','high',['railway.json']);
141
+ if (has('render.yaml')) return fact('render','high',['render.yaml']);
142
+ if (has('Procfile')) return fact('heroku','low',['Procfile']);
143
+ if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/) || root?.has(/(^|\/)Dockerfile$|docker-compose\.ya?ml$/))
144
+ return fact('vps','low',['Dockerfile — host inferred']);
136
145
  return fact(null,'low',[]);
137
146
  }
138
147
 
@@ -166,6 +175,13 @@ const PII = /\b(email|phone|first_?name|last_?name|address|date_?of_?birth|do
166
175
  const FIN = /\b(card_?number|iban|invoice|amount_cents|stripe_customer|tax_id|vat)\b/i;
167
176
  const HEALTH = /\b(diagnosis|patient|medical_record|prescription|icd_?10)\b/i;
168
177
  const CHILD = /\b(parental_consent|guardian|is_minor|age_gate)\b/i;
178
+ // Third-party credentials stored at rest: an app holding a user's WB token, a
179
+ // bot token or an API key in its own database is a far bigger target than one
180
+ // holding an email - if that database leaks, the attacker gets into the user's
181
+ // OTHER accounts. Matches encrypted-token columns (…tokenEnc, …secretEnc) and
182
+ // the common credential field names, but NOT a bare "token" (a session or reset
183
+ // token is the app's own auth, not a stored third-party secret).
184
+ const CRED = /\b(\w+(?:token|key|secret)_?enc|api_?key|access_?token|refresh_?token|client_?secret|bot_?token|webhook_?secret|oauth_?token)\b/i;
169
185
  // only count matches inside actual schema declarations, with comments removed
170
186
  function schemaHits(repo, re) {
171
187
  const files = repo.files.filter(f => f.text && /\.(sql|prisma|ts|py|rb)$/.test(f.path));
@@ -187,7 +203,11 @@ function detectSensitivity(repo) {
187
203
  const health = schemaHits(repo, HEALTH), child = schemaHits(repo, CHILD);
188
204
  if (terms(health).length >= 2) return fact('health','high',[`clinical fields: ${terms(health).join(', ')}`]);
189
205
  if (terms(child).length >= 2) return fact('children','high',[`minor/guardian fields: ${terms(child).join(', ')}`]);
190
- const fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
206
+ const cred = schemaHits(repo, CRED), fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
207
+ // Stored third-party credentials carry the same at-rest duties as financial
208
+ // data - encryption, access audit - so they map to the value those rules gate
209
+ // on, with evidence that says plainly what was found.
210
+ if (terms(cred).length >= 1) return fact('financial','high',[`credentials stored at rest: ${terms(cred).slice(0,4).join(', ')}`]);
191
211
  if (terms(fin).length >= 1) return fact('financial','high',[`financial fields: ${terms(fin).slice(0,4).join(', ')}`]);
192
212
  if (terms(pii).length >= 2) return fact('pii','high',[`personal fields: ${terms(pii).slice(0,4).join(', ')}`]);
193
213
  if (terms(pii).length === 1) return fact('pii','low',[`single personal field: ${terms(pii)[0]}`]);
@@ -244,11 +264,16 @@ function detectSurface(repo, framework, deps) {
244
264
  return fact('web-app','low',[]);
245
265
  }
246
266
 
247
- function detectStage(repo, host) {
267
+ function detectStage(repo, host, root = null) {
268
+ const has = (f) => repo.exists(f) || Boolean(root?.exists(f));
269
+ const hasRe = (re) => repo.has(re) || Boolean(root?.has(re));
248
270
  const signals = [];
249
271
  if (host.value) signals.push(`host config (${host.value})`);
250
- if (repo.has(/^\.github\/workflows\//)) signals.push('CI workflows');
251
- if (repo.exists('.env.production') || repo.exists('.env.prod')) signals.push('production env file');
272
+ if (hasRe(/(^|\/)\.github\/workflows\//)) signals.push('CI workflows');
273
+ if (has('.env.production') || has('.env.prod')) signals.push('production env file');
274
+ // A start script or Procfile is a "this is meant to be deployed and run"
275
+ // signal a prototype rarely has - scripts/start.sh, a Procfile, bin/start.
276
+ if (has('scripts/start.sh') || has('Procfile') || has('bin/start')) signals.push('start script');
252
277
  if (signals.length >= 2) return fact('production','low',signals);
253
278
  if (signals.length === 1) return fact('pre-launch','low',signals);
254
279
  return fact('prototype','low',['no deploy signals']);
@@ -263,7 +288,7 @@ export function detectProfile(repo, opts = {}) {
263
288
  const framework = detectFramework(repo, deps);
264
289
  const database = detectDatabase(repo, deps, root);
265
290
  const auth = detectAuth(repo, deps);
266
- const host = detectHost(repo, deps);
291
+ const host = detectHost(repo, deps, root);
267
292
  const llm = detectLlm(repo, deps);
268
293
  const accounts = detectAccounts(repo, deps, auth);
269
294
 
@@ -279,10 +304,10 @@ export function detectProfile(repo, opts = {}) {
279
304
  tenancy: (() => { const l = detectTenancy(repo, accounts.value);
280
305
  return (['none','single-user'].includes(l.value) && root)
281
306
  ? detectTenancy(root, accounts.value) : l; })(),
282
- stage: detectStage(repo, host),
307
+ stage: detectStage(repo, host, root),
283
308
  // cheap extras that gate real rules
284
309
  has_migrations: repo.has(/migrations?\//) ? fact(true,'high',['migrations dir']) : fact(false,'low',[]),
285
- has_ci: repo.has(/^\.github\/workflows\//) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
310
+ has_ci: (repo.has(/^\.github\/workflows\//) || root?.has(/(^|\/)\.github\/workflows\//)) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
286
311
  sends_email: dep(deps,'resend','@sendgrid/mail','nodemailer','postmark','@aws-sdk/client-ses')
287
312
  ? fact(true,'high',['email sdk']) : fact(false,'low',[]),
288
313
  handles_payments: dep(deps,'stripe','@stripe/stripe-js') ? fact('stripe','high',['stripe sdk'])
package/src/index.mjs CHANGED
@@ -202,6 +202,15 @@ function printDelta(fresh, moreRan) {
202
202
  const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
203
203
  const p = (s = '') => process.stdout.write(s + '\n');
204
204
 
205
+ // Nothing new RAN is different from checks running and passing. When an answer
206
+ // points away from a regime - "other" means no specific privacy law, "no"
207
+ // means the AI cannot act - the checks it would unlock simply do not apply, or
208
+ // they need the deep scan. Saying "they all passed" there is a small lie.
209
+ if (moreRan === 0) {
210
+ p(`\n ${C.d}Your answers unlocked no more free checks here — the ones they point to`);
211
+ p(` either don't apply to what you said, or need the deep scan.${C.o}\n`);
212
+ return;
213
+ }
205
214
  p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
206
215
  if (!fresh.length) {
207
216
  p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
package/src/report.mjs CHANGED
@@ -13,7 +13,13 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
13
13
  const p = (s = '') => L.push(s);
14
14
  const pr = lead.profile;
15
15
 
16
- p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${repo}${C.off}\n`);
16
+ // Collapse the home directory to ~, so the header does not print the user's
17
+ // OS username and full folder layout. It is only shown in their own terminal,
18
+ // but a screenshot or a pasted report would otherwise carry it. process.env
19
+ // (not node:os) so nothing new is imported and the read-only guard is happy.
20
+ const home = process.env.HOME || process.env.USERPROFILE || '';
21
+ const shownPath = home && repo.startsWith(home + '/') ? '~' + repo.slice(home.length) : repo;
22
+ p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${shownPath}${C.off}\n`);
17
23
 
18
24
  // ---------- what this app is ----------
19
25
  p(`${C.bold}What we found${C.off}`);