launchprep 0.5.1 → 0.5.3
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 +2 -2
- package/src/checks-auth.mjs +17 -3
- package/src/index.mjs +139 -67
- package/src/interactive.mjs +88 -0
- package/src/report.mjs +27 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
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",
|
|
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",
|
|
21
21
|
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
22
|
"postpack": "node scripts/restore-after-publish.mjs"
|
|
23
23
|
},
|
package/src/checks-auth.mjs
CHANGED
|
@@ -115,11 +115,25 @@ export const AUTH_CHECKS = [
|
|
|
115
115
|
const out = [];
|
|
116
116
|
for (const f of repo.files) {
|
|
117
117
|
if (!f.text || !isCode(f.path)) continue;
|
|
118
|
-
const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)
|
|
118
|
+
const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)\s*\.update\s*\(\s*([A-Za-z0-9_$.]+)?/g;
|
|
119
119
|
let m;
|
|
120
120
|
while ((m = re.exec(f.text))) {
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Judge what is being HASHED, not a 400-char neighbourhood. On real
|
|
122
|
+
// code this fired CRITICAL on `hashToken(raw){ createHash('sha256')
|
|
123
|
+
// .update(raw) }` because the word "password" appeared elsewhere in the
|
|
124
|
+
// file (a password-policy import). sha256 on a high-entropy token - a
|
|
125
|
+
// reset token, a session token, a CSRF value - is correct; on a user's
|
|
126
|
+
// password it is the vulnerability. The thing being hashed decides.
|
|
127
|
+
const arg = m[2] || '';
|
|
128
|
+
const TOKEN = /token|csrf|nonce|otp|session|reset|verif|magic|digest|\braw\b|hmac|signature|fingerprint|etag|checksum|api[_-]?key/i;
|
|
129
|
+
const PASS = /password|passwd|\bpwd\b|\bpass\b/i;
|
|
130
|
+
if (TOKEN.test(arg)) continue; // hashing a token, not a password
|
|
131
|
+
const ctx = f.text.slice(Math.max(0, m.index - 200), m.index + 80);
|
|
132
|
+
const ctxToken = /token|csrf|nonce|\botp\b|session|reset|verif|magic|fingerprint/i.test(ctx);
|
|
133
|
+
const ctxPass = /password|passwd|\bpwd\b/i.test(ctx);
|
|
134
|
+
// fire only when a password is what is hashed here: the argument names
|
|
135
|
+
// it, or the immediate surroundings do and are not about a token.
|
|
136
|
+
if (!(PASS.test(arg) || (ctxPass && !ctxToken))) continue;
|
|
123
137
|
out.push(finding('AUTH-008', `Passwords hashed with ${m[1]}`, 'critical',
|
|
124
138
|
f.path, lineOf(f.text, m.index),
|
|
125
139
|
`${m[1]} is built to be fast, which is exactly wrong for passwords — a modern GPU tries billions of guesses per second. If your database leaks, the passwords are recoverable.`,
|
package/src/index.mjs
CHANGED
|
@@ -5,11 +5,17 @@ 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 } from './interactive.mjs';
|
|
9
|
+
import { questionFor } from './questions.mjs';
|
|
8
10
|
import { existsSync, statSync } from 'node:fs';
|
|
9
11
|
|
|
10
12
|
const args = process.argv.slice(2);
|
|
11
13
|
const target = args.find(a => !a.startsWith('-')) || process.cwd();
|
|
12
14
|
const asJson = args.includes('--json');
|
|
15
|
+
// Opt out of the terminal Q&A: --yes (take defaults, ask nothing) or the
|
|
16
|
+
// explicit --no-interactive. It is also skipped automatically when there is no
|
|
17
|
+
// TTY, so a pipe or a CI job never blocks waiting on a keypress.
|
|
18
|
+
const noInteractive = args.includes('--yes') || args.includes('--no-interactive');
|
|
13
19
|
|
|
14
20
|
// --fail-on <severity>: the CI gate. Findings at or above the threshold turn
|
|
15
21
|
// the exit code to 1 so a pipeline can stop the deploy. Opt-in on purpose —
|
|
@@ -45,12 +51,10 @@ const packages = splitWorkspaces(repo);
|
|
|
45
51
|
//
|
|
46
52
|
// The scanner works most things out from the code, but some facts are simply
|
|
47
53
|
// not in the code — where the users live, whether the AI is allowed to act,
|
|
48
|
-
// how many people are expected. Those
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
// Reading a file the user wrote is still read-only. Nothing is written.
|
|
53
|
-
const stated = (() => {
|
|
54
|
+
// how many people are expected. Those come either from a launchprep.json the
|
|
55
|
+
// user wrote, or from the terminal Q&A below. Reading a file the user wrote is
|
|
56
|
+
// still read-only, and the Q&A only reads the keyboard: nothing is written.
|
|
57
|
+
const fileStated = (() => {
|
|
54
58
|
const raw = repo.read('launchprep.json');
|
|
55
59
|
if (raw === null) return null;
|
|
56
60
|
try {
|
|
@@ -67,7 +71,7 @@ const stated = (() => {
|
|
|
67
71
|
|
|
68
72
|
// Merge one level deep so { "stack": { "auth": "clerk" } } overrides only auth
|
|
69
73
|
// and leaves the detected framework and database alone.
|
|
70
|
-
const applyStated = (profile) => {
|
|
74
|
+
const applyStated = (profile, stated) => {
|
|
71
75
|
if (!stated) return profile;
|
|
72
76
|
const out = { ...profile };
|
|
73
77
|
for (const [k, v] of Object.entries(stated)) {
|
|
@@ -78,77 +82,145 @@ const applyStated = (profile) => {
|
|
|
78
82
|
return out;
|
|
79
83
|
};
|
|
80
84
|
|
|
81
|
-
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
82
|
-
// simply match very few rules, which is the correct outcome, not a bug.
|
|
83
|
-
const scanned = packages.map(w => {
|
|
84
|
-
const full = detectProfile(w.view, { root: repo });
|
|
85
|
-
const profile = applyStated(toGateProfile(full));
|
|
86
|
-
const g = gate(profile);
|
|
87
|
-
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
88
|
-
const findings = runChecks(w.view, profile, applicableIds)
|
|
89
|
-
.map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
|
|
90
|
-
return { name: w.name, full, profile, gate: g, findings };
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// The app most representative of this repo leads the report.
|
|
94
|
-
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
95
|
-
const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
|
|
96
|
-
|
|
97
|
-
// Root-scoped checks see the whole repo, once, regardless of how many
|
|
98
|
-
// workspaces it contains. Gated by the lead app's profile.
|
|
99
|
-
const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
|
|
100
|
-
const rootFindings = runRootChecks(repo, lead.profile, rootIds);
|
|
101
|
-
|
|
102
|
-
const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
|
|
103
|
-
const seen = new Set();
|
|
104
|
-
// Each workspace sorts its own findings, but concatenating several workspaces
|
|
105
|
-
// and the root-scoped checks destroys that order - a monorepo would show a LOW
|
|
106
|
-
// from apps/web above a CRITICAL from apps/api. Sort once, at the end.
|
|
107
85
|
const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
108
|
-
const findings = allFindings
|
|
109
|
-
.filter(f => {
|
|
110
|
-
const k = `${f.id}:${f.file}:${f.line}`;
|
|
111
|
-
if (seen.has(k)) return false;
|
|
112
|
-
seen.add(k); return true;
|
|
113
|
-
})
|
|
114
|
-
.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
115
|
-
|
|
116
|
-
// Eleven tier-2 rules have a cheap static approximation that ships free. When
|
|
117
|
-
// one of those finds nothing that is NOT a pass - a pattern cannot see an
|
|
118
|
-
// ownership check that lives in middleware. Reporting it as clean would tell
|
|
119
|
-
// someone they are safe when the check simply could not look. Say so instead.
|
|
120
|
-
const firedIds = new Set(findings.map(f => f.id));
|
|
121
|
-
const shallow = lead.gate.evaluated
|
|
122
|
-
.filter(r => r.has_static_approximation && !firedIds.has(r.id));
|
|
123
|
-
|
|
124
|
-
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
125
|
-
|
|
126
|
-
// "51 checks apply to this app" counted 17 that nothing examined — tier-2 rules
|
|
127
|
-
// that need the deep scan. Counting them as applying is a silent pass on a
|
|
128
|
-
// third of the headline number, which is the one thing this scanner must never
|
|
129
|
-
// do. It also threw away the sentence that sells the paid tier: those 17 are
|
|
130
|
-
// not a gap, they are the product.
|
|
131
86
|
const implemented = new Set(CHECKS.map(c => c.id));
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
87
|
+
const keyOf = (f) => `${f.id}:${f.file}:${f.line}`;
|
|
88
|
+
|
|
89
|
+
// One scan pass, given whatever the user has stated so far. Called once for the
|
|
90
|
+
// initial report and again after the terminal Q&A, so the answers can unlock
|
|
91
|
+
// more checks without asking the user to touch a file.
|
|
92
|
+
function analyze(stated) {
|
|
93
|
+
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
94
|
+
// simply match very few rules, which is the correct outcome, not a bug.
|
|
95
|
+
const scanned = packages.map(w => {
|
|
96
|
+
const full = detectProfile(w.view, { root: repo });
|
|
97
|
+
const profile = applyStated(toGateProfile(full), stated);
|
|
98
|
+
const g = gate(profile);
|
|
99
|
+
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
100
|
+
const findings = runChecks(w.view, profile, applicableIds)
|
|
101
|
+
.map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
|
|
102
|
+
return { name: w.name, full, profile, gate: g, findings };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The app most representative of this repo leads the report.
|
|
106
|
+
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
107
|
+
const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
|
|
108
|
+
|
|
109
|
+
// Root-scoped checks see the whole repo, once, regardless of how many
|
|
110
|
+
// workspaces it contains. Gated by the lead app's profile.
|
|
111
|
+
const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
|
|
112
|
+
const rootFindings = runRootChecks(repo, lead.profile, rootIds);
|
|
113
|
+
|
|
114
|
+
const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
// Concatenating several workspaces and the root-scoped checks destroys each
|
|
117
|
+
// one's own order - a monorepo would show a LOW from apps/web above a
|
|
118
|
+
// CRITICAL from apps/api. Sort once, at the end.
|
|
119
|
+
const findings = allFindings
|
|
120
|
+
.filter(f => { const k = keyOf(f); if (seen.has(k)) return false; seen.add(k); return true; })
|
|
121
|
+
.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
122
|
+
|
|
123
|
+
// Eleven tier-2 rules have a cheap static approximation that ships free. When
|
|
124
|
+
// one of those finds nothing that is NOT a pass - a pattern cannot see an
|
|
125
|
+
// ownership check that lives in middleware. Reporting it as clean would tell
|
|
126
|
+
// someone they are safe when the check simply could not look. Say so instead.
|
|
127
|
+
const firedIds = new Set(findings.map(f => f.id));
|
|
128
|
+
const shallow = lead.gate.evaluated.filter(r => r.has_static_approximation && !firedIds.has(r.id));
|
|
129
|
+
|
|
130
|
+
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
131
|
+
|
|
132
|
+
// "51 checks apply" counted tier-2 rules that nothing examined - a silent pass
|
|
133
|
+
// on a third of the headline number. Split them: what actually ran, and what
|
|
134
|
+
// still needs the deep scan (which is the product, not a gap).
|
|
135
|
+
const coverage = {
|
|
136
|
+
ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
|
|
137
|
+
needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { scanned, lead, findings, shallow, questions, coverage };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let stated = fileStated;
|
|
144
|
+
let r = analyze(stated);
|
|
136
145
|
|
|
137
146
|
if (asJson) {
|
|
138
147
|
console.log(JSON.stringify({
|
|
139
|
-
packages: scanned.map(s => ({
|
|
148
|
+
packages: r.scanned.map(s => ({
|
|
140
149
|
name: s.name, profile: s.profile,
|
|
141
150
|
evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
|
|
142
151
|
})),
|
|
143
|
-
findings, questions, coverage, stated,
|
|
144
|
-
shallow: shallow.map(
|
|
152
|
+
findings: r.findings, questions: r.questions, coverage: r.coverage, stated,
|
|
153
|
+
shallow: r.shallow.map(s => ({ id: s.id, title: s.title, severity: s.severity })),
|
|
145
154
|
}, null, 2));
|
|
146
155
|
} else {
|
|
147
|
-
|
|
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.
|
|
168
|
+
const unlocks = r.questions.reduce((n, [, c]) => n + c, 0);
|
|
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`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
148
188
|
}
|
|
149
189
|
|
|
150
|
-
// The gate, after
|
|
151
|
-
//
|
|
152
|
-
|
|
190
|
+
// The gate, after everything has printed: the human sees the whole report, the
|
|
191
|
+
// pipeline sees the verdict it asked for. Uses the final findings, so an answer
|
|
192
|
+
// that unlocked a critical still fails the build.
|
|
193
|
+
if (failOn && r.findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
|
|
153
194
|
process.exit(1);
|
|
154
195
|
}
|
|
196
|
+
|
|
197
|
+
// The extra findings the answers unlocked, grouped the same way the main report
|
|
198
|
+
// groups: one line per rule with a count, so a check that fired on many files
|
|
199
|
+
// does not bury the rest.
|
|
200
|
+
function printDelta(fresh, moreRan) {
|
|
201
|
+
const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', grn: '\x1b[32m', gry: '\x1b[90m' };
|
|
202
|
+
const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
|
|
203
|
+
const p = (s = '') => process.stdout.write(s + '\n');
|
|
204
|
+
|
|
205
|
+
p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
|
|
206
|
+
if (!fresh.length) {
|
|
207
|
+
p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const groups = [];
|
|
211
|
+
const byId = new Map();
|
|
212
|
+
for (const f of fresh) {
|
|
213
|
+
if (byId.has(f.id)) byId.get(f.id).push(f);
|
|
214
|
+
else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
|
|
215
|
+
}
|
|
216
|
+
p(` ${C.d}${fresh.length} new finding${fresh.length === 1 ? '' : 's'}:${C.o}`);
|
|
217
|
+
for (const g of groups) {
|
|
218
|
+
const f = g[0];
|
|
219
|
+
const more = g.length - 1;
|
|
220
|
+
p(`\n ${SEV[f.severity]}${C.b}${f.severity.toUpperCase()}${C.o} ${f.title}${more ? `${C.d} · ${g.length} places${C.o}` : ''}`);
|
|
221
|
+
p(` ${C.gry}${f.file}:${f.line}${C.o}`);
|
|
222
|
+
p(` ${f.detail}`);
|
|
223
|
+
p(` ${C.grn}Fix${C.o} ${f.fix}`);
|
|
224
|
+
}
|
|
225
|
+
p('');
|
|
226
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The optional questions, asked in the terminal instead of made into homework.
|
|
2
|
+
//
|
|
3
|
+
// The report ends with "put these in launchprep.json and run again" - which
|
|
4
|
+
// means writing JSON with keys like "llm_tools_enabled" before the extra checks
|
|
5
|
+
// unlock. For a one-off scan that is friction most people will not pay. This
|
|
6
|
+
// asks the same questions out loud, takes a number or a word, and re-runs with
|
|
7
|
+
// the answers held in memory.
|
|
8
|
+
//
|
|
9
|
+
// READ-ONLY, deliberately. It reads the keyboard and nothing else: no file is
|
|
10
|
+
// written, so launchprep.json is never created behind the user's back and the
|
|
11
|
+
// read-only promise holds. Power users who want the answers saved for CI still
|
|
12
|
+
// write the file themselves - that path is unchanged.
|
|
13
|
+
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { questionFor } from './questions.mjs';
|
|
16
|
+
|
|
17
|
+
const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', c: '\x1b[36m', g: '\x1b[32m' };
|
|
18
|
+
|
|
19
|
+
// Turn a QUESTION's `answer` example - the exact JSON a user would paste - into
|
|
20
|
+
// the shape of the question: which key it sets, whether it is a yes/no or a
|
|
21
|
+
// choice, and (for a choice) the options. Parsing the example rather than
|
|
22
|
+
// duplicating it keeps one source of truth: questions.mjs.
|
|
23
|
+
export function shapeOf(answerExample) {
|
|
24
|
+
const dash = answerExample.indexOf('—');
|
|
25
|
+
const json = (dash >= 0 ? answerExample.slice(0, dash) : answerExample).trim();
|
|
26
|
+
const rest = dash >= 0 ? answerExample.slice(dash + 1).trim() : '';
|
|
27
|
+
|
|
28
|
+
const nested = json.match(/"([\w-]+)"\s*:\s*\{\s*"([\w-]+)"/); // "stack": { "auth": ... }
|
|
29
|
+
const topKey = json.match(/^"([\w-]+)"/)?.[1] || null;
|
|
30
|
+
const isArray = /:\s*\[/.test(json);
|
|
31
|
+
const isBool = !nested && /:\s*(true|false)\s*$/.test(json) && !rest;
|
|
32
|
+
|
|
33
|
+
const choices = isBool
|
|
34
|
+
? ['yes', 'no']
|
|
35
|
+
: rest.replace(/^any of:\s*/i, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
// Fold the chosen value into a stated-shaped object, matching whatever the
|
|
38
|
+
// example demonstrated: nested (stack.auth), array (jurisdictions), boolean
|
|
39
|
+
// (has_rag), or a plain string (business_model).
|
|
40
|
+
const apply = (stated, value) => {
|
|
41
|
+
if (nested) stated[nested[1]] = { ...(stated[nested[1]] || {}), [nested[2]]: value };
|
|
42
|
+
else if (isArray) stated[topKey] = [value];
|
|
43
|
+
else if (isBool) stated[topKey] = (value === 'yes' || value === true);
|
|
44
|
+
else stated[topKey] = value;
|
|
45
|
+
return stated;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { choices, isBool, apply, ok: Boolean(topKey && choices.length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rlQuestion = (rl, q) => new Promise(res => rl.question(q, res));
|
|
52
|
+
|
|
53
|
+
// Ask up to `facts` in the terminal. Returns a stated-shaped object of the
|
|
54
|
+
// answers given; an empty object if the user answered nothing.
|
|
55
|
+
export async function askQuestions(facts, { input = process.stdin, output = process.stdout, readLine } = {}) {
|
|
56
|
+
const rl = readLine ? null : createInterface({ input, output });
|
|
57
|
+
const ask = readLine || ((prompt) => new Promise(res => rl.question(prompt, res)));
|
|
58
|
+
const stated = {};
|
|
59
|
+
try {
|
|
60
|
+
for (const fact of facts) {
|
|
61
|
+
const q = questionFor(fact);
|
|
62
|
+
if (!q) continue;
|
|
63
|
+
const shape = shapeOf(q.answer);
|
|
64
|
+
if (!shape.ok) continue;
|
|
65
|
+
|
|
66
|
+
output.write(`\n ${C.c}${q.ask}${C.o}\n`);
|
|
67
|
+
const numbered = shape.choices.map((c, i) => `${C.d}${i + 1})${C.o} ${c}`).join(' ');
|
|
68
|
+
output.write(` ${numbered}\n`);
|
|
69
|
+
|
|
70
|
+
// Accept a number, the literal value, or blank to skip. Re-ask on garbage
|
|
71
|
+
// rather than silently dropping it - a wrong answer the user thinks landed
|
|
72
|
+
// is worse than being asked again.
|
|
73
|
+
let value = null;
|
|
74
|
+
while (value === null) {
|
|
75
|
+
const raw = (await ask(` ${C.d}number, or Enter to skip${C.o} ${C.b}›${C.o} `)).trim().toLowerCase();
|
|
76
|
+
if (raw === '') break; // skip this one
|
|
77
|
+
const n = Number(raw);
|
|
78
|
+
if (Number.isInteger(n) && n >= 1 && n <= shape.choices.length) value = shape.choices[n - 1];
|
|
79
|
+
else if (shape.choices.includes(raw)) value = raw;
|
|
80
|
+
else output.write(` ${C.d}pick a number 1–${shape.choices.length}, or press Enter to skip${C.o}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (value !== null) shape.apply(stated, value);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
rl?.close();
|
|
86
|
+
}
|
|
87
|
+
return stated;
|
|
88
|
+
}
|
package/src/report.mjs
CHANGED
|
@@ -8,7 +8,7 @@ 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;
|
|
@@ -65,14 +65,34 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
65
65
|
const summary = ['critical', 'high', 'medium', 'low']
|
|
66
66
|
.filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(' · ');
|
|
67
67
|
p(` ${C.dim}${summary}${C.off}`);
|
|
68
|
-
|
|
68
|
+
|
|
69
|
+
// Group by rule. One check firing on 43 tables is ONE issue in 43 places,
|
|
70
|
+
// not 43 issues - listing each pushed the genuinely distinct findings off
|
|
71
|
+
// the bottom into "and N more", the drowning this tool exists to avoid.
|
|
72
|
+
// Findings arrive severity-sorted, so first-seen order keeps that order.
|
|
73
|
+
const groups = [];
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
for (const f of findings) {
|
|
76
|
+
if (byId.has(f.id)) byId.get(f.id).push(f);
|
|
77
|
+
else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
|
|
78
|
+
}
|
|
79
|
+
if (groups.length < findings.length)
|
|
80
|
+
p(` ${C.dim}${groups.length} distinct, across ${findings.length} places${C.off}`);
|
|
81
|
+
|
|
82
|
+
for (const g of groups.slice(0, 25)) {
|
|
83
|
+
const f = g[0];
|
|
69
84
|
const [col, label] = SEV[f.severity];
|
|
70
|
-
|
|
85
|
+
const more = g.length - 1;
|
|
86
|
+
p(`\n ${col}${C.bold}${label}${C.off} ${f.title}${more ? `${C.dim} · ${g.length} places${C.off}` : ''}`);
|
|
71
87
|
p(` ${C.gry}${f.file}:${f.line}${C.off}`);
|
|
88
|
+
if (more) {
|
|
89
|
+
const also = g.slice(1, 4).map(x => `${x.file}:${x.line}`).join(' ');
|
|
90
|
+
p(` ${C.dim}${also}${more > 3 ? ` + ${more - 3} more` : ''}${C.off}`);
|
|
91
|
+
}
|
|
72
92
|
p(` ${f.detail}`);
|
|
73
93
|
p(` ${C.grn}Fix${C.off} ${f.fix}`);
|
|
74
94
|
}
|
|
75
|
-
if (
|
|
95
|
+
if (groups.length > 25) p(`\n ${C.dim}… and ${groups.length - 25} more issues${C.off}`);
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
// ---------- checked, but only as far as a program can see ----------
|
|
@@ -110,7 +130,7 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
110
130
|
p(` ${C.dim}${String(n).padStart(3)} because ${f} is ${JSON.stringify(lead.profile[f] ?? lead.profile.stack?.[f.split('.').pop()])}${C.off}`);
|
|
111
131
|
|
|
112
132
|
// ---------- the three questions ----------
|
|
113
|
-
if (questions.length) {
|
|
133
|
+
if (questions.length && !interactive) {
|
|
114
134
|
p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
|
|
115
135
|
for (const [factName, n] of questions) {
|
|
116
136
|
const q = questionFor(factName);
|
|
@@ -122,9 +142,9 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
122
142
|
// This used to say "Correct it and the checks adjust" with no way on earth to
|
|
123
143
|
// do so — no flag, no file, no prompt. It was the last line of every scan and
|
|
124
144
|
// it invited an action that did not exist. Now it names the file that works.
|
|
125
|
-
if (questions.length)
|
|
145
|
+
if (questions.length && !interactive)
|
|
126
146
|
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
|
|
147
|
+
else if (!interactive)
|
|
128
148
|
p(`\n ${C.dim}Wrong about your app? Correct it in ${C.off}launchprep.json${C.dim} and run again.${C.off}\n`);
|
|
129
149
|
return L.join('\n');
|
|
130
150
|
}
|