launchprep 0.0.1 → 0.1.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.
- package/README.md +76 -12
- package/bin/launchprep.mjs +24 -0
- package/net/client.mjs +40 -0
- package/net/commands.mjs +156 -0
- package/net/consent.mjs +57 -0
- package/net/credentials.mjs +33 -0
- package/package.json +27 -12
- package/scripts/verify-readonly.mjs +80 -0
- package/src/brand.mjs +13 -0
- package/src/checks-ai.mjs +255 -0
- package/src/checks-auth.mjs +263 -0
- package/src/checks-authz.mjs +179 -0
- package/src/checks-batch2.mjs +385 -0
- package/src/checks-batch3.mjs +327 -0
- package/src/checks-batch4.mjs +529 -0
- package/src/checks-deploy.mjs +272 -0
- package/src/checks-frameworks.mjs +337 -0
- package/src/checks.mjs +209 -0
- package/src/detect.mjs +304 -0
- package/src/digest.mjs +169 -0
- package/src/fs-scan.mjs +118 -0
- package/src/gate.mjs +103 -0
- package/src/index.mjs +71 -0
- package/src/report.mjs +101 -0
- package/src/rules.json +3651 -0
- package/src/workspace.mjs +0 -0
- package/bin/cli.js +0 -11
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Remaining tier-1 checks: data, deployment, framework config, real-user readiness.
|
|
2
|
+
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
3
|
+
({ id, title, severity, file, line, detail, fix });
|
|
4
|
+
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
5
|
+
const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
6
|
+
const isProdSettings = (p) =>
|
|
7
|
+
/settings/i.test(p) && !/local|dev|development|test|example|template/i.test(p);
|
|
8
|
+
|
|
9
|
+
export const BATCH3_CHECKS = [
|
|
10
|
+
|
|
11
|
+
// ---- data & scale --------------------------------------------------------
|
|
12
|
+
{ id: 'DATA-001', run(repo, profile) {
|
|
13
|
+
if (profile.stack?.database === 'none') return [];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const f of repo.files) {
|
|
16
|
+
if (!f.text || !isJS(f.path)) continue;
|
|
17
|
+
if (!/\/(api|routes?|server|actions?)\//.test(f.path)) continue;
|
|
18
|
+
// match the call site, then read a window - a whole-call regex cannot
|
|
19
|
+
// survive nested objects like { where: { active: true } }
|
|
20
|
+
const re = /\.findMany\s*\(|\.select\s*\(\s*['"`]\*/g;
|
|
21
|
+
let m;
|
|
22
|
+
while ((m = re.exec(f.text))) {
|
|
23
|
+
const around = f.text.slice(m.index, m.index + 320) +
|
|
24
|
+
f.text.slice(Math.max(0, m.index - 200), m.index);
|
|
25
|
+
if (/take\s*:|limit\s*:|\.limit\s*\(|\.range\s*\(|paginat/i.test(around)) continue;
|
|
26
|
+
if (/count|aggregate|findFirst|\.single\(/.test(around)) continue;
|
|
27
|
+
out.push(finding('DATA-001', 'List query with no limit', 'high',
|
|
28
|
+
f.path, lineOf(f.text, m.index),
|
|
29
|
+
'This returns every matching row. Fine with fifty records, a timeout with fifty thousand — and the failure only appears once you have real users.',
|
|
30
|
+
'Add a limit and a cursor, and cap whatever the caller can ask for.'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}},
|
|
35
|
+
|
|
36
|
+
{ id: 'DATA-004', run(repo, profile) {
|
|
37
|
+
const SERVERLESS = ['vercel', 'netlify', 'cloudflare'];
|
|
38
|
+
if (!SERVERLESS.includes(profile.stack?.host)) return [];
|
|
39
|
+
if (!['postgres', 'mysql', 'planetscale'].includes(profile.stack?.database)) return [];
|
|
40
|
+
const pooled = repo.grep(/pgbouncer|pooler\.|connection_limit|:6543|@neondatabase|planetscale|prisma-accelerate|\bpool\b/i,
|
|
41
|
+
/\.(ts|js|mjs|env|prisma|ya?ml|json)$/).length;
|
|
42
|
+
if (pooled) return [];
|
|
43
|
+
const f = repo.files.find(x => x.text && /(schema\.prisma|db\.(ts|js)|database\.(ts|js)|drizzle\.config)/.test(x.path));
|
|
44
|
+
if (!f) return [];
|
|
45
|
+
return [finding('DATA-004', 'No connection pooling on a serverless host', 'high',
|
|
46
|
+
f.path, 1,
|
|
47
|
+
`Every ${profile.stack.host} invocation opens its own database connection. Under ordinary traffic you exhaust the connection limit and the database starts refusing everyone, including you.`,
|
|
48
|
+
'Route through a pooler — PgBouncer, Supabase\'s pooler on 6543, Neon, or Prisma Accelerate.')];
|
|
49
|
+
}},
|
|
50
|
+
|
|
51
|
+
{ id: 'DATA-005', run(repo, profile) {
|
|
52
|
+
if (profile.stack?.database === 'none') return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const f of repo.files) {
|
|
55
|
+
if (!f.text || !/\.(ts|js|mjs|py|rb)$/.test(f.path)) continue;
|
|
56
|
+
const re = /SELECT\s+\*\s+FROM\s+(\w+)/gi;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(f.text))) {
|
|
59
|
+
if (/count\(|exists|information_schema|pg_/i.test(f.text.slice(m.index, m.index + 160))) continue;
|
|
60
|
+
out.push(finding('DATA-005', `SELECT * on ${m[1]}`, 'medium',
|
|
61
|
+
f.path, lineOf(f.text, m.index),
|
|
62
|
+
'Every column travels over the wire, including ones you never read and any you add later — password hashes and internal flags among them.',
|
|
63
|
+
'Name the columns you actually use.'));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}},
|
|
68
|
+
|
|
69
|
+
// ---- deployment ----------------------------------------------------------
|
|
70
|
+
{ id: 'DEP-005', run(repo, profile) {
|
|
71
|
+
if (!profile.has_migrations || profile.stage !== 'production') return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
const dirs = repo.find(/migrations?\//);
|
|
74
|
+
const hasDown = dirs.some(f => /down|rollback|revert/i.test(f.path)) ||
|
|
75
|
+
repo.grep(/def downgrade|\.down\s*=|DROP.*-- rollback|def self\.down/i, /\.(py|rb|ts|js|sql)$/).length;
|
|
76
|
+
if (hasDown || !dirs.length) return [];
|
|
77
|
+
return [finding('DEP-005', 'Migrations have no rollback path', 'medium',
|
|
78
|
+
dirs[0].path, 1,
|
|
79
|
+
'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.',
|
|
80
|
+
'Write a down step for each migration, or use a tool that generates one.')];
|
|
81
|
+
}},
|
|
82
|
+
|
|
83
|
+
{ id: 'DEP-014', run(repo, profile) {
|
|
84
|
+
if (profile.stage !== 'production') return [];
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const f of repo.files) {
|
|
87
|
+
if (!f.text || !/(next\.config|vite\.config|webpack\.config|nuxt\.config)/.test(f.path)) continue;
|
|
88
|
+
const re = /(productionBrowserSourceMaps\s*:\s*true|sourcemap\s*:\s*true|devtool\s*:\s*['"]source-map)/g;
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = re.exec(f.text))) {
|
|
91
|
+
out.push(finding('DEP-014', 'Source maps published in production', 'medium',
|
|
92
|
+
f.path, lineOf(f.text, m.index),
|
|
93
|
+
'Source maps let anyone reconstruct your original source from the deployed bundle — comments, variable names, internal logic and all.',
|
|
94
|
+
'Generate them for your error reporter but do not serve them publicly.'));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}},
|
|
99
|
+
|
|
100
|
+
{ id: 'DEP-009', run(repo, profile) {
|
|
101
|
+
if (profile.stage !== 'production' || !profile.has_background_jobs) return [];
|
|
102
|
+
if (repo.grep(/SIGTERM|SIGINT|gracefulShutdown|on\(['"]exit/).length) return [];
|
|
103
|
+
const f = repo.files.find(x => x.text && /(server|index|main|worker|app)\.(ts|js|mjs|py|rb)$/.test(x.path));
|
|
104
|
+
if (!f) return [];
|
|
105
|
+
return [finding('DEP-009', 'No graceful shutdown', 'medium',
|
|
106
|
+
f.path, 1,
|
|
107
|
+
'On every deploy the process is killed mid-flight. Jobs that were running are lost, and half-finished work stays half-finished.',
|
|
108
|
+
'Handle SIGTERM: stop accepting new work, let in-flight work finish, then exit.')];
|
|
109
|
+
}},
|
|
110
|
+
|
|
111
|
+
// ---- django config -------------------------------------------------------
|
|
112
|
+
{ id: 'DJ-007', run(repo, profile) {
|
|
113
|
+
if (profile.stack?.framework !== 'django') return [];
|
|
114
|
+
if (profile.stage !== 'production' || !profile.is_public) return [];
|
|
115
|
+
const settings = repo.files.filter(f => f.text && /\.py$/.test(f.path) && isProdSettings(f.path));
|
|
116
|
+
if (!settings.length) return [];
|
|
117
|
+
const all = settings.map(f => f.text).join('\n');
|
|
118
|
+
if (/SECURE_SSL_REDIRECT\s*=\s*True/.test(all)) return [];
|
|
119
|
+
return [finding('DJ-007', 'SECURE_SSL_REDIRECT is not enabled', 'high',
|
|
120
|
+
settings[0].path, 1,
|
|
121
|
+
'Django will serve over plain HTTP if asked. The first request of a session carries the session cookie in clear text.',
|
|
122
|
+
'SECURE_SSL_REDIRECT = True, and set SECURE_PROXY_SSL_HEADER if you sit behind a proxy.')];
|
|
123
|
+
}},
|
|
124
|
+
|
|
125
|
+
{ id: 'DJ-009', run(repo, profile) {
|
|
126
|
+
if (profile.stack?.framework !== 'django') return [];
|
|
127
|
+
if (profile.stage !== 'production' || !profile.is_public) return [];
|
|
128
|
+
const settings = repo.files.filter(f => f.text && /\.py$/.test(f.path) && isProdSettings(f.path));
|
|
129
|
+
if (!settings.length) return [];
|
|
130
|
+
const all = settings.map(f => f.text).join('\n');
|
|
131
|
+
const m = /SECURE_HSTS_SECONDS\s*=\s*(\d+)/.exec(all);
|
|
132
|
+
if (m && Number(m[1]) > 0) return [];
|
|
133
|
+
return [finding('DJ-009', 'HSTS not configured', 'medium',
|
|
134
|
+
settings[0].path, 1,
|
|
135
|
+
'Without HSTS a browser will try plain HTTP first every time, which is the window an attacker on the same network needs.',
|
|
136
|
+
'SECURE_HSTS_SECONDS = 31536000, plus SECURE_HSTS_INCLUDE_SUBDOMAINS.')];
|
|
137
|
+
}},
|
|
138
|
+
|
|
139
|
+
{ id: 'DJ-013', run(repo, profile) {
|
|
140
|
+
if (profile.stack?.framework !== 'django' || !profile.is_public) return [];
|
|
141
|
+
const settings = repo.files.filter(f => f.text && /\.py$/.test(f.path) &&
|
|
142
|
+
isProdSettings(f.path) && /MIDDLEWARE\s*=/.test(f.text));
|
|
143
|
+
if (!settings.length) return [];
|
|
144
|
+
const bad = settings.find(f => !/XFrameOptionsMiddleware/.test(f.text) ||
|
|
145
|
+
/X_FRAME_OPTIONS\s*=\s*['"]ALLOWALL/.test(f.text));
|
|
146
|
+
if (!bad) return [];
|
|
147
|
+
return [finding('DJ-013', 'Clickjacking protection disabled', 'medium',
|
|
148
|
+
bad.path, 1,
|
|
149
|
+
'Django enables this by default, so its absence was deliberate. Without it your pages can be framed invisibly over a lookalike site and clicks stolen.',
|
|
150
|
+
'Keep django.middleware.clickjacking.XFrameOptionsMiddleware in MIDDLEWARE.')];
|
|
151
|
+
}},
|
|
152
|
+
|
|
153
|
+
{ id: 'DJ-012', run(repo, profile) {
|
|
154
|
+
if (profile.stack?.framework !== 'django' || !profile.has_accounts) return [];
|
|
155
|
+
for (const f of repo.files) {
|
|
156
|
+
if (!f.text || !/\.py$/.test(f.path) || !isProdSettings(f.path)) continue;
|
|
157
|
+
const m = /AUTH_PASSWORD_VALIDATORS\s*=\s*\[\s*\]/.exec(f.text);
|
|
158
|
+
if (!m) continue;
|
|
159
|
+
return [finding('DJ-012', 'Password validators removed', 'medium',
|
|
160
|
+
f.path, lineOf(f.text, m.index),
|
|
161
|
+
'Django ships four validators — length, common passwords, all-numeric, similarity to the username. An empty list accepts "1234".',
|
|
162
|
+
'Restore the default validator list.')];
|
|
163
|
+
}
|
|
164
|
+
return [];
|
|
165
|
+
}},
|
|
166
|
+
|
|
167
|
+
// ---- next.js -------------------------------------------------------------
|
|
168
|
+
{ id: 'NEXT-009', run(repo, profile) {
|
|
169
|
+
if (profile.stack?.framework !== 'next') return [];
|
|
170
|
+
for (const f of repo.files) {
|
|
171
|
+
if (!f.text || !/next\.config/.test(f.path)) continue;
|
|
172
|
+
const m = /hostname\s*:\s*['"]\*\*?['"]|domains\s*:\s*\[\s*['"]\*/.exec(f.text);
|
|
173
|
+
if (!m) continue;
|
|
174
|
+
return [finding('NEXT-009', 'Image optimizer accepts any remote host', 'medium',
|
|
175
|
+
f.path, lineOf(f.text, m.index),
|
|
176
|
+
'Anyone can point your optimizer at any URL and make your server fetch and resize it — bandwidth and compute on your bill, from their request.',
|
|
177
|
+
'List the hostnames you actually load images from.')];
|
|
178
|
+
}
|
|
179
|
+
return [];
|
|
180
|
+
}},
|
|
181
|
+
|
|
182
|
+
{ id: 'NEXT-011', run(repo, profile) {
|
|
183
|
+
if (profile.stack?.framework !== 'next') return [];
|
|
184
|
+
if (repo.has(/app\/.*error\.(tsx|jsx|js)$|global-error\.(tsx|jsx|js)$/)) return [];
|
|
185
|
+
if (!repo.has(/app\/(layout|page)\.(tsx|jsx|js)$/)) return [];
|
|
186
|
+
return [finding('NEXT-011', 'No error boundary', 'medium',
|
|
187
|
+
'app/error.tsx', 1,
|
|
188
|
+
'When a component throws, the user gets Next.js\'s default screen instead of anything you wrote. In production that is a blank page and a lost customer.',
|
|
189
|
+
'Add app/error.tsx and app/global-error.tsx with something recoverable.')];
|
|
190
|
+
}},
|
|
191
|
+
|
|
192
|
+
// ---- rails ---------------------------------------------------------------
|
|
193
|
+
{ id: 'RB-014', run(repo, profile) {
|
|
194
|
+
if (profile.stack?.auth !== 'devise' || !profile.is_public) return [];
|
|
195
|
+
const models = repo.grep(/devise\s+:/, /\.rb$/);
|
|
196
|
+
if (!models.length) return [];
|
|
197
|
+
if (models.some(f => /:lockable/.test(f.text))) return [];
|
|
198
|
+
const f = models[0];
|
|
199
|
+
return [finding('RB-014', 'Devise configured without :lockable', 'medium',
|
|
200
|
+
f.path, lineOf(f.text, f.text.search(/devise\s+:/)),
|
|
201
|
+
'Devise ships no brute-force lockout enabled. Nothing stops unlimited password guesses against every account you have.',
|
|
202
|
+
'Add :lockable to the devise line and configure maximum_attempts.')];
|
|
203
|
+
}},
|
|
204
|
+
|
|
205
|
+
// ---- real users ----------------------------------------------------------
|
|
206
|
+
{ id: 'RU-004', run(repo, profile) {
|
|
207
|
+
if (profile.handles_payments === 'none') return [];
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const f of repo.files) {
|
|
210
|
+
if (!f.text || !/\.(sql|prisma|ts)$/.test(f.path)) continue;
|
|
211
|
+
if (!/CREATE TABLE|pgTable\s*\(|^model\s+\w+/mi.test(f.text)) continue;
|
|
212
|
+
const re = /(?:^|[(,\s])["`]?(\w*(?:amount|price|total|subtotal)\w*)["`]?\s*[:\s]/gim;
|
|
213
|
+
let m, flagged = false;
|
|
214
|
+
while ((m = re.exec(f.text)) && !flagged) {
|
|
215
|
+
const block = f.text.slice(Math.max(0, m.index - 700), m.index + 700);
|
|
216
|
+
if (/currency|iso_?4217|\bccy\b/i.test(block)) continue;
|
|
217
|
+
flagged = true;
|
|
218
|
+
out.push(finding('RU-004', `Money column "${m[1]}" with no currency alongside it`, 'high',
|
|
219
|
+
f.path, lineOf(f.text, m.index),
|
|
220
|
+
'A number without a currency is not an amount. The day you take a second currency, every historical row becomes ambiguous and there is no way to reconstruct which was which.',
|
|
221
|
+
'Store a currency code next to every monetary column.'));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}},
|
|
226
|
+
|
|
227
|
+
{ id: 'RU-019', run(repo, profile) {
|
|
228
|
+
if (!profile.has_accounts || profile.stack?.auth !== 'custom') return [];
|
|
229
|
+
const sessions = repo.grep(/session|jwt\.sign|createToken/i, /\.(ts|js|mjs)$/);
|
|
230
|
+
if (!sessions.length) return [];
|
|
231
|
+
if (repo.grep(/expiresIn|maxAge|expires_at|exp\s*:|ttl/i, /\.(ts|js|mjs)$/).length) return [];
|
|
232
|
+
return [finding('RU-019', 'Sessions never expire', 'medium',
|
|
233
|
+
sessions[0].path, 1,
|
|
234
|
+
'A token with no expiry is valid until someone revokes it — which nothing here does. A session stolen today still works next year.',
|
|
235
|
+
'Set an expiry, and refresh it while the user is active.')];
|
|
236
|
+
}},
|
|
237
|
+
|
|
238
|
+
{ id: 'RU-010', run(repo, profile) {
|
|
239
|
+
if (!profile.is_public || profile.stage !== 'production') return [];
|
|
240
|
+
if (profile.surface === 'library') return [];
|
|
241
|
+
const has = repo.grep(/mailto:|support@|help@|contact@|\/contact\b/i,
|
|
242
|
+
/\.(tsx|jsx|html|erb|vue|svelte|md|py|rb)$/).length;
|
|
243
|
+
if (has) return [];
|
|
244
|
+
return [finding('RU-010', 'No way for a customer to reach you', 'medium',
|
|
245
|
+
'contact details', 1,
|
|
246
|
+
'A user with a billing problem and no contact address disputes the charge instead. That costs you the sale, a fee, and a mark against your dispute rate.',
|
|
247
|
+
'Put a monitored email address in the footer and on the receipt.')];
|
|
248
|
+
}},
|
|
249
|
+
|
|
250
|
+
// ---- uploads -------------------------------------------------------------
|
|
251
|
+
{ id: 'UP-007', run(repo, profile) {
|
|
252
|
+
if (!profile.has_file_uploads) return [];
|
|
253
|
+
const out = [];
|
|
254
|
+
for (const f of repo.files) {
|
|
255
|
+
if (!f.text || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
|
|
256
|
+
const re = /(?:createSignedUrl|getSignedUrl|generate_presigned_url|expiresIn|ExpiresIn|expires_in)/g;
|
|
257
|
+
let m;
|
|
258
|
+
while ((m = re.exec(f.text))) {
|
|
259
|
+
// the lifetime is somewhere in the call, not necessarily adjacent
|
|
260
|
+
const win = f.text.slice(m.index, m.index + 160);
|
|
261
|
+
const num = win.match(/\b(\d{5,})\b/);
|
|
262
|
+
if (!num) continue;
|
|
263
|
+
const secs = Number(num[1]);
|
|
264
|
+
if (secs <= 86400) continue; // a day is defensible
|
|
265
|
+
const days = Math.round(secs / 86400);
|
|
266
|
+
out.push(finding('UP-007', `Signed URL valid for about ${days} days`, 'medium',
|
|
267
|
+
f.path, lineOf(f.text, m.index),
|
|
268
|
+
'A signed URL is a bearer token in a link. Anything that long survives being pasted into a chat, forwarded, or logged by a proxy.',
|
|
269
|
+
'Minutes, not days. Re-sign on demand instead.'));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}},
|
|
274
|
+
|
|
275
|
+
// ---- ai ------------------------------------------------------------------
|
|
276
|
+
{ id: 'AIOP-002', run(repo, profile) {
|
|
277
|
+
if (!profile.calls_llm || profile.stage !== 'production') return [];
|
|
278
|
+
const out = [];
|
|
279
|
+
const ALIASES = /['"]((?:claude|gpt|gemini)[a-z0-9.-]*(?:latest|preview))['"]/gi;
|
|
280
|
+
for (const f of repo.files) {
|
|
281
|
+
if (!f.text || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
|
|
282
|
+
let m; ALIASES.lastIndex = 0;
|
|
283
|
+
while ((m = ALIASES.exec(f.text))) {
|
|
284
|
+
out.push(finding('AIOP-002', `Model pinned to a moving alias: ${m[1]}`, 'medium',
|
|
285
|
+
f.path, lineOf(f.text, m.index),
|
|
286
|
+
'An alias points at whatever is current. Behaviour, output shape and price can all change under you with no deploy and no warning.',
|
|
287
|
+
'Pin an explicit model version and upgrade deliberately.'));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}},
|
|
292
|
+
|
|
293
|
+
{ id: 'AI-011', run(repo, profile) {
|
|
294
|
+
if (!profile.calls_llm || profile.stage !== 'production') return [];
|
|
295
|
+
if (repo.grep(/maxRetries|max_retries|retry|backoff|p-retry|tenacity/i).length) return [];
|
|
296
|
+
const site = repo.grep(/\.messages\.create|\.chat\.completions\.create/)[0];
|
|
297
|
+
if (!site) return [];
|
|
298
|
+
return [finding('AI-011', 'No retry on rate-limit responses', 'medium',
|
|
299
|
+
site.path, 1,
|
|
300
|
+
'Providers return 429 under load. With no backoff, a brief rate limit surfaces to your user as a broken feature.',
|
|
301
|
+
'Retry with exponential backoff — the SDKs take maxRetries directly.')];
|
|
302
|
+
}},
|
|
303
|
+
|
|
304
|
+
// ---- observability & discoverability -------------------------------------
|
|
305
|
+
{ id: 'UX-005', run(repo, profile) {
|
|
306
|
+
if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
|
|
307
|
+
const has = repo.grep(/og:image|twitter:image|openGraph/i,
|
|
308
|
+
/\.(tsx|jsx|html|erb|vue|svelte|ts|js)$/).length;
|
|
309
|
+
if (has) return [];
|
|
310
|
+
const f = repo.files.find(x => x.text && /(layout|index|_app|_document)\.(tsx|jsx|html|js)$/.test(x.path));
|
|
311
|
+
if (!f) return [];
|
|
312
|
+
return [finding('UX-005', 'No social preview image', 'low',
|
|
313
|
+
f.path, 1,
|
|
314
|
+
'Shared anywhere — Slack, X, iMessage — your link renders as a bare grey box. It is the cheapest credibility you will ever buy.',
|
|
315
|
+
'Add og:image and twitter:image tags with a 1200x630 image.')];
|
|
316
|
+
}},
|
|
317
|
+
|
|
318
|
+
{ id: 'INC-004', run(repo, profile) {
|
|
319
|
+
if (!profile.is_public || profile.stage !== 'production') return [];
|
|
320
|
+
if (repo.has(/security\.txt$|SECURITY\.md$/i)) return [];
|
|
321
|
+
if (repo.grep(/security@/i).length) return [];
|
|
322
|
+
return [finding('INC-004', 'No security contact', 'medium',
|
|
323
|
+
'SECURITY.md', 1,
|
|
324
|
+
'A researcher who finds a flaw in your app has no way to tell you. What they do instead is post it publicly, or sell it.',
|
|
325
|
+
'Add a SECURITY.md and a /.well-known/security.txt with an address you read.')];
|
|
326
|
+
}},
|
|
327
|
+
];
|