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,219 @@
1
+ import { SEVERITY_ORDER } from './types.js';
2
+ import { color } from './util/color.js';
3
+ import { t } from './i18n.js';
4
+ import { VERSION } from './version.js';
5
+ const WEIGHTS = { critical: 25, warning: 8, info: 2, advisory: 0 };
6
+ const EMOJI = { critical: 'πŸ”΄', warning: '🟑', info: 'πŸ”΅', advisory: 'βšͺ' };
7
+ export function coverage(runs) {
8
+ const c = { completed: 0, partial: 0, failed: 0, skipped: 0, unsupported: 0 };
9
+ for (const r of runs)
10
+ c[r.status]++;
11
+ return {
12
+ ...c,
13
+ total: runs.length,
14
+ incomplete: c.failed > 0 || c.partial > 0 || c.unsupported > 0,
15
+ nothingVerified: c.completed + c.partial === 0,
16
+ };
17
+ }
18
+ export function summarize(findings, runs = []) {
19
+ const counts = { critical: 0, warning: 0, info: 0, advisory: 0 };
20
+ let score = 100;
21
+ for (const f of findings) {
22
+ counts[f.severity]++;
23
+ score -= WEIGHTS[f.severity];
24
+ }
25
+ const cov = coverage(runs);
26
+ const gate = counts.critical > 0 ? 'fail'
27
+ : cov.incomplete || cov.nothingVerified ? 'incomplete'
28
+ : counts.warning > 0 ? 'warn'
29
+ : 'pass';
30
+ return { score: Math.max(0, score), gate, counts, coverage: cov };
31
+ }
32
+ /** CI exit code from the same policy: 2 critical, 1 warning, 3 incomplete, 0 clean. */
33
+ export function exitCodeFor(summary) {
34
+ switch (summary.gate) {
35
+ case 'fail': return 2;
36
+ case 'incomplete': return 3;
37
+ case 'warn': return 1;
38
+ default: return 0;
39
+ }
40
+ }
41
+ export function whereOf(f) {
42
+ if (f.endpoint)
43
+ return f.endpoint;
44
+ if (f.file && f.line)
45
+ return `${f.file}:${f.line}`;
46
+ if (f.file)
47
+ return f.file;
48
+ return 'β€”';
49
+ }
50
+ export function sortFindings(findings) {
51
+ const rank = (s) => SEVERITY_ORDER.indexOf(s);
52
+ return [...findings].sort((a, b) => rank(a.severity) - rank(b.severity) ||
53
+ (a.file ?? '').localeCompare(b.file ?? '') ||
54
+ (a.line ?? 0) - (b.line ?? 0));
55
+ }
56
+ /** Badge reflects the gate, never a bare score: an incomplete run is never green. */
57
+ export function badgeMarkdown(summary) {
58
+ if (summary.gate === 'fail')
59
+ return `![EasyVibeGate](https://img.shields.io/badge/EasyVibeGate-${summary.score}%2F100-red)`;
60
+ if (summary.gate === 'incomplete')
61
+ return '![EasyVibeGate](https://img.shields.io/badge/EasyVibeGate-incomplete-yellow)';
62
+ if (summary.gate === 'warn')
63
+ return `![EasyVibeGate](https://img.shields.io/badge/EasyVibeGate-${summary.score}%2F100-yellow)`;
64
+ const c = summary.score >= 90 ? 'brightgreen' : summary.score >= 60 ? 'yellow' : 'orange';
65
+ return `![EasyVibeGate](https://img.shields.io/badge/EasyVibeGate-${summary.score}%2F100-${c})`;
66
+ }
67
+ function stackLine(result) {
68
+ const d = result.detection;
69
+ const parts = [];
70
+ if (d.frameworks.length)
71
+ parts.push(d.frameworks.join(', '));
72
+ if (d.backends.length)
73
+ parts.push(`backend: ${d.backends.join(', ')}`);
74
+ if (d.languages.length)
75
+ parts.push(d.languages.join(', '));
76
+ return parts.join(' Β· ') || 'unknown stack';
77
+ }
78
+ function gateLabel(summary) {
79
+ if (summary.gate === 'fail')
80
+ return color.red(color.bold('FAIL'));
81
+ if (summary.gate === 'incomplete')
82
+ return color.yellow(color.bold('INCOMPLETE'));
83
+ if (summary.gate === 'warn')
84
+ return color.yellow(color.bold('WARN'));
85
+ return color.green(color.bold('PASS'));
86
+ }
87
+ export function renderConsole(result, summary, lang = 'en') {
88
+ const lines = [];
89
+ lines.push('');
90
+ lines.push(`${color.bold('πŸ›‘ EasyVibeGate')} ${color.gray(`Β· ${t(lang, 'console.filesLine', { n: result.fileCount })} Β· ${stackLine(result)}`)}`);
91
+ lines.push('');
92
+ const shown = sortFindings(result.findings);
93
+ if (shown.length === 0)
94
+ lines.push(color.green(` ${t(lang, 'console.none')}`));
95
+ for (const f of shown) {
96
+ lines.push(` ${EMOJI[f.severity]} ${color.bold(f.title)} ${color.gray(whereOf(f))}`);
97
+ lines.push(` ${color.dim(f.detail)}`);
98
+ if (f.evidence)
99
+ lines.push(` ${color.gray(`${t(lang, 'console.evidence')}: ${f.evidence}`)}`);
100
+ lines.push(` ${color.cyan(`${t(lang, 'console.fix')}: ${f.fix}`)}`);
101
+ }
102
+ lines.push('');
103
+ const c = summary.counts;
104
+ lines.push(` ${color.bold(t(lang, 'console.score'))} ${scoreColor(summary)} ${color.gray('/100')} ${color.bold(t(lang, 'console.gate'))} ${gateLabel(summary)}`);
105
+ lines.push(` ${EMOJI.critical} ${c.critical} ${EMOJI.warning} ${c.warning} ${EMOJI.info} ${c.info} ${EMOJI.advisory} ${c.advisory}`);
106
+ const cov = summary.coverage;
107
+ const covLine = t(lang, 'cov.line', { ok: cov.completed, failed: cov.failed + cov.partial + cov.unsupported, skipped: cov.skipped });
108
+ lines.push(` ${cov.incomplete ? color.yellow(covLine) : color.gray(covLine)}`);
109
+ lines.push('');
110
+ return lines.join('\n');
111
+ }
112
+ function scoreColor(summary) {
113
+ const s = String(summary.score);
114
+ if (summary.gate === 'fail')
115
+ return color.red(s);
116
+ if (summary.gate === 'incomplete' || summary.gate === 'warn')
117
+ return color.yellow(s);
118
+ return color.green(s);
119
+ }
120
+ /** One plain-language line a non-technical user understands. */
121
+ export function renderVerdict(summary, lang = 'en') {
122
+ const c = summary.counts;
123
+ if (summary.gate === 'fail')
124
+ return color.red(color.bold(` ${t(lang, 'verdict.fail', { crit: c.critical })}`));
125
+ if (summary.gate === 'incomplete') {
126
+ const key = summary.coverage.nothingVerified ? 'verdict.nocov' : 'verdict.incompleteGate';
127
+ return color.yellow(color.bold(` ${t(lang, key, { n: summary.coverage.failed + summary.coverage.partial + summary.coverage.unsupported })}`));
128
+ }
129
+ if (c.warning > 0)
130
+ return color.yellow(color.bold(` ${t(lang, 'verdict.warn', { warn: c.warning })}`));
131
+ return color.green(color.bold(` ${t(lang, 'verdict.clean')}`));
132
+ }
133
+ /** The beginner-facing "what do I do now" block, with an AI-agent handoff. */
134
+ export function renderNextSteps(summary, reportDir, lang = 'en') {
135
+ const lines = [];
136
+ lines.push(color.bold(` ${t(lang, 'next.title')}`));
137
+ if (summary.counts.critical === 0 && summary.counts.warning === 0) {
138
+ lines.push(` ${summary.gate === 'incomplete' ? color.yellow(t(lang, 'next.incompleteClean')) : t(lang, 'next.clean')}`);
139
+ lines.push('');
140
+ return lines.join('\n');
141
+ }
142
+ lines.push(` ${t(lang, 'next.step1', { path: color.cyan(`${reportDir}/ai-fix-prompt.md`) })}`);
143
+ lines.push(` ${t(lang, 'next.step2a')}`);
144
+ lines.push(` ${t(lang, 'next.step2b')}`);
145
+ lines.push(color.gray(` ${t(lang, 'next.model1')}`));
146
+ lines.push(color.gray(` ${t(lang, 'next.model2')}`));
147
+ let n = 3;
148
+ if (summary.counts.critical > 0) {
149
+ lines.push(` ${t(lang, 'next.rotate', { n })}`);
150
+ n++;
151
+ }
152
+ lines.push(` ${t(lang, 'next.rerun', { n })}`);
153
+ if (summary.gate === 'incomplete')
154
+ lines.push(color.yellow(` ${t(lang, 'next.incompleteClean')}`));
155
+ lines.push('');
156
+ return lines.join('\n');
157
+ }
158
+ export function renderMarkdown(result, summary, lang = 'en') {
159
+ const lines = [];
160
+ lines.push('# πŸ›‘ EasyVibeGate Report');
161
+ lines.push('');
162
+ lines.push(t(lang, 'md.summary', { score: summary.score, gate: summary.gate.toUpperCase(), files: result.fileCount }));
163
+ lines.push('');
164
+ lines.push(t(lang, 'md.stack', { stack: stackLine(result) }));
165
+ lines.push('');
166
+ lines.push(`\`${result.root}\` Β· EasyVibeGate ${VERSION} Β· ${new Date().toISOString()}`);
167
+ lines.push('');
168
+ lines.push(badgeMarkdown(summary));
169
+ lines.push('');
170
+ // Coverage β€” make failed/partial/skipped/unsupported checks visible.
171
+ const notDone = result.runs.filter((r) => r.status !== 'completed');
172
+ if (notDone.length > 0) {
173
+ lines.push(`## ${t(lang, 'md.checks')}`);
174
+ lines.push('');
175
+ for (const r of notDone)
176
+ lines.push(`- \`${r.id}\` β€” **${r.status}**${r.note ? ` (${r.note})` : ''}`);
177
+ lines.push('');
178
+ }
179
+ const shown = sortFindings(result.findings);
180
+ if (shown.length === 0) {
181
+ lines.push(t(lang, 'md.none'));
182
+ lines.push('');
183
+ return lines.join('\n');
184
+ }
185
+ for (const sev of SEVERITY_ORDER) {
186
+ const group = shown.filter((f) => f.severity === sev);
187
+ if (group.length === 0)
188
+ continue;
189
+ lines.push(`## ${EMOJI[sev]} ${t(lang, `sev.${sev}`)} (${group.length})`);
190
+ lines.push('');
191
+ for (const f of group) {
192
+ lines.push(`- **${f.title}** β€” ${f.detail}`);
193
+ lines.push(` - ${t(lang, 'md.where')}: \`${whereOf(f)}\``);
194
+ if (f.evidence)
195
+ lines.push(` - ${t(lang, 'md.evidence')}: \`${f.evidence}\``);
196
+ lines.push(` - ${t(lang, 'md.fix')}: ${f.fix}`);
197
+ }
198
+ lines.push('');
199
+ }
200
+ lines.push('---');
201
+ lines.push(t(lang, 'md.generated'));
202
+ lines.push('');
203
+ return lines.join('\n');
204
+ }
205
+ export function renderJson(result, summary) {
206
+ return JSON.stringify({
207
+ projectRoot: result.root,
208
+ scannedAt: new Date().toISOString(),
209
+ version: VERSION,
210
+ score: summary.score,
211
+ gate: summary.gate,
212
+ counts: summary.counts,
213
+ coverage: summary.coverage,
214
+ fileCount: result.fileCount,
215
+ detection: result.detection,
216
+ runs: result.runs,
217
+ findings: sortFindings(result.findings),
218
+ }, null, 2);
219
+ }
@@ -0,0 +1,53 @@
1
+ import { walk } from './walk.js';
2
+ import { detect } from './detect.js';
3
+ import { staticCheckers } from './checkers/index.js';
4
+ import { applyIgnores, loadConfig } from './config.js';
5
+ /** Run all Level 0 (static, read-only) checkers over a project directory. */
6
+ export async function scanStatic(root, opts = {}) {
7
+ const { files, skippedOversized, skippedUnreadable } = walk(root);
8
+ const detection = detect(root, files);
9
+ const ctx = { root, files, detection };
10
+ let findings = [];
11
+ const runs = [];
12
+ // Zero scannable files means nothing was actually reviewed β€” record it as a
13
+ // failed precondition, and mark the static checkers as skipped (they had no
14
+ // input), so the result is never shown as a clean, well-covered 100/100.
15
+ if (files.length === 0) {
16
+ runs.push({ id: 'walk', level: 0, status: 'failed', note: 'no scannable files found at this path' });
17
+ for (const checker of staticCheckers) {
18
+ runs.push({ id: `static:${checker.id}`, level: 0, status: 'skipped', note: 'no files to check' });
19
+ }
20
+ return { root, detection, findings, fileCount: 0, files, runs };
21
+ }
22
+ // Files we could not read are missing coverage, not a clean result.
23
+ const lost = skippedOversized + skippedUnreadable;
24
+ if (lost > 0) {
25
+ const parts = [];
26
+ if (skippedOversized)
27
+ parts.push(`${skippedOversized} over the 1 MB limit`);
28
+ if (skippedUnreadable)
29
+ parts.push(`${skippedUnreadable} unreadable`);
30
+ runs.push({ id: 'walk', level: 0, status: 'partial', note: `${lost} file(s) not scanned (${parts.join(', ')})` });
31
+ }
32
+ for (const checker of staticCheckers) {
33
+ try {
34
+ findings.push(...(await checker.run(ctx)));
35
+ runs.push({ id: `static:${checker.id}`, level: 0, status: 'completed' });
36
+ }
37
+ catch (err) {
38
+ // A broken checker is a failed check, not a clean pass.
39
+ runs.push({
40
+ id: `static:${checker.id}`,
41
+ level: 0,
42
+ status: 'failed',
43
+ note: err instanceof Error ? err.message : String(err),
44
+ });
45
+ }
46
+ }
47
+ const config = loadConfig(root, opts.configPath);
48
+ if (config.problem) {
49
+ runs.push({ id: 'config', level: 0, status: 'failed', note: `${config.problem} β€” suppression rules were NOT applied` });
50
+ }
51
+ findings = applyIgnores(findings, config, files);
52
+ return { root, detection, findings, fileCount: files.length, files, runs };
53
+ }
@@ -0,0 +1 @@
1
+ export const SEVERITY_ORDER = ['critical', 'warning', 'info', 'advisory'];
@@ -0,0 +1,17 @@
1
+ const enabled = !!process.stdout.isTTY &&
2
+ process.env['NO_COLOR'] === undefined &&
3
+ process.env['TERM'] !== 'dumb';
4
+ function wrap(code) {
5
+ return (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
6
+ }
7
+ export const color = {
8
+ enabled,
9
+ red: wrap(31),
10
+ green: wrap(32),
11
+ yellow: wrap(33),
12
+ blue: wrap(34),
13
+ cyan: wrap(36),
14
+ gray: wrap(90),
15
+ bold: wrap(1),
16
+ dim: wrap(2),
17
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Blank out comments (and, optionally, quoted strings) while preserving length
3
+ * and newlines, so match offsets and line numbers stay correct. Used so that a
4
+ * comment like `// never use eval()` is not reported as a finding.
5
+ */
6
+ export function maskCode(src, opts = {}) {
7
+ const out = src.split('');
8
+ const n = src.length;
9
+ const blank = (from, to) => {
10
+ for (let k = from; k < to && k < n; k++)
11
+ if (out[k] !== '\n')
12
+ out[k] = ' ';
13
+ };
14
+ let i = 0;
15
+ while (i < n) {
16
+ const ch = src[i];
17
+ const next = src[i + 1];
18
+ if (ch === '/' && next === '/') {
19
+ let j = i;
20
+ while (j < n && src[j] !== '\n')
21
+ j++;
22
+ blank(i, j);
23
+ i = j;
24
+ continue;
25
+ }
26
+ if (ch === '#' && src[i - 1] !== '$') {
27
+ let j = i;
28
+ while (j < n && src[j] !== '\n')
29
+ j++;
30
+ blank(i, j);
31
+ i = j;
32
+ continue;
33
+ }
34
+ if (ch === '/' && next === '*') {
35
+ const c = src.indexOf('*/', i + 2);
36
+ const end = c === -1 ? n : c + 2;
37
+ blank(i, end);
38
+ i = end;
39
+ continue;
40
+ }
41
+ if (opts.strings && (ch === '"' || ch === "'")) {
42
+ let j = i + 1;
43
+ while (j < n && src[j] !== ch) {
44
+ if (src[j] === '\\')
45
+ j++;
46
+ if (src[j] === '\n')
47
+ break;
48
+ j++;
49
+ }
50
+ const end = Math.min(j + 1, n);
51
+ blank(i, end);
52
+ i = end;
53
+ continue;
54
+ }
55
+ i++;
56
+ }
57
+ return out.join('');
58
+ }
59
+ /** Minified/bundled output is not source a human wrote β€” reviewing it is noise. */
60
+ export function looksMinified(rel, content) {
61
+ if (/\.min\.(js|css|mjs|cjs)$/i.test(rel) || /(^|\/)(dist|build|vendor|bundle)\//.test(rel))
62
+ return true;
63
+ const lines = content.split('\n');
64
+ const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
65
+ return longest > 800 && lines.length < content.length / 200;
66
+ }
@@ -0,0 +1,50 @@
1
+ /** Shannon entropy (bits per char) of a string. High for real secrets. */
2
+ export function shannonEntropy(s) {
3
+ if (!s)
4
+ return 0;
5
+ const freq = {};
6
+ for (const ch of s)
7
+ freq[ch] = (freq[ch] ?? 0) + 1;
8
+ let e = 0;
9
+ for (const count of Object.values(freq)) {
10
+ const p = count / s.length;
11
+ e -= p * Math.log2(p);
12
+ }
13
+ return e;
14
+ }
15
+ /** Mask a sensitive value so it is safe to print in a report. */
16
+ export function redact(s) {
17
+ const t = s.trim();
18
+ if (t.length <= 8)
19
+ return '***';
20
+ return `${t.slice(0, 4)}…${t.slice(-3)}`;
21
+ }
22
+ /** 1-based line number of a character offset inside `content`. */
23
+ export function lineAt(content, index) {
24
+ let line = 1;
25
+ const stop = Math.min(index, content.length);
26
+ for (let i = 0; i < stop; i++) {
27
+ if (content.charCodeAt(i) === 10)
28
+ line++;
29
+ }
30
+ return line;
31
+ }
32
+ const PLACEHOLDER = /(x{3,}|your[_-]?|<[^>]+>|\$\{|process\.env|import\.meta\.env|example|placeholder|changeme|dummy|test[_-]?key|xxxxx|\.\.\.)/i;
33
+ /** True if a captured value looks like a template/placeholder, not a real secret. */
34
+ export function looksLikePlaceholder(value) {
35
+ return PLACEHOLDER.test(value);
36
+ }
37
+ /** Decode a JWT payload without verifying the signature. Returns null on failure. */
38
+ export function decodeJwtPayload(token) {
39
+ const parts = token.split('.');
40
+ if (parts.length !== 3)
41
+ return null;
42
+ try {
43
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
44
+ const obj = JSON.parse(json);
45
+ return typeof obj === 'object' && obj !== null ? obj : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** Single source of truth for the version, read from package.json. */
3
+ export function readVersion() {
4
+ try {
5
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
6
+ return pkg.version ?? '0.0.0';
7
+ }
8
+ catch {
9
+ return '0.0.0';
10
+ }
11
+ }
12
+ export const VERSION = readVersion();
@@ -0,0 +1,86 @@
1
+ import { readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { join, relative, extname, sep } from 'node:path';
3
+ const SKIP_DIRS = new Set([
4
+ '.git', 'node_modules', '.next', 'dist', 'build', 'out', '.venv', 'venv',
5
+ '__pycache__', 'coverage', '.turbo', '.cache', 'vendor', '.svelte-kit',
6
+ '.nuxt', '.output', 'target', '.idea', '.vscode', 'easyvibegate-report',
7
+ ]);
8
+ const TEXT_EXT = new Set([
9
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
10
+ '.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
11
+ '.html', '.css', '.scss', '.json', '.yml', '.yaml', '.toml',
12
+ '.env', '.sh', '.sql', '.md', '.txt', '.ini', '.conf', '.tf',
13
+ '.pem', '.key', '.crt', '.cert', '.pkcs8',
14
+ '.astro', '.properties', '.plist', '.swift', '.dart', '.ipynb', '.bash', '.zsh', '.mdx',
15
+ ]);
16
+ const ALWAYS_NAMES = new Set([
17
+ 'Dockerfile', 'Gemfile', 'Procfile', 'Makefile', '.gitignore',
18
+ '.npmrc', '.netrc', '.yarnrc', '.dockerignore', 'env.local', 'credentials',
19
+ ]);
20
+ const MAX_SIZE = 1024 * 1024; // 1 MB
21
+ function isScannable(name) {
22
+ if (name.startsWith('.env'))
23
+ return true;
24
+ if (name.startsWith('docker-compose'))
25
+ return true;
26
+ if (ALWAYS_NAMES.has(name))
27
+ return true;
28
+ return TEXT_EXT.has(extname(name).toLowerCase());
29
+ }
30
+ /** Recursively collect scannable text files under `root`, skipping noise. */
31
+ export function walk(root) {
32
+ const out = [];
33
+ let skippedOversized = 0;
34
+ let skippedUnreadable = 0;
35
+ const stack = [root];
36
+ while (stack.length > 0) {
37
+ const dir = stack.pop();
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(dir, { withFileTypes: true });
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ for (const ent of entries) {
46
+ if (ent.isSymbolicLink())
47
+ continue;
48
+ const full = join(dir, ent.name);
49
+ if (ent.isDirectory()) {
50
+ if (!SKIP_DIRS.has(ent.name))
51
+ stack.push(full);
52
+ continue;
53
+ }
54
+ if (!ent.isFile() || !isScannable(ent.name))
55
+ continue;
56
+ let st;
57
+ try {
58
+ st = statSync(full);
59
+ }
60
+ catch {
61
+ skippedUnreadable++;
62
+ continue;
63
+ }
64
+ if (st.size > MAX_SIZE) {
65
+ skippedOversized++;
66
+ continue;
67
+ }
68
+ let content;
69
+ try {
70
+ content = readFileSync(full, 'utf8');
71
+ }
72
+ catch {
73
+ skippedUnreadable++;
74
+ continue;
75
+ }
76
+ out.push({
77
+ abs: full,
78
+ rel: relative(root, full).split(sep).join('/'),
79
+ content,
80
+ ext: extname(ent.name).toLowerCase(),
81
+ size: st.size,
82
+ });
83
+ }
84
+ }
85
+ return { files: out, skippedOversized, skippedUnreadable };
86
+ }
@@ -0,0 +1,116 @@
1
+ import { scanStatic } from '../engine/scan.js';
2
+ import { collectEndpoints } from '../engine/endpoints.js';
3
+ import { applyIgnores, loadConfig } from '../engine/config.js';
4
+ import { auditDeps } from '../engine/checkers/deep/deps.js';
5
+ import { classifyKey, discoverSupabase, probeSupabase } from '../engine/checkers/backend/supabase.js';
6
+ import { discoverFirebase, probeFirebase } from '../engine/checkers/backend/firebase.js';
7
+ import { checkLiveSite } from '../engine/checkers/live/http-checks.js';
8
+ import { probeEndpointsUnauth } from '../engine/checkers/live/endpoint-probe.js';
9
+ import { idorDifferential } from '../engine/checkers/live/idor.js';
10
+ /**
11
+ * Tiered orchestrator shared by the CLI wizard and the skill. Every check that
12
+ * runs records a status (completed/partial/failed/skipped/unsupported) so a
13
+ * failed or not-run check is never mistaken for a clean result.
14
+ */
15
+ export async function runFlow(opts) {
16
+ const log = opts.log ?? (() => { });
17
+ const result = opts.precomputedStatic ?? (await scanStatic(opts.root, { configPath: opts.configPath }));
18
+ const findings = [...result.findings];
19
+ const runs = [...result.runs];
20
+ // Files used for backend discovery / endpoint enumeration honor ignorePaths,
21
+ // so an ignored path (e.g. tests/, fixtures/) does not feed the live probes.
22
+ const config = loadConfig(opts.root, opts.configPath);
23
+ const visible = result.files.filter((f) => !config.ignorePaths.some((sub) => f.rel.includes(sub)));
24
+ // Level 1 β€” dependency audit.
25
+ if (opts.runDeps) {
26
+ log('Level 1: dependency audit…');
27
+ const deps = await auditDeps(opts.root, result.detection.packageManagers);
28
+ findings.push(...deps.findings);
29
+ runs.push(deps.run);
30
+ }
31
+ // Level 2 β€” Supabase active probe (read-only).
32
+ const sbCreds = opts.supabaseUrl && opts.supabaseKey
33
+ ? { url: opts.supabaseUrl, anonKey: opts.supabaseKey, keyKind: classifyKey(opts.supabaseKey) === 'publishable' ? 'publishable' : 'jwt-anon' }
34
+ : discoverSupabase(visible);
35
+ if (sbCreds) {
36
+ const ok = await opts.consent({
37
+ kind: 'supabase',
38
+ target: sbCreds.url,
39
+ detail: 'read tables, buckets and RPC using the public key (read-only)',
40
+ });
41
+ if (ok === true) {
42
+ log(`Level 2: probing Supabase ${sbCreds.url}…`);
43
+ const r = await probeSupabase({ creds: sbCreds, log });
44
+ findings.push(...r.findings);
45
+ runs.push(r.run);
46
+ }
47
+ else {
48
+ runs.push({ id: 'supabase-probe', level: 2, status: 'skipped', note: 'declined' });
49
+ }
50
+ }
51
+ // Level 2 β€” Firebase active probe.
52
+ const fbCreds = discoverFirebase(visible);
53
+ if (fbCreds) {
54
+ const ok = await opts.consent({
55
+ kind: 'firebase',
56
+ target: fbCreds.projectId,
57
+ detail: 'anonymous reads of RTDB, Firestore and Storage',
58
+ });
59
+ if (ok === true) {
60
+ log(`Level 2: probing Firebase ${fbCreds.projectId}…`);
61
+ const r = await probeFirebase({ creds: fbCreds, log });
62
+ findings.push(...r.findings);
63
+ runs.push(r.run);
64
+ }
65
+ else {
66
+ runs.push({ id: 'firebase-probe', level: 2, status: 'skipped', note: 'declined' });
67
+ }
68
+ }
69
+ // Level 2 β€” live site + endpoint probe + IDOR.
70
+ if (opts.appUrl) {
71
+ const ok = await opts.consent({
72
+ kind: 'live',
73
+ target: opts.appUrl,
74
+ detail: 'passive checks (headers, exposed files) + unauthenticated endpoint probe',
75
+ });
76
+ if (ok === true) {
77
+ log(`Level 2: live checks on ${opts.appUrl}…`);
78
+ const site = await checkLiveSite(opts.appUrl);
79
+ findings.push(...site.findings);
80
+ runs.push(site.run);
81
+ const endpoints = collectEndpoints(visible);
82
+ const ep = await probeEndpointsUnauth(opts.appUrl, endpoints);
83
+ findings.push(...ep.findings);
84
+ runs.push(ep.run);
85
+ if (opts.idorTokens) {
86
+ const idorOk = await opts.consent({
87
+ kind: 'idor',
88
+ target: opts.appUrl,
89
+ detail: 'replay object-scoped endpoints with two accounts (IDOR test)',
90
+ });
91
+ if (idorOk === true) {
92
+ const r = await idorDifferential(opts.appUrl, endpoints, opts.idorTokens[0], opts.idorTokens[1]);
93
+ findings.push(...r.findings);
94
+ runs.push(r.run);
95
+ }
96
+ else {
97
+ runs.push({ id: 'idor', level: 2, status: 'skipped', note: 'declined' });
98
+ }
99
+ }
100
+ }
101
+ else {
102
+ // A URL only reaches here because the caller explicitly asked for it, so
103
+ // this is a check that was REQUESTED and did not run. That is missing
104
+ // coverage, not a clean result: `unsupported` makes the gate incomplete,
105
+ // where `skipped` would have let a never-run live check exit 0 as PASS.
106
+ const note = 'requested but not authorized β€” ownership was not confirmed';
107
+ runs.push({ id: 'live-site', level: 2, status: 'unsupported', note });
108
+ runs.push({ id: 'endpoint-probe', level: 2, status: 'unsupported', note });
109
+ if (opts.idorTokens)
110
+ runs.push({ id: 'idor', level: 2, status: 'unsupported', note });
111
+ }
112
+ }
113
+ // Apply config-based suppressions to the full finding set (Level 1/2 too).
114
+ const filtered = applyIgnores(findings, config, result.files);
115
+ return { ...result, findings: filtered, runs };
116
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "easyvibegate",
3
+ "version": "0.4.4",
4
+ "description": "Interactive security scanner for vibe-coded apps (focus: Next.js + Supabase). Checks common risks and shows evidence where it can.",
5
+ "type": "module",
6
+ "bin": {
7
+ "easyvibegate": "dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "node tests/run.mjs",
19
+ "prepare": "tsc",
20
+ "prepublishOnly": "tsc",
21
+ "easyvibegate": "node dist/cli/index.js"
22
+ },
23
+ "author": "valedol190387 (https://github.com/valedol190387)",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/valedol190387/easyvibegate.git"
27
+ },
28
+ "homepage": "https://github.com/valedol190387/easyvibegate#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/valedol190387/easyvibegate/issues"
31
+ },
32
+ "keywords": [
33
+ "security",
34
+ "vibe-coding",
35
+ "scanner",
36
+ "supabase",
37
+ "rls",
38
+ "secrets",
39
+ "audit"
40
+ ],
41
+ "license": "MIT",
42
+ "devDependencies": {
43
+ "@types/node": "^22.7.0",
44
+ "typescript": "^5.6.3"
45
+ }
46
+ }