easyvibegate 0.4.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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +144 -0
  4. package/dist/cli/index.js +402 -0
  5. package/dist/cli/wizard.js +196 -0
  6. package/dist/engine/aifix.js +65 -0
  7. package/dist/engine/checkers/backend/firebase.js +146 -0
  8. package/dist/engine/checkers/backend/supabase.js +249 -0
  9. package/dist/engine/checkers/deep/deps.js +118 -0
  10. package/dist/engine/checkers/index.js +15 -0
  11. package/dist/engine/checkers/live/endpoint-probe.js +72 -0
  12. package/dist/engine/checkers/live/http-checks.js +123 -0
  13. package/dist/engine/checkers/live/idor.js +101 -0
  14. package/dist/engine/checkers/static/client-exposure.js +34 -0
  15. package/dist/engine/checkers/static/config-risks.js +89 -0
  16. package/dist/engine/checkers/static/env-git.js +70 -0
  17. package/dist/engine/checkers/static/rls-migrations.js +324 -0
  18. package/dist/engine/checkers/static/route-inventory.js +31 -0
  19. package/dist/engine/checkers/static/secrets.js +262 -0
  20. package/dist/engine/config.js +54 -0
  21. package/dist/engine/detect.js +110 -0
  22. package/dist/engine/endpoints.js +65 -0
  23. package/dist/engine/i18n.js +189 -0
  24. package/dist/engine/net/http.js +108 -0
  25. package/dist/engine/report.js +219 -0
  26. package/dist/engine/scan.js +53 -0
  27. package/dist/engine/types.js +1 -0
  28. package/dist/engine/util/color.js +17 -0
  29. package/dist/engine/util/mask.js +66 -0
  30. package/dist/engine/util/text.js +50 -0
  31. package/dist/engine/version.js +12 -0
  32. package/dist/engine/walk.js +86 -0
  33. package/dist/orchestrator/flow.js +116 -0
  34. package/package.json +46 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { runFlow } from '../orchestrator/flow.js';
6
+ import { badgeMarkdown, exitCodeFor, renderConsole, renderJson, renderMarkdown, renderNextSteps, renderVerdict, summarize, } from '../engine/report.js';
7
+ import { buildAiFixPrompt } from '../engine/aifix.js';
8
+ import { runWizard } from './wizard.js';
9
+ import { pickLang } from '../engine/i18n.js';
10
+ import { color } from '../engine/util/color.js';
11
+ import { VERSION } from '../engine/version.js';
12
+ const HELP = `
13
+ ๐Ÿ›ก easyvibegate โ€” universal security scanner for vibe-coded apps
14
+
15
+ Usage:
16
+ easyvibegate [path] [options]
17
+
18
+ Just run \`easyvibegate\` in your project for a guided, beginner-friendly wizard.
19
+ Level 0 (static, any stack) always runs. Level 1/2 need opt-in.
20
+ A fix plan (ai-fix-prompt.md) is always written next to the report.
21
+
22
+ Options:
23
+ -o, --output <dir> Report directory (default: <project>/easyvibegate-report)
24
+ -f, --format <fmt> all | md | json | none (default: all)
25
+ --ci Non-interactive: no wizard, no prompts, quiet output
26
+ -c, --config <file> Path to a easyvibegate config JSON
27
+
28
+ Level 1:
29
+ --deps Run the dependency vulnerability audit
30
+
31
+ Level 2 (live probe โ€” only against apps you own):
32
+ --url <appUrl> Probe a running app: headers, exposed files, unauth endpoints
33
+ --supabase-url <u> Override the detected Supabase URL
34
+ --supabase-key <k> Override the detected Supabase anon key
35
+ --idor-tokens a,b Two bearer tokens for the IDOR differential probe
36
+ --i-own-this Authorize probing without interactive prompts (for CI)
37
+ -y, --yes Assume yes to all consent prompts
38
+
39
+ Other:
40
+ --lang <ru|en> Interface language (default: ru; use "en" for English)
41
+ --no-wizard Skip the guided wizard; run directly and print results
42
+
43
+ -h, --help Show help
44
+ -v, --version Show version
45
+
46
+ Exit codes (every mode): 2 = critical, 1 = warnings, 3 = a check did not complete, 0 = clean.
47
+ Ethics: the live probe sends real requests. Only run it against systems you own.
48
+ `;
49
+ function parseArgs(argv) {
50
+ const a = {
51
+ path: '.',
52
+ format: 'all',
53
+ ci: false,
54
+ iOwnThis: false,
55
+ yes: false,
56
+ deps: false,
57
+ noWizard: false,
58
+ wizard: false,
59
+ badIdorTokens: false,
60
+ noReport: false,
61
+ unknown: [],
62
+ argErrors: [],
63
+ help: false,
64
+ version: false,
65
+ };
66
+ let sawPath = false;
67
+ let endOfFlags = false;
68
+ let i = 0;
69
+ // Read a required value; error if it's missing or looks like another flag.
70
+ const need = (name) => {
71
+ const v = argv[i + 1];
72
+ if (v === undefined || v.startsWith('-')) {
73
+ a.argErrors.push(`${name} needs a value`);
74
+ return undefined;
75
+ }
76
+ i++;
77
+ return v;
78
+ };
79
+ for (; i < argv.length; i++) {
80
+ const arg = argv[i];
81
+ if (endOfFlags) {
82
+ if (!sawPath) {
83
+ a.path = arg;
84
+ sawPath = true;
85
+ }
86
+ else
87
+ a.argErrors.push(`unexpected extra path "${arg}"`);
88
+ continue;
89
+ }
90
+ if (arg === '--') {
91
+ endOfFlags = true;
92
+ continue;
93
+ }
94
+ switch (arg) {
95
+ case '-h':
96
+ case '--help':
97
+ a.help = true;
98
+ break;
99
+ case '-v':
100
+ case '--version':
101
+ a.version = true;
102
+ break;
103
+ case '--ci':
104
+ a.ci = true;
105
+ break;
106
+ case '-o':
107
+ case '--output':
108
+ a.output = need('--output') ?? a.output;
109
+ break;
110
+ case '-f':
111
+ case '--format': {
112
+ const v = need('--format')?.toLowerCase();
113
+ if (v === undefined)
114
+ break;
115
+ if (v === 'all' || v === 'md' || v === 'json' || v === 'none')
116
+ a.format = v;
117
+ else
118
+ a.argErrors.push(`--format must be one of all|md|json|none (got "${v}")`);
119
+ break;
120
+ }
121
+ case '--no-report':
122
+ a.noReport = true;
123
+ break;
124
+ case '-c':
125
+ case '--config':
126
+ a.config = need('--config');
127
+ break;
128
+ case '--url':
129
+ a.appUrl = need('--url');
130
+ break;
131
+ case '--supabase-url':
132
+ a.supabaseUrl = need('--supabase-url');
133
+ break;
134
+ case '--supabase-key':
135
+ a.supabaseKey = need('--supabase-key');
136
+ break;
137
+ case '--i-own-this':
138
+ a.iOwnThis = true;
139
+ break;
140
+ case '-y':
141
+ case '--yes':
142
+ a.yes = true;
143
+ break;
144
+ case '--deps':
145
+ a.deps = true;
146
+ break;
147
+ case '--no-wizard':
148
+ case '--scan':
149
+ a.noWizard = true;
150
+ break;
151
+ case '--wizard':
152
+ a.wizard = true;
153
+ break;
154
+ case '--lang': {
155
+ const v = need('--lang')?.toLowerCase();
156
+ if (v === undefined)
157
+ break;
158
+ if (v === 'ru' || v === 'en')
159
+ a.lang = v;
160
+ else
161
+ a.argErrors.push(`--lang must be ru or en (got "${v}")`);
162
+ break;
163
+ }
164
+ case '--idor-tokens': {
165
+ const v = need('--idor-tokens');
166
+ if (v === undefined)
167
+ break;
168
+ const parts = v.split(',').map((s) => s.trim()).filter(Boolean);
169
+ if (parts.length === 2)
170
+ a.idorTokens = [parts[0], parts[1]];
171
+ else
172
+ a.badIdorTokens = true;
173
+ break;
174
+ }
175
+ default:
176
+ if (arg.startsWith('-'))
177
+ a.unknown.push(arg);
178
+ else if (!sawPath) {
179
+ a.path = arg;
180
+ sawPath = true;
181
+ }
182
+ else
183
+ a.argErrors.push(`unexpected extra path "${arg}" โ€” scan one project at a time`);
184
+ }
185
+ }
186
+ // Cross-option validation.
187
+ if (a.noReport)
188
+ a.format = 'none'; // wins regardless of flag order
189
+ if (a.wizard && a.noWizard)
190
+ a.argErrors.push('--wizard and --no-wizard cannot be combined');
191
+ if (a.output !== undefined && a.output.trim() === '')
192
+ a.argErrors.push('--output needs a directory path');
193
+ const httpish = (u) => /^https?:\/\//i.test(u);
194
+ if (a.appUrl && !httpish(a.appUrl))
195
+ a.argErrors.push('--url must start with http:// or https://');
196
+ if (a.supabaseUrl && !httpish(a.supabaseUrl))
197
+ a.argErrors.push('--supabase-url must start with http:// or https://');
198
+ if (!!a.supabaseUrl !== !!a.supabaseKey)
199
+ a.argErrors.push('--supabase-url and --supabase-key must be provided together');
200
+ if (a.idorTokens && !a.appUrl)
201
+ a.argErrors.push('--idor-tokens requires --url (the running app to probe)');
202
+ return a;
203
+ }
204
+ function ask(question) {
205
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
206
+ return new Promise((res) => {
207
+ let done = false;
208
+ const finish = (v) => { if (!done) {
209
+ done = true;
210
+ rl.close();
211
+ res(v);
212
+ } };
213
+ rl.question(question, finish);
214
+ rl.once('close', () => finish('')); // Ctrl-D / EOF = "no", never a silent hang
215
+ });
216
+ }
217
+ async function main() {
218
+ const args = parseArgs(process.argv.slice(2));
219
+ if (args.help) {
220
+ process.stdout.write(HELP);
221
+ return;
222
+ }
223
+ if (args.version) {
224
+ process.stdout.write(`easyvibegate ${VERSION}\n`);
225
+ return;
226
+ }
227
+ // Fail loudly on bad usage instead of silently doing the wrong thing.
228
+ if (args.unknown.length > 0) {
229
+ process.stderr.write(`easyvibegate: unknown option(s): ${args.unknown.join(', ')}\nRun with --help.\n`);
230
+ process.exit(2);
231
+ }
232
+ if (args.badIdorTokens) {
233
+ process.stderr.write('easyvibegate: --idor-tokens needs exactly two comma-separated tokens (tokenA,tokenB).\n');
234
+ process.exit(2);
235
+ }
236
+ if (args.argErrors.length > 0) {
237
+ process.stderr.write(`easyvibegate: ${args.argErrors.join('; ')}\nRun with --help.\n`);
238
+ process.exit(2);
239
+ }
240
+ const root = resolve(args.path);
241
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
242
+ process.stderr.write(`easyvibegate: path not found or not a directory: ${root}\n`);
243
+ process.exit(2);
244
+ }
245
+ // An explicitly requested config must exist and be valid โ€” silently falling
246
+ // back to defaults would apply different ignore rules than the user asked for.
247
+ if (args.config !== undefined) {
248
+ const problem = validateConfigFile(resolve(args.config));
249
+ if (problem) {
250
+ process.stderr.write(`easyvibegate: --config ${args.config}: ${problem}\n`);
251
+ process.exit(2);
252
+ }
253
+ }
254
+ const autoYes = args.iOwnThis || args.yes;
255
+ const lang = pickLang(args.lang);
256
+ // Reports land next to the scanned project by default, so scanning several
257
+ // projects from one shell never overwrites another project's report.
258
+ const outDir = args.output !== undefined ? resolve(args.output) : join(root, 'easyvibegate-report');
259
+ // Use the friendly wizard when a human runs it in a terminal without
260
+ // automation flags; --wizard forces it. Either way the pipeline below is shared.
261
+ // --ci is a non-interactive contract: never ask questions there.
262
+ const useWizard = !args.ci && (args.wizard || (!args.noWizard && !autoYes && !!process.stdin.isTTY));
263
+ // Asking for a live check in a run that can never confirm ownership is a
264
+ // misconfigured invocation, not a clean scan. Fail on the flags rather than
265
+ // silently skipping the very check the run was set up to perform.
266
+ const canConfirmOwnership = autoYes || useWizard || !!process.stdin.isTTY;
267
+ if (!canConfirmOwnership) {
268
+ const requested = [args.appUrl ? '--url' : '', args.supabaseUrl ? '--supabase-url' : ''].filter(Boolean);
269
+ if (requested.length > 0) {
270
+ process.stderr.write(`easyvibegate: ${requested.join(' and ')} asks for a live check, but this run is non-interactive and cannot confirm you own the target.\n` +
271
+ 'Add --i-own-this to assert ownership, or run it in a terminal.\n');
272
+ process.exit(2);
273
+ }
274
+ }
275
+ let result;
276
+ if (useWizard) {
277
+ result = await runWizard({
278
+ path: args.path,
279
+ config: args.config,
280
+ lang,
281
+ appUrl: args.appUrl,
282
+ deps: args.deps,
283
+ idorTokens: args.idorTokens,
284
+ supabaseUrl: args.supabaseUrl,
285
+ supabaseKey: args.supabaseKey,
286
+ autoYes,
287
+ });
288
+ }
289
+ else {
290
+ const interactive = !!process.stdin.isTTY && !args.ci && !autoYes;
291
+ const log = (m) => { if (!args.ci)
292
+ process.stderr.write(color.gray(` ${m}\n`)); };
293
+ const consent = async (req) => {
294
+ if (autoYes)
295
+ return true;
296
+ if (!interactive) {
297
+ process.stderr.write(color.gray(` skipped ${req.kind} probe of ${req.target} โ€” needs --i-own-this or an interactive terminal\n`));
298
+ return false;
299
+ }
300
+ const ans = await ask(color.yellow(` Probe ${req.kind} โ†’ ${req.target}?\n (${req.detail}) [y/N] `));
301
+ return /^y(es)?$/i.test(ans.trim());
302
+ };
303
+ result = await runFlow({
304
+ root,
305
+ configPath: args.config,
306
+ appUrl: args.appUrl,
307
+ supabaseUrl: args.supabaseUrl,
308
+ supabaseKey: args.supabaseKey,
309
+ runDeps: args.deps,
310
+ idorTokens: args.idorTokens,
311
+ consent,
312
+ log,
313
+ });
314
+ }
315
+ // ---- One shared pipeline: console, reports, verdict, exit code. ----
316
+ const summary = summarize(result.findings, result.runs);
317
+ if (!args.ci)
318
+ process.stdout.write(renderConsole(result, summary, lang) + '\n');
319
+ if (args.format !== 'none') {
320
+ const problem = prepareOutputDir(outDir);
321
+ if (problem) {
322
+ process.stderr.write(`easyvibegate: --output ${outDir}: ${problem}\n`);
323
+ process.exit(2);
324
+ }
325
+ const written = [];
326
+ if (args.format === 'all' || args.format === 'md') {
327
+ const p = join(outDir, 'report.md');
328
+ writeFileSync(p, renderMarkdown(result, summary, lang), 'utf8');
329
+ written.push(p);
330
+ }
331
+ if (args.format === 'all' || args.format === 'json') {
332
+ const p = join(outDir, 'report.json');
333
+ writeFileSync(p, renderJson(result, summary), 'utf8');
334
+ written.push(p);
335
+ }
336
+ // The fix plan is the whole point โ€” always produce it alongside a report.
337
+ writeFileSync(join(outDir, 'ai-fix-prompt.md'), buildAiFixPrompt(result, summary, lang), 'utf8');
338
+ if (!args.ci && written.length) {
339
+ process.stdout.write(color.gray(` report: ${written.join(', ')}\n`));
340
+ process.stdout.write(color.gray(` badge: ${badgeMarkdown(summary)}\n\n`));
341
+ }
342
+ }
343
+ if (!args.ci) {
344
+ process.stdout.write(renderVerdict(summary, lang) + '\n\n');
345
+ if (args.format !== 'none')
346
+ process.stdout.write(renderNextSteps(summary, outDir, lang));
347
+ }
348
+ // Same contract in every mode: 2 critical, 1 warning, 3 incomplete, 0 clean.
349
+ process.exit(exitCodeFor(summary));
350
+ }
351
+ /** Create the report dir and clear our own stale files, or explain why we cannot. */
352
+ function prepareOutputDir(dir) {
353
+ try {
354
+ if (existsSync(dir) && !statSync(dir).isDirectory())
355
+ return 'exists and is not a directory';
356
+ mkdirSync(dir, { recursive: true });
357
+ // Old report.md next to a fresh report.json told two different stories.
358
+ for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
359
+ const p = join(dir, name);
360
+ if (existsSync(p))
361
+ rmSync(p, { force: true });
362
+ }
363
+ return null;
364
+ }
365
+ catch (e) {
366
+ return e instanceof Error ? e.message : String(e);
367
+ }
368
+ }
369
+ /** Returns a human message when an explicitly given config is unusable. */
370
+ function validateConfigFile(path) {
371
+ if (!existsSync(path))
372
+ return 'file not found';
373
+ if (!statSync(path).isFile())
374
+ return 'not a file';
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
378
+ }
379
+ catch (e) {
380
+ return `invalid JSON (${e instanceof Error ? e.message : String(e)})`;
381
+ }
382
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
383
+ return 'must be a JSON object';
384
+ const cfg = parsed;
385
+ const known = ['ignore', 'ignorePaths'];
386
+ for (const key of known) {
387
+ const v = cfg[key];
388
+ if (v === undefined)
389
+ continue;
390
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
391
+ return `"${key}" must be an array of strings`;
392
+ }
393
+ // A typo like "ignorePath" would silently do nothing โ€” say so instead.
394
+ const unknown = Object.keys(cfg).filter((k) => !known.includes(k));
395
+ if (unknown.length)
396
+ return `unknown key(s): ${unknown.join(', ')} (expected ${known.join(', ')})`;
397
+ return null;
398
+ }
399
+ main().catch((err) => {
400
+ process.stderr.write(`easyvibegate: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
401
+ process.exit(1);
402
+ });
@@ -0,0 +1,196 @@
1
+ import { resolve } from 'node:path';
2
+ import { createInterface } from 'node:readline';
3
+ import { scanStatic } from '../engine/scan.js';
4
+ import { runFlow } from '../orchestrator/flow.js';
5
+ import { discoverSupabase } from '../engine/checkers/backend/supabase.js';
6
+ import { discoverFirebase } from '../engine/checkers/backend/firebase.js';
7
+ import { summarize } from '../engine/report.js';
8
+ import { t } from '../engine/i18n.js';
9
+ import { color } from '../engine/util/color.js';
10
+ /**
11
+ * Normalize what a person types as a URL. Returns null if it cannot be one.
12
+ * Loopback and private hosts default to http:// โ€” a dev server is almost never
13
+ * https, and silently guessing https makes the whole probe fail.
14
+ */
15
+ export function normalizeUrl(input) {
16
+ const raw = input.trim();
17
+ if (raw === '' || /\s/.test(raw))
18
+ return null;
19
+ const hostPart = raw.replace(/^[a-z]+:\/\//i, '').split(/[/:?#]/)[0] ?? '';
20
+ const isLocal = /^(localhost|127(\.\d+){3}|0\.0\.0\.0|\[::1\]|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2})$/i.test(hostPart);
21
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `${isLocal ? 'http' : 'https'}://${raw}`;
22
+ try {
23
+ const u = new URL(withScheme);
24
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
25
+ return null;
26
+ const host = u.hostname;
27
+ if (!(host === 'localhost' || host.startsWith('[') || /^[^.]+\.[^.]+/.test(host) || /^\d+(\.\d+){3}$/.test(host)))
28
+ return null;
29
+ return u.toString().replace(/\/$/, '');
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Read every line from a non-TTY stdin up front, so piped answers are not lost.
37
+ * An empty stream is zero answers, not one blank line: `''.split('\n')` yields
38
+ * `['']`, which reads as a deliberate Enter and would accept a question's
39
+ * default โ€” opting into a network action nobody asked for. The trailing newline
40
+ * of a normal stream is dropped for the same reason.
41
+ */
42
+ async function readPipedLines() {
43
+ const chunks = [];
44
+ for await (const chunk of process.stdin)
45
+ chunks.push(Buffer.from(chunk));
46
+ const text = Buffer.concat(chunks).toString('utf8');
47
+ if (text === '')
48
+ return [];
49
+ return text.replace(/\r?\n$/, '').split('\n');
50
+ }
51
+ /**
52
+ * The beginner-friendly guided run: plain questions, plain answers.
53
+ * It only gathers input and runs the checks โ€” writing reports, the verdict and
54
+ * the exit code stay in the CLI's single shared pipeline.
55
+ */
56
+ export async function runWizard(args) {
57
+ const root = resolve(args.path);
58
+ const lang = args.lang;
59
+ const w = (s = '') => process.stdout.write(s + '\n');
60
+ const tty = !!process.stdin.isTTY;
61
+ const piped = tty ? [] : await readPipedLines();
62
+ let pipeIdx = 0;
63
+ const rl = tty ? createInterface({ input: process.stdin, output: process.stdout }) : null;
64
+ let inputEnded = false;
65
+ rl?.on('close', () => { inputEnded = true; });
66
+ /** Returns null when there is no answer left (EOF / Ctrl-D / exhausted pipe). */
67
+ const ask = (question) => {
68
+ if (inputEnded)
69
+ return Promise.resolve(null);
70
+ if (!rl) {
71
+ if (pipeIdx >= piped.length) {
72
+ inputEnded = true;
73
+ return Promise.resolve(null);
74
+ }
75
+ const line = piped[pipeIdx++] ?? '';
76
+ process.stdout.write(question + line + '\n');
77
+ return Promise.resolve(line.trim());
78
+ }
79
+ return new Promise((res) => {
80
+ let done = false;
81
+ const finish = (v) => { if (!done) {
82
+ done = true;
83
+ res(v);
84
+ } };
85
+ rl.question(question, (a) => finish(a.trim()));
86
+ // Ctrl-D closes the interface: answer "no input" instead of crashing the
87
+ // next question with ERR_USE_AFTER_CLOSE.
88
+ rl.once('close', () => { inputEnded = true; finish(null); });
89
+ });
90
+ };
91
+ /** No answer means "no" โ€” never opt into a network action on EOF. */
92
+ const askYesNo = async (question, def) => {
93
+ for (let attempt = 0; attempt < 2; attempt++) {
94
+ const raw = await ask(`${question} ${def ? '[Y/n]' : '[y/N]'} `);
95
+ if (raw === null)
96
+ return false;
97
+ const ans = raw.toLowerCase();
98
+ if (ans === '')
99
+ return def;
100
+ if (/^(y|yes|ะด|ะดะฐ|1)/.test(ans))
101
+ return true;
102
+ if (/^(n|no|ะฝ|ะฝะตั‚|0)/.test(ans))
103
+ return false;
104
+ w(color.yellow(` ${t(lang, 'wiz.answerUnclear', { input: raw })}`));
105
+ }
106
+ return false;
107
+ };
108
+ w();
109
+ w(` ${color.bold('๐Ÿ›ก EasyVibeGate')}`);
110
+ w(color.gray(` ${t(lang, 'wiz.sub1')}`));
111
+ w(color.gray(` ${t(lang, 'wiz.sub2')}`));
112
+ w();
113
+ w(color.gray(` ${t(lang, 'wiz.project', { root })}`));
114
+ w();
115
+ // Step 1 โ€” static code review (always, safe).
116
+ w(` ${color.bold(t(lang, 'wiz.step1'))}${color.gray(t(lang, 'wiz.step1hint'))}`);
117
+ const staticResult = await scanStatic(root, { configPath: args.config });
118
+ const s0 = summarize(staticResult.findings, staticResult.runs);
119
+ w(color.gray(` ${t(lang, 'wiz.step1result', { files: staticResult.fileCount, crit: s0.counts.critical, warn: s0.counts.warning })}`));
120
+ w();
121
+ // Step 2 โ€” dependencies (an explicit --deps already answers this).
122
+ w(` ${color.bold(t(lang, 'wiz.step2'))}`);
123
+ const runDeps = args.deps ? true : await askYesNo(t(lang, 'wiz.qDeps'), true);
124
+ if (args.deps)
125
+ w(color.gray(` --deps โ†’ ${t(lang, 'wiz.fromFlag')}`));
126
+ w();
127
+ // Step 3 โ€” live checks (opt-in, own project only).
128
+ w(` ${color.bold(t(lang, 'wiz.step3'))}${color.gray(t(lang, 'wiz.step3hint'))}`);
129
+ const sb = args.supabaseUrl && args.supabaseKey
130
+ ? { url: args.supabaseUrl, anonKey: args.supabaseKey, keyKind: 'jwt-anon' }
131
+ : discoverSupabase(staticResult.files);
132
+ const fb = discoverFirebase(staticResult.files);
133
+ let approveSupabase = false;
134
+ let approveFirebase = false;
135
+ if (sb) {
136
+ w(color.gray(t(lang, 'wiz.sbFound', { url: sb.url })));
137
+ w(color.gray(t(lang, 'wiz.sbDesc1')));
138
+ w(color.gray(t(lang, 'wiz.sbDesc2')));
139
+ approveSupabase = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qSb'), false);
140
+ w();
141
+ }
142
+ if (fb) {
143
+ w(color.gray(t(lang, 'wiz.fbFound', { id: fb.projectId })));
144
+ approveFirebase = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qFb'), false);
145
+ w();
146
+ }
147
+ // A URL from the command line wins; otherwise ask โ€” and never silently discard
148
+ // a non-empty answer that merely lacks a scheme.
149
+ let appUrl = args.appUrl;
150
+ if (!appUrl) {
151
+ for (let attempt = 0; attempt < 2 && !appUrl; attempt++) {
152
+ const raw = await ask(t(lang, 'wiz.qUrl'));
153
+ if (raw === null || raw === '')
154
+ break; // EOF or empty = deliberately skip
155
+ const normalized = normalizeUrl(raw);
156
+ if (normalized) {
157
+ appUrl = normalized;
158
+ if (normalized !== raw)
159
+ w(color.gray(` โ†’ ${t(lang, 'wiz.urlNormalized', { url: normalized })}`));
160
+ }
161
+ else {
162
+ w(color.yellow(` ${t(lang, 'wiz.urlInvalid', { input: raw })}`));
163
+ }
164
+ }
165
+ }
166
+ // Probing a live host always needs ownership confirmation, even from --url.
167
+ let approveLive = !!appUrl;
168
+ if (appUrl && !args.autoYes) {
169
+ approveLive = await askYesNo(t(lang, 'wiz.qOwn', { url: appUrl }), false);
170
+ }
171
+ rl?.close();
172
+ w();
173
+ const consent = async (req) => {
174
+ switch (req.kind) {
175
+ case 'supabase': return approveSupabase;
176
+ case 'firebase': return approveFirebase;
177
+ case 'live': return approveLive;
178
+ case 'idor': return approveLive && !!args.idorTokens;
179
+ default: return false;
180
+ }
181
+ };
182
+ const log = (m) => process.stdout.write(color.gray(` โ€ฆ ${m}\n`));
183
+ w(` ${color.bold(t(lang, 'wiz.running'))}`);
184
+ return runFlow({
185
+ root,
186
+ configPath: args.config,
187
+ appUrl: approveLive ? appUrl : undefined,
188
+ supabaseUrl: args.supabaseUrl,
189
+ supabaseKey: args.supabaseKey,
190
+ runDeps,
191
+ idorTokens: args.idorTokens,
192
+ precomputedStatic: staticResult,
193
+ consent,
194
+ log,
195
+ });
196
+ }
@@ -0,0 +1,65 @@
1
+ import { sortFindings, whereOf } from './report.js';
2
+ import { t } from './i18n.js';
3
+ import { VERSION } from './version.js';
4
+ const LABEL = { critical: 'CRITICAL', warning: 'WARNING', info: 'INFO', advisory: 'ADVISORY' };
5
+ /**
6
+ * Assemble a single "master fix prompt" to paste into Cursor/Claude/an AI agent.
7
+ * Actionable findings become numbered fix tasks; advisories become a manual
8
+ * verification checklist. Secrets are never included in raw form.
9
+ */
10
+ export function buildAiFixPrompt(result, summary, lang = 'en') {
11
+ const all = sortFindings(result.findings);
12
+ const actionable = all.filter((f) => f.severity === 'critical' || f.severity === 'warning');
13
+ const advisories = all.filter((f) => f.severity === 'advisory');
14
+ const lines = [];
15
+ lines.push(t(lang, 'aifix.title'));
16
+ lines.push('');
17
+ lines.push(`Project: \`${result.root}\` ยท scanned by EasyVibeGate ${VERSION} at ${new Date().toISOString()}`);
18
+ lines.push('');
19
+ lines.push(t(lang, 'aifix.intro', { crit: summary.counts.critical, warn: summary.counts.warning, score: summary.score, gate: summary.gate.toUpperCase() }));
20
+ lines.push('');
21
+ lines.push(`## ${t(lang, 'aifix.rules')}`);
22
+ lines.push(t(lang, 'aifix.rule1'));
23
+ lines.push(t(lang, 'aifix.rule2'));
24
+ lines.push(t(lang, 'aifix.rule3'));
25
+ lines.push(t(lang, 'aifix.rule4'));
26
+ lines.push(t(lang, 'aifix.rule5'));
27
+ lines.push(t(lang, 'aifix.rule6'));
28
+ lines.push('');
29
+ lines.push(`## ${t(lang, 'aifix.issues')}`);
30
+ lines.push('');
31
+ if (actionable.length === 0) {
32
+ lines.push(t(lang, 'aifix.none'));
33
+ }
34
+ actionable.forEach((f, i) => {
35
+ lines.push(`### ${i + 1}. [${LABEL[f.severity]}] ${f.title}`);
36
+ lines.push(`- ${t(lang, 'aifix.location')}: \`${whereOf(f)}\``);
37
+ lines.push(`- ${t(lang, 'aifix.problem')}: ${f.detail}`);
38
+ lines.push(`- ${t(lang, 'aifix.fix')}: ${f.fix}`);
39
+ lines.push('');
40
+ });
41
+ if (advisories.length > 0) {
42
+ lines.push(`## ${t(lang, 'aifix.manual')}`);
43
+ lines.push('');
44
+ for (const f of advisories)
45
+ lines.push(`- ${f.title}: ${f.detail}`);
46
+ lines.push('');
47
+ }
48
+ // Checks that did not complete โ€” the AI must not read a failed check as "clean".
49
+ const notDone = result.runs.filter((r) => r.status === 'failed' || r.status === 'partial' || r.status === 'unsupported');
50
+ if (notDone.length > 0) {
51
+ lines.push(`## ${t(lang, 'aifix.incomplete')}`);
52
+ lines.push('');
53
+ for (const r of notDone)
54
+ lines.push(`- \`${r.id}\` โ€” ${r.status}${r.note ? ` (${r.note})` : ''}`);
55
+ lines.push('');
56
+ lines.push(t(lang, 'aifix.incompleteTask'));
57
+ lines.push('');
58
+ }
59
+ lines.push(`## ${t(lang, 'aifix.done')}`);
60
+ lines.push(t(lang, 'aifix.done1'));
61
+ lines.push(t(lang, 'aifix.done2'));
62
+ lines.push(t(lang, 'aifix.done3'));
63
+ lines.push('');
64
+ return lines.join('\n');
65
+ }