launchprep 0.3.0 → 0.5.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.
@@ -0,0 +1,72 @@
1
+ // Is this value a real credential, or something that only looks like one?
2
+ //
3
+ // This is the seventh-and-eighth instance of the mistake this codebase keeps
4
+ // making: matching a SHAPE and calling it a secret. Scanning its own repository
5
+ // produced five CRITICALs and every one was this —
6
+ //
7
+ // .github/workflows/ci.yml postgresql://postgres:postgres@localhost/..._test
8
+ // cli/src/git-history.mjs /^AKIAIOSFODNN7EXAMPLE$/ (a detection pattern)
9
+ // cli/src/checks-*.mjs the regexes that look for model output
10
+ //
11
+ // None is a credential. The first is the throwaway database every project with
12
+ // CI tests spins up; the second is AWS's own published example; the third is
13
+ // source code that describes a pattern rather than containing a secret.
14
+ //
15
+ // The discipline is the one written at the top of the project guide: read the
16
+ // VALUE, and the line it sits on, not the shape. A leaked key is high-entropy,
17
+ // points at a real host, and is not sitting inside a regex.
18
+ //
19
+ // The redaction pass in redact.mjs learned the same lesson separately. This is
20
+ // the shared version so a fix in one place is a fix in both.
21
+
22
+ // The value spells out that it is not real.
23
+ const FAKE_WORDS = /(example|sample|dummy|placeholder|fake|changeme|redacted|notreal|donotuse|yourkey|your[_-]key|xxx+|test[_-]?key|foobar)/i;
24
+
25
+ // Sequences a human types when inventing a key.
26
+ const TYPED_BY_HAND = /abcdefghij|0123456789|qwerty|lorem|(.)\1{7,}/i;
27
+
28
+ // A substitution means the real value is somewhere else: ${VAR}, {{var}},
29
+ // [password], <password>, $VAR, %s.
30
+ const SUBSTITUTION = /[$%]\{|\{\{|\[[a-z][a-z0-9_.-]*\]|<[a-z][a-z0-9_.-]*>|\$[A-Z_][A-Z0-9_]*/i;
31
+
32
+ // A connection string that points nowhere real.
33
+ const LOCAL_OR_SERVICE_HOST =
34
+ /@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|example\.(?:com|org|net)|postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|db|database)(?:[-_][a-z0-9-]+)?(?::\d+)?(?:[/?]|$)/i;
35
+ // user and password are the same word, or are the words for themselves.
36
+ const DEFAULT_CREDENTIALS =
37
+ /:\/\/([a-z0-9_-]+):\1@|:\/\/(?:user|username|admin|root|foo|bar|test|me)[^:@/]*:(?:pass|password|passwd|secret|hunter2|foo|bar|test)/i;
38
+ // a password that announces it is for testing
39
+ const TEST_CREDENTIALS = /:\/\/[^:@/]+:[^@/]*(?:test|demo|local|dev|dummy|ci|example)[^@/]*@/i;
40
+
41
+ // AWS publishes these two in its own documentation. They are in a thousand
42
+ // READMEs and are not anyone's key.
43
+ const AWS_DOC_KEYS = /^(AKIAIOSFODNN7EXAMPLE|ASIAIOSFODNN7EXAMPLE)$/;
44
+
45
+ /**
46
+ * @param value the matched string
47
+ * @param line the whole line it appeared on (optional, but much better with it)
48
+ * @param path the file path (optional)
49
+ */
50
+ export function looksLikePlaceholder(value, line = '', path = '') {
51
+ const v = String(value);
52
+
53
+ if (FAKE_WORDS.test(v) || TYPED_BY_HAND.test(v) || SUBSTITUTION.test(v)) return true;
54
+ if (AWS_DOC_KEYS.test(v)) return true;
55
+ if (LOCAL_OR_SERVICE_HOST.test(v) || DEFAULT_CREDENTIALS.test(v) || TEST_CREDENTIALS.test(v)) return true;
56
+
57
+ const l = String(line);
58
+ // The line DEFINES a pattern rather than holding a secret: a regex literal,
59
+ // a list of shapes to look for. This is what made the scanner flag itself.
60
+ if (/\/\^|\^.*\$\/|\\b|\\d|\\w|\[A-Za-z0-9|\[0-9A-Z|RegExp\(|\.test\(|\.match\(/.test(l)) return true;
61
+ // A comment, or documentation showing the format.
62
+ if (/^\s*(?:[#*]|\/\/|--)/.test(l)) return true;
63
+ // An assertion or fixture.
64
+ if (/expect\(|assert|describe\(|it\(['"`]|toBe\(|toEqual\(/.test(l)) return true;
65
+
66
+ const p = String(path);
67
+ // A test file or a fixture directory is not where real credentials live.
68
+ if (/(^|\/)(test|tests|__tests__|spec|e2e|cypress|fixtures?|mocks?|__mocks__|examples?)\//.test(p)) return true;
69
+ if (/\.(test|spec)\.[a-z]+$/.test(p)) return true;
70
+
71
+ return false;
72
+ }
@@ -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 ADDED
@@ -0,0 +1,127 @@
1
+ // Strip secret VALUES out of a file before it is ever put in the upload digest.
2
+ //
3
+ // The deep scan sends a digest of the user's code to our server and on to
4
+ // Anthropic. The privacy page promises credentials are never transmitted, and
5
+ // until this module existed that promise was false: a backup.sql full of card
6
+ // numbers, a docker-compose.yml with a plaintext password, or a key hardcoded
7
+ // in source went up verbatim. Only .env was excluded.
8
+ //
9
+ // This runs in the CLI, on the user's machine, BEFORE anything leaves it — so a
10
+ // masked secret never travels at all, not even to us. That is the only correct
11
+ // place for it; redacting server-side would mean the secret already left.
12
+ //
13
+ // The rule of thumb is the inverse of the scanner's. The scanner must not cry
14
+ // wolf, so it needs evidence before it fires. Redaction is the opposite: a
15
+ // masked non-secret costs a little context, a leaked real secret is the whole
16
+ // failure. So this leans aggressive on things SHAPED like secrets, and only
17
+ // holds back where masking would destroy code the scan genuinely needs — a
18
+ // reference like `process.env.STRIPE_KEY` is good code, not a leak, and stays.
19
+ //
20
+ // What the tier-2 rules ask about — authorization, tenancy, deletion, spend —
21
+ // is never answered by the literal value of a key or a row of customer data, so
22
+ // nothing here reduces what the scan can find.
23
+
24
+ const TAG = (kind) => `«redacted:${kind}»`;
25
+
26
+ // 1. PEM private-key blocks — the whole block, header to footer.
27
+ const PEM = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g;
28
+
29
+ // 2. Credentials embedded in a connection URL: scheme://user:pass@host.
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;
32
+
33
+ // 3. Provider keys by their published shape. pk_ (Stripe publishable) is left
34
+ // alone on purpose — it is designed to be public, and masking it was one of
35
+ // the original false positives this whole product learned from.
36
+ const SHAPES = [
37
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, 'stripe-key'],
38
+ [/\bwhsec_[A-Za-z0-9]{20,}/g, 'webhook-secret'],
39
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
40
+ [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, 'openai-key'],
41
+ [/\bAKIA[0-9A-Z]{16}\b/g, 'aws-access-key'],
42
+ [/\bASIA[0-9A-Z]{16}\b/g, 'aws-temp-key'],
43
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, 'google-key'],
44
+ [/\bya29\.[0-9A-Za-z_-]{20,}/g, 'google-oauth'],
45
+ [/\bgh[pousr]_[A-Za-z0-9]{36,}/g, 'github-token'],
46
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, 'gitlab-token'],
47
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
48
+ [/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, 'sendgrid-key'],
49
+ [/\bre_[A-Za-z0-9_-]{20,}/g, 'resend-key'],
50
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, 'jwt'],
51
+ ];
52
+
53
+ // 4. A value assigned to a secret-NAMED identifier. This catches the passwords
54
+ // and tokens that have no distinctive shape. It fires only on string
55
+ // literals, and holds back where the value is plainly not a secret: an env
56
+ // 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;
58
+ const ASSIGN = new RegExp(
59
+ '(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)(["\'`])([^"\'`\\n]{6,}?)\\2',
60
+ 'gi',
61
+ );
62
+ // values that are NOT secrets even though the key name matched
63
+ const NOT_A_SECRET = /^(?:process\.env|import\.meta|os\.environ|ENV\[|Deno\.env|\$\{|\$[A-Z(]|<[A-Za-z%{]|\{\{|%[A-Za-z]|your[_-]|change[_-]?me|placeholder|example|dummy|test[_-]?key|xxx+|\*{3,}|\.{3,}|null|undefined|true|false|none|n\/?a)/i;
64
+
65
+ // 4b. The same, unquoted: `POSTGRES_PASSWORD: hunter2` / `DB_PASS=hunter2` /
66
+ // Dockerfile `ENV SECRET hunter2`. This is how .env, YAML, Dockerfiles,
67
+ // Terraform and .ini files carry secrets, and none of them quote. Applied
68
+ // ONLY to those config files — in real code a bare `password: foo` is a
69
+ // variable reference (foo), and masking it would delete a name the scan
70
+ // wants. The value runs to end of line.
71
+ // colon or equals, spaced or not: `PASSWORD: hunter2` (yaml), `SECRET=hunter2`
72
+ // (.env), `token = "..."` handled by the quoted rule above.
73
+ const ASSIGN_BARE = new RegExp(
74
+ '(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)([^\\s"\'`#][^\\n#]*?)(\\s*(?:#.*)?)$',
75
+ 'gim',
76
+ );
77
+ // Dockerfile / shell: `ENV DB_PASSWORD hunter2`, `ARG API_TOKEN=hunter2`. The
78
+ // secret word is a substring of the identifier, and the value is one token.
79
+ const DOCKER_ENV = new RegExp(
80
+ '\\b(ENV|ARG|export)\\s+([A-Za-z0-9_]*' + SECRET_NAME.source + '[A-Za-z0-9_]*[\\s=]+)(["\']?)([^\\s"\']+)\\3',
81
+ 'gi',
82
+ );
83
+ const CONFIGISH = /(^|\/)(\.env|.*\.env|.*\.ya?ml|.*\.tf|.*\.tfvars|.*\.toml|.*\.ini|.*\.conf|.*\.properties|Dockerfile[^/]*|.*\.dockerfile)$/i;
84
+
85
+ // 5. Bulk data in a SQL dump. The schema the scan needs comes from CREATE TABLE
86
+ // and ALTER; the rows do not, and the rows are where the customer PII and any
87
+ // secret literals in a dump actually live.
88
+ const SQL_INSERT = /(\bINSERT\s+INTO\s+[^\n(]+?)(\([^)]*\)\s*)?\bVALUES\b[\s\S]*?;/gi;
89
+ const SQL_COPY = /(\bCOPY\s+[^\n]+?FROM\s+stdin\s*;)[\s\S]*?\n\\\.$/gim;
90
+
91
+ export function redact(text, path = '') {
92
+ if (!text) return { text, count: 0 };
93
+ let count = 0;
94
+ const bump = (n = 1) => { count += n; };
95
+
96
+ let out = text.replace(PEM, () => (bump(), TAG('private-key')));
97
+
98
+ out = out.replace(CONN, (_, scheme) => (bump(), `${scheme}${TAG('credentials')}@`));
99
+
100
+ for (const [re, kind] of SHAPES) {
101
+ out = out.replace(re, () => (bump(), TAG(kind)));
102
+ }
103
+
104
+ out = out.replace(ASSIGN, (m, head, q, val) => {
105
+ if (NOT_A_SECRET.test(val)) return m;
106
+ bump();
107
+ return `${head}${q}${TAG('secret')}${q}`;
108
+ });
109
+
110
+ if (CONFIGISH.test(path)) {
111
+ out = out.replace(ASSIGN_BARE, (m, head, val, trail) => {
112
+ if (NOT_A_SECRET.test(val)) return m;
113
+ bump();
114
+ return `${head}${TAG('secret')}${trail}`;
115
+ });
116
+ out = out.replace(DOCKER_ENV, (m, kw, name, q, val) => {
117
+ if (NOT_A_SECRET.test(val)) return m;
118
+ bump();
119
+ return `${kw} ${name}${q}${TAG('secret')}${q}`;
120
+ });
121
+ }
122
+
123
+ out = out.replace(SQL_INSERT, (m, head) => (bump(), `${head}VALUES ${TAG('rows')};`));
124
+ out = out.replace(SQL_COPY, (_, head) => (bump(), `${head} ${TAG('rows')}\n\\.`));
125
+
126
+ return { text: out, count };
127
+ }
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
  }