launchprep 0.5.2 → 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 +16 -8
- package/src/checks-batch2.mjs +2 -2
- package/src/checks-batch3.mjs +7 -2
- package/src/checks-batch4.mjs +37 -4
- package/src/detect.mjs +40 -15
- package/src/index.mjs +39 -22
- package/src/interactive.mjs +1 -13
- package/src/report.mjs +11 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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
|
-
"
|
|
39
|
+
"scanner",
|
|
32
40
|
"checklist"
|
|
33
41
|
],
|
|
34
42
|
"homepage": "https://launchprep.dev",
|
package/src/checks-batch2.mjs
CHANGED
|
@@ -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) ||
|
package/src/checks-batch3.mjs
CHANGED
|
@@ -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 [];
|
package/src/checks-batch4.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
251
|
-
if (
|
|
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
|
@@ -5,7 +5,7 @@ import { detectProfile, toGateProfile } from './detect.mjs';
|
|
|
5
5
|
import { gate, missingFacts } from './gate.mjs';
|
|
6
6
|
import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
|
|
7
7
|
import { render } from './report.mjs';
|
|
8
|
-
import { askQuestions
|
|
8
|
+
import { askQuestions } from './interactive.mjs';
|
|
9
9
|
import { questionFor } from './questions.mjs';
|
|
10
10
|
import { existsSync, statSync } from 'node:fs';
|
|
11
11
|
|
|
@@ -153,28 +153,36 @@ if (asJson) {
|
|
|
153
153
|
shallow: r.shallow.map(s => ({ id: s.id, title: s.title, severity: s.severity })),
|
|
154
154
|
}, null, 2));
|
|
155
155
|
} else {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
// Will we ask the questions in the terminal? Only in a real TTY, only when
|
|
157
|
+
// there is something to unlock, and never on --json / --yes / --no-interactive
|
|
158
|
+
// or a pipe. Decided BEFORE rendering so the report can drop the "write
|
|
159
|
+
// launchprep.json" instructions it would otherwise duplicate.
|
|
160
|
+
const willPrompt = !noInteractive && process.stdin.isTTY && process.stdout.isTTY && r.questions.length > 0;
|
|
161
|
+
|
|
162
|
+
process.stdout.write(render({ repo: target, ...r, stated, interactive: willPrompt }));
|
|
163
|
+
|
|
164
|
+
if (willPrompt) {
|
|
165
|
+
// No yes/no gate: a user typed their answers into the old [Y/n] and it read
|
|
166
|
+
// the whole sentence as "no". Go straight to the questions instead, each one
|
|
167
|
+
// clearly skippable with Enter, so there is only ever one thing to type.
|
|
162
168
|
const unlocks = r.questions.reduce((n, [, c]) => n + c, 0);
|
|
163
|
-
|
|
164
|
-
`\n \x1b[
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
`\n \x1b[36m${r.questions.length} question${r.questions.length === 1 ? '' : 's'} unlock up to ` +
|
|
171
|
+
`${unlocks} more checks\x1b[0m \x1b[2m— they can't be read from your code. ` +
|
|
172
|
+
`Type a number for each, or press Enter to skip.\x1b[0m\n`);
|
|
173
|
+
|
|
174
|
+
const answers = await askQuestions(r.questions.map(([fact]) => fact));
|
|
175
|
+
if (Object.keys(answers).length) {
|
|
176
|
+
const before = new Set(r.findings.map(keyOf));
|
|
177
|
+
const ranBefore = r.coverage.ran;
|
|
178
|
+
stated = { ...(fileStated || {}), ...answers };
|
|
179
|
+
r = analyze(stated);
|
|
180
|
+
const fresh = r.findings.filter(f => !before.has(keyOf(f)));
|
|
181
|
+
printDelta(fresh, r.coverage.ran - ranBefore);
|
|
182
|
+
} else {
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
`\n \x1b[2mSkipped — the report above is unchanged. You can also put the answers in\x1b[0m` +
|
|
185
|
+
` launchprep.json \x1b[2mand run again.\x1b[0m\n\n`);
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
}
|
|
@@ -194,6 +202,15 @@ function printDelta(fresh, moreRan) {
|
|
|
194
202
|
const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
|
|
195
203
|
const p = (s = '') => process.stdout.write(s + '\n');
|
|
196
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
|
+
}
|
|
197
214
|
p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
|
|
198
215
|
if (!fresh.length) {
|
|
199
216
|
p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
|
package/src/interactive.mjs
CHANGED
|
@@ -72,7 +72,7 @@ export async function askQuestions(facts, { input = process.stdin, output = proc
|
|
|
72
72
|
// is worse than being asked again.
|
|
73
73
|
let value = null;
|
|
74
74
|
while (value === null) {
|
|
75
|
-
const raw = (await ask(` ${C.b}›${C.o} `)).trim().toLowerCase();
|
|
75
|
+
const raw = (await ask(` ${C.d}number, or Enter to skip${C.o} ${C.b}›${C.o} `)).trim().toLowerCase();
|
|
76
76
|
if (raw === '') break; // skip this one
|
|
77
77
|
const n = Number(raw);
|
|
78
78
|
if (Number.isInteger(n) && n >= 1 && n <= shape.choices.length) value = shape.choices[n - 1];
|
|
@@ -86,15 +86,3 @@ export async function askQuestions(facts, { input = process.stdin, output = proc
|
|
|
86
86
|
}
|
|
87
87
|
return stated;
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
// The yes/no gate before any of it. Returns true only on an explicit yes.
|
|
91
|
-
export async function confirm(prompt, { input = process.stdin, output = process.stdout, readLine } = {}) {
|
|
92
|
-
const rl = readLine ? null : createInterface({ input, output });
|
|
93
|
-
const ask = readLine || ((q) => new Promise(res => rl.question(q, res)));
|
|
94
|
-
try {
|
|
95
|
-
const raw = (await ask(prompt)).trim().toLowerCase();
|
|
96
|
-
return raw === '' || raw === 'y' || raw === 'yes'; // default yes
|
|
97
|
-
} finally {
|
|
98
|
-
rl?.close();
|
|
99
|
-
}
|
|
100
|
-
}
|
package/src/report.mjs
CHANGED
|
@@ -8,12 +8,18 @@ const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'M
|
|
|
8
8
|
// The wording lives in questions.mjs — 33 facts, not the 5 that had text.
|
|
9
9
|
// A customer used to read "? llm_tools_enabled" as the last line of a scan.
|
|
10
10
|
|
|
11
|
-
export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null }) {
|
|
11
|
+
export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null, interactive = false }) {
|
|
12
12
|
const L = [];
|
|
13
13
|
const p = (s = '') => L.push(s);
|
|
14
14
|
const pr = lead.profile;
|
|
15
15
|
|
|
16
|
-
|
|
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}`);
|
|
@@ -130,7 +136,7 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
130
136
|
p(` ${C.dim}${String(n).padStart(3)} because ${f} is ${JSON.stringify(lead.profile[f] ?? lead.profile.stack?.[f.split('.').pop()])}${C.off}`);
|
|
131
137
|
|
|
132
138
|
// ---------- the three questions ----------
|
|
133
|
-
if (questions.length) {
|
|
139
|
+
if (questions.length && !interactive) {
|
|
134
140
|
p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
|
|
135
141
|
for (const [factName, n] of questions) {
|
|
136
142
|
const q = questionFor(factName);
|
|
@@ -142,9 +148,9 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
142
148
|
// This used to say "Correct it and the checks adjust" with no way on earth to
|
|
143
149
|
// do so — no flag, no file, no prompt. It was the last line of every scan and
|
|
144
150
|
// it invited an action that did not exist. Now it names the file that works.
|
|
145
|
-
if (questions.length)
|
|
151
|
+
if (questions.length && !interactive)
|
|
146
152
|
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`);
|
|
147
|
-
else
|
|
153
|
+
else if (!interactive)
|
|
148
154
|
p(`\n ${C.dim}Wrong about your app? Correct it in ${C.off}launchprep.json${C.dim} and run again.${C.off}\n`);
|
|
149
155
|
return L.join('\n');
|
|
150
156
|
}
|