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.
- package/README.md +21 -0
- package/net/client.mjs +7 -0
- package/net/commands.mjs +22 -1
- package/package.json +2 -2
- package/src/check-helpers.mjs +43 -0
- package/src/checks-ai.mjs +31 -7
- package/src/checks-auth.mjs +1 -4
- package/src/checks-authz.mjs +1 -3
- package/src/checks-batch2.mjs +4 -9
- package/src/checks-batch3.mjs +1 -3
- package/src/checks-batch4.mjs +5 -9
- package/src/checks-deploy.mjs +9 -15
- package/src/checks-frameworks.mjs +1 -3
- package/src/checks-injection.mjs +1 -15
- package/src/checks.mjs +83 -14
- package/src/detect.mjs +10 -3
- package/src/fs-scan.mjs +58 -13
- package/src/git-history.mjs +255 -0
- package/src/index.mjs +52 -4
- package/src/placeholder.mjs +72 -0
- package/src/questions.mjs +206 -0
- package/src/redact.mjs +7 -2
- package/src/report.mjs +41 -12
- package/src/rules.json +1 -1
- package/src/workspace.mjs +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|