launchprep 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ // The questions the scanner asks when it cannot tell something from the code,
2
+ // written for the person who reads the report.
3
+ //
4
+ // The scanner can gate on 33 facts. Five of them had wording; the other 28
5
+ // printed their internal name — a customer saw "? llm_tools_enabled" and
6
+ // "? has_webhooks_in" as the last thing on screen. The stated audience is
7
+ // people who cannot read code, and those are not questions, they are variable
8
+ // names a programmer wrote for themselves.
9
+ //
10
+ // Each entry carries:
11
+ // ask the question, in words someone non-technical can answer
12
+ // answer what to write in launchprep.json, shown so the answer is obvious
13
+ // why what it unlocks, so the question does not feel like paperwork
14
+ //
15
+ // The rule for the wording is the same one the findings follow: name the
16
+ // consequence, not the mechanism. "Can people sign in?" not "has_accounts".
17
+ export const QUESTION = {
18
+ // --- who and where -------------------------------------------------------
19
+ jurisdictions: {
20
+ ask: 'Where do your users live?',
21
+ answer: '"jurisdictions": ["eu"] — any of: eu, uk, us, ca, ru, kz, other',
22
+ why: 'decides which privacy laws reach you at all',
23
+ },
24
+ business_model: {
25
+ ask: 'Who is this for — businesses, consumers, or a marketplace?',
26
+ answer: '"business_model": "b2b-saas" — b2b-saas, b2c, marketplace, internal',
27
+ why: 'a marketplace holds other people’s money and has different duties',
28
+ },
29
+ expected_scale: {
30
+ ask: 'Roughly how many users do you expect in the first year?',
31
+ answer: '"expected_scale": "under-100k" — under-1k, under-100k, over-100k',
32
+ why: 'what is safe at 100 users falls over at 100,000',
33
+ },
34
+ audience_locale: {
35
+ ask: 'Do you serve more than one language or region?',
36
+ answer: '"audience_locale": "multi" — single, multi',
37
+ why: 'dates, times and text break first when you cross a border',
38
+ },
39
+ serves_currency: {
40
+ ask: 'Do you charge in more than one currency?',
41
+ answer: '"serves_currency": "multi" — single, multi',
42
+ why: 'rounding and exchange rates are where money quietly goes missing',
43
+ },
44
+ data_sensitivity: {
45
+ ask: 'Do you hold anything especially sensitive — money, health, or data about children?',
46
+ answer: '"data_sensitivity": "financial" — normal, financial, health, children',
47
+ why: 'these carry duties ordinary personal data does not',
48
+ },
49
+
50
+ // --- what the app is -----------------------------------------------------
51
+ surface: {
52
+ ask: 'What is it — a website, a web app, an API, or a mobile app?',
53
+ answer: '"surface": "web-app" — web-site, web-app, api-only, mobile-ios, mobile-android, cli, library',
54
+ why: 'a static site cannot have most of these problems, and is not asked about them',
55
+ },
56
+ stage: {
57
+ ask: 'Is this live, about to launch, or still a prototype?',
58
+ answer: '"stage": "pre-launch" — prototype, pre-launch, production',
59
+ why: 'a prototype is not asked about backups; something about to launch is',
60
+ },
61
+ is_public: {
62
+ ask: 'Can anyone on the internet reach it, or is it internal only?',
63
+ answer: '"is_public": true',
64
+ why: 'an internal tool has a very different attack surface',
65
+ },
66
+ tenancy: {
67
+ ask: 'Do separate companies or teams share one database?',
68
+ answer: '"tenancy": "multi-tenant-shared-db" — single-tenant, multi-tenant-shared-db, multi-tenant-isolated',
69
+ why: 'this is the setting behind "one customer can read another customer’s data"',
70
+ },
71
+
72
+ // --- accounts and access -------------------------------------------------
73
+ has_accounts: {
74
+ ask: 'Can people sign in?',
75
+ answer: '"has_accounts": true',
76
+ why: 'unlocks every check about logins, sessions and passwords',
77
+ },
78
+ has_roles: {
79
+ ask: 'Do some users have more power than others — admin, editor, viewer?',
80
+ answer: '"has_roles": true',
81
+ why: 'unlocks the checks about someone giving themselves permissions',
82
+ },
83
+ has_admin_panel: {
84
+ ask: 'Is there an admin area only you and your staff can reach?',
85
+ answer: '"has_admin_panel": true',
86
+ why: 'an admin page left open is the shortest path to everything',
87
+ },
88
+ has_invitations: {
89
+ ask: 'Can users invite other people to join them?',
90
+ answer: '"has_invitations": true',
91
+ why: 'invite links are commonly guessable or never expire',
92
+ },
93
+ stack_auth: {
94
+ ask: 'How do people sign in — your own code, or a service?',
95
+ answer: '"stack": { "auth": "supabase-auth" } — custom, supabase-auth, clerk, auth0, nextauth, devise',
96
+ why: 'hand-written login code is checked far more strictly',
97
+ },
98
+
99
+ // --- money ---------------------------------------------------------------
100
+ handles_payments: {
101
+ ask: 'Do you take payments, and through whom?',
102
+ answer: '"handles_payments": "stripe" — none, stripe, paddle, lemonsqueezy, other',
103
+ why: 'unlocks the checks about webhooks, refunds and money stored as decimals',
104
+ },
105
+ has_subscriptions: {
106
+ ask: 'Do people pay you repeatedly — a subscription?',
107
+ answer: '"has_subscriptions": true',
108
+ why: 'failed renewals and cancellations are where subscription apps leak money',
109
+ },
110
+
111
+ // --- AI ------------------------------------------------------------------
112
+ calls_llm: {
113
+ ask: 'Does your app call an AI model?',
114
+ answer: '"calls_llm": true',
115
+ why: 'unlocks the AI checks — runaway costs, prompt injection, model output',
116
+ },
117
+ llm_input_from_user: {
118
+ ask: 'Does anything a user types get sent to the AI?',
119
+ answer: '"llm_input_from_user": true',
120
+ why: 'this is the difference between a prompt you wrote and one a stranger did',
121
+ },
122
+ llm_tools_enabled: {
123
+ ask: 'Can the AI do things on its own — send email, call an API, change data?',
124
+ answer: '"llm_tools_enabled": true',
125
+ why: 'an AI that can only talk is a very different risk from one that can act',
126
+ },
127
+ has_rag: {
128
+ ask: 'Does the AI read from your own documents or database to answer?',
129
+ answer: '"has_rag": true',
130
+ why: 'documents the AI reads can carry instructions that hijack it',
131
+ },
132
+
133
+ // --- how it runs ---------------------------------------------------------
134
+ sends_email: {
135
+ ask: 'Does your app send email to users?',
136
+ answer: '"sends_email": true',
137
+ why: 'unlocks deliverability, unsubscribe duties and password-reset checks',
138
+ },
139
+ has_file_uploads: {
140
+ ask: 'Can users upload files or images?',
141
+ answer: '"has_file_uploads": true',
142
+ why: 'uploads are how a stranger gets a file onto your server',
143
+ },
144
+ has_background_jobs: {
145
+ ask: 'Does work happen in the background — queues, scheduled tasks?',
146
+ answer: '"has_background_jobs": true',
147
+ why: 'a job that fails silently is a job nobody knows failed',
148
+ },
149
+ has_realtime: {
150
+ ask: 'Is there anything live — chat, notifications, a shared cursor?',
151
+ answer: '"has_realtime": true',
152
+ why: 'live connections are authorised differently from ordinary pages',
153
+ },
154
+ has_webhooks_in: {
155
+ ask: 'Do other services send data INTO your app automatically?',
156
+ answer: '"has_webhooks_in": true',
157
+ why: 'an unverified webhook is an open door anyone can post through',
158
+ },
159
+ collects_analytics: {
160
+ ask: 'Do you track how people use the app?',
161
+ answer: '"collects_analytics": true',
162
+ why: 'tracking without consent is the most common privacy fine',
163
+ },
164
+
165
+ // --- the plumbing --------------------------------------------------------
166
+ stack_database: {
167
+ ask: 'What stores your data?',
168
+ answer: '"stack": { "database": "postgres" } — postgres, supabase, mysql, planetscale, mongodb, sqlite, none',
169
+ why: 'each has its own way of leaking one customer’s rows to another',
170
+ },
171
+ stack_framework: {
172
+ ask: 'What is it built with?',
173
+ answer: '"stack": { "framework": "next" } — next, react, vue, django, rails, express, laravel, other',
174
+ why: 'unlocks the checks specific to that framework’s own footguns',
175
+ },
176
+ stack_host: {
177
+ ask: 'Where does it run?',
178
+ answer: '"stack": { "host": "vercel" } — vercel, netlify, cloudflare, railway, fly, aws, gcp, vps, other',
179
+ why: 'a serverless host and a rented server fail in opposite ways',
180
+ },
181
+ stack_orm: {
182
+ ask: 'How does your code talk to the database — a library, or hand-written SQL?',
183
+ answer: '"stack": { "orm": "prisma" } — prisma, drizzle, typeorm, sequelize, activerecord, raw-sql, none',
184
+ why: 'hand-written SQL is checked much harder for injection',
185
+ },
186
+ has_migrations: {
187
+ ask: 'Do you have database migrations — versioned schema changes?',
188
+ answer: '"has_migrations": true',
189
+ why: 'a migration that cannot be undone is a deploy that cannot be undone',
190
+ },
191
+ has_ci: {
192
+ ask: 'Does anything run your tests automatically when you push?',
193
+ answer: '"has_ci": true',
194
+ why: 'unlocks the checks about what your pipeline does and does not catch',
195
+ },
196
+ };
197
+
198
+ // gate.mjs uses dotted names (stack.auth); JSON and this file use underscores
199
+ // for the top level. One map, so neither side has to know about the other.
200
+ const DOTTED = { stack_auth: 'stack.auth', stack_database: 'stack.database',
201
+ stack_framework: 'stack.framework', stack_host: 'stack.host',
202
+ stack_orm: 'stack.orm' };
203
+ const UNDERSCORED = Object.fromEntries(Object.entries(DOTTED).map(([k, v]) => [v, k]));
204
+
205
+ export const questionFor = (fact) => QUESTION[UNDERSCORED[fact] || fact] || null;
206
+ export const factNames = () => Object.keys(QUESTION).map(k => DOTTED[k] || k);
package/src/redact.mjs CHANGED
@@ -28,7 +28,12 @@ const PEM = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?
28
28
 
29
29
  // 2. Credentials embedded in a connection URL: scheme://user:pass@host.
30
30
  // Keep the scheme and host (useful shape), mask only user:pass.
31
- const CONN = /\b((?:postgres|postgresql|mysql|mysql2|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|mariadb):\/\/)([^\s:@/]+):([^\s@/]+)@/gi;
31
+ //
32
+ // http and ssh belong here as much as postgres does. A git remote with a
33
+ // token in it (https://bot:ghp_live...@github.com), an SMTP URL holding a
34
+ // provider key, a Grafana or Elasticsearch URL - all were uploaded intact
35
+ // while the privacy page promised the value never leaves the machine.
36
+ const CONN = /\b((?:postgres|postgresql|mysql|mysql2|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|mariadb|https?|smtps?|ftps?|sftp|ssh|ldaps?|clickhouse|elasticsearch):\/\/)([^\s:@/]+):([^\s@/]+)@/gi;
32
37
 
33
38
  // 3. Provider keys by their published shape. pk_ (Stripe publishable) is left
34
39
  // alone on purpose — it is designed to be public, and masking it was one of
@@ -54,7 +59,7 @@ const SHAPES = [
54
59
  // and tokens that have no distinctive shape. It fires only on string
55
60
  // literals, and holds back where the value is plainly not a secret: an env
56
61
  // reference, a template placeholder, an interpolation, an empty string.
57
- const SECRET_NAME = /(?:pass(?:word|wd)?|pwd|secret|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|token|credentials?|passphrase|dsn|encryption[_-]?key|signing[_-]?key|session[_-]?secret|db[_-]?pass(?:word)?)/i;
62
+ const SECRET_NAME = /(?:pass(?:word|wd)?|pwd|secret[_-]?key|secret|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|token|credentials?|passphrase|dsn|encryption[_-]?key|signing[_-]?key|session[_-]?secret|db[_-]?pass(?:word)?)/i;
58
63
  const ASSIGN = new RegExp(
59
64
  '(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)(["\'`])([^"\'`\\n]{6,}?)\\2',
60
65
  'gi',
package/src/report.mjs CHANGED
@@ -1,18 +1,14 @@
1
1
  import { BRAND } from './brand.mjs';
2
+ import { questionFor } from './questions.mjs';
2
3
 
3
4
  const C = { red:'\x1b[31m', yel:'\x1b[33m', blu:'\x1b[34m', gry:'\x1b[90m',
4
5
  bold:'\x1b[1m', dim:'\x1b[2m', grn:'\x1b[32m', cyn:'\x1b[36m', off:'\x1b[0m' };
5
6
  const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'MEDIUM'], low:[C.gry,'LOW'] };
6
7
 
7
- const QUESTION = {
8
- business_model: 'Who is this for consumers, businesses, a marketplace, or internal use?',
9
- jurisdictions: 'Where are your users? (EU / UK / US / other — this decides which privacy rules apply)',
10
- expected_scale: 'How many users do you expect — under 1k, under 100k, or more?',
11
- serves_currency: 'Do you charge in more than one currency?',
12
- audience_locale: 'Do you serve more than one language or region?',
13
- };
8
+ // The wording lives in questions.mjs — 33 facts, not the 5 that had text.
9
+ // A customer used to read "? llm_tools_enabled" as the last line of a scan.
14
10
 
15
- export function render({ repo, scanned, lead, findings, questions, shallow = [] }) {
11
+ export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null }) {
16
12
  const L = [];
17
13
  const p = (s = '') => L.push(s);
18
14
  const pr = lead.profile;
@@ -28,6 +24,21 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
28
24
  row('Type', pr.surface, ev('surface'));
29
25
  row('Stack', [pr.stack.framework, pr.stack.database, pr.stack.host].filter(Boolean).join(' · ') || '—');
30
26
  row('Accounts', pr.has_accounts ? 'yes' : 'no', ev('has_accounts'));
27
+
28
+ // What the user stated beats what we detected, so it must be VISIBLE. A wrong
29
+ // answer silently changing which checks run would be worse than no answers
30
+ // at all — they would never find out why something was skipped.
31
+ if (stated && Object.keys(stated).length) {
32
+ const flat = [];
33
+ for (const [k, v] of Object.entries(stated)) {
34
+ if (v && typeof v === 'object' && !Array.isArray(v))
35
+ for (const [k2, v2] of Object.entries(v)) flat.push(`${k}.${k2}=${v2}`);
36
+ else flat.push(`${k}=${Array.isArray(v) ? v.join('/') : v}`);
37
+ }
38
+ p(` ${C.gry}${'You said'.padEnd(10)}${C.off}${flat.slice(0, 6).join(' ')}` +
39
+ `${flat.length > 6 ? ` ${C.dim}+${flat.length - 6} more${C.off}` : ''}` +
40
+ ` ${C.dim}(from launchprep.json)${C.off}`);
41
+ }
31
42
  if (pr.tenancy !== 'none') row('Tenancy', pr.tenancy, ev('tenancy'));
32
43
  if (pr.calls_llm) row('AI', (pr.llm_providers || []).join(', ') || 'yes', ev('calls_llm'));
33
44
  if (pr.handles_payments !== 'none') row('Payments', pr.handles_payments);
@@ -78,7 +89,16 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
78
89
  // ---------- what we did not check, and why ----------
79
90
  const g = lead.gate;
80
91
  p(`\n${C.bold}Coverage${C.off}`);
81
- p(` ${C.bold}${g.evaluated.length}${C.off} of ${g.total} checks apply to this app`);
92
+ // Say what RAN, not what applies. The difference is the tier-2 rules, which
93
+ // need a model to read the code — counting those as "applied" reported a
94
+ // pass on checks nothing performed.
95
+ if (coverage) {
96
+ p(` ${C.bold}${coverage.ran}${C.off} of ${g.total} checks ran here`);
97
+ if (coverage.needsDeep)
98
+ p(` ${C.bold}${coverage.needsDeep}${C.off} more apply to you but need a deep scan ${C.dim}(they need a model to read the code)${C.off}`);
99
+ } else {
100
+ p(` ${C.bold}${g.evaluated.length}${C.off} of ${g.total} checks apply to this app`);
101
+ }
82
102
  p(` ${C.dim}${g.skipped.length} skipped — they don't fit what you built${C.off}`);
83
103
 
84
104
  const bySkipFact = {};
@@ -92,10 +112,19 @@ export function render({ repo, scanned, lead, findings, questions, shallow = []
92
112
  // ---------- the three questions ----------
93
113
  if (questions.length) {
94
114
  p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
95
- for (const [factName, n] of questions)
96
- p(` ${C.cyn}?${C.off} ${QUESTION[factName] || factName} ${C.dim}(unlocks ${n})${C.off}`);
115
+ for (const [factName, n] of questions) {
116
+ const q = questionFor(factName);
117
+ p(` ${C.cyn}?${C.off} ${q ? q.ask : factName} ${C.dim}(unlocks ${n})${C.off}`);
118
+ if (q) p(` ${C.dim}${q.answer}${C.off}`);
119
+ }
97
120
  }
98
121
 
99
- p(`\n ${C.dim}Anything above wrong? Correct it and the checks adjust.${C.off}\n`);
122
+ // This used to say "Correct it and the checks adjust" with no way on earth to
123
+ // do so — no flag, no file, no prompt. It was the last line of every scan and
124
+ // it invited an action that did not exist. Now it names the file that works.
125
+ if (questions.length)
126
+ p(`\n ${C.dim}Put those in ${C.off}launchprep.json${C.dim} at the top of your project and run again — the answers unlock the checks above.${C.off}\n`);
127
+ else
128
+ p(`\n ${C.dim}Wrong about your app? Correct it in ${C.off}launchprep.json${C.dim} and run again.${C.off}\n`);
100
129
  return L.join('\n');
101
130
  }