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.
- package/LICENSE +21 -0
- package/README.en.md +123 -0
- package/README.md +144 -0
- package/dist/cli/index.js +402 -0
- package/dist/cli/wizard.js +196 -0
- package/dist/engine/aifix.js +65 -0
- package/dist/engine/checkers/backend/firebase.js +146 -0
- package/dist/engine/checkers/backend/supabase.js +249 -0
- package/dist/engine/checkers/deep/deps.js +118 -0
- package/dist/engine/checkers/index.js +15 -0
- package/dist/engine/checkers/live/endpoint-probe.js +72 -0
- package/dist/engine/checkers/live/http-checks.js +123 -0
- package/dist/engine/checkers/live/idor.js +101 -0
- package/dist/engine/checkers/static/client-exposure.js +34 -0
- package/dist/engine/checkers/static/config-risks.js +89 -0
- package/dist/engine/checkers/static/env-git.js +70 -0
- package/dist/engine/checkers/static/rls-migrations.js +324 -0
- package/dist/engine/checkers/static/route-inventory.js +31 -0
- package/dist/engine/checkers/static/secrets.js +262 -0
- package/dist/engine/config.js +54 -0
- package/dist/engine/detect.js +110 -0
- package/dist/engine/endpoints.js +65 -0
- package/dist/engine/i18n.js +189 -0
- package/dist/engine/net/http.js +108 -0
- package/dist/engine/report.js +219 -0
- package/dist/engine/scan.js +53 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/util/color.js +17 -0
- package/dist/engine/util/mask.js +66 -0
- package/dist/engine/util/text.js +50 -0
- package/dist/engine/version.js +12 -0
- package/dist/engine/walk.js +86 -0
- package/dist/orchestrator/flow.js +116 -0
- package/package.json +46 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { lineAt } from '../../util/text.js';
|
|
2
|
+
const IDENT = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
3
|
+
const QUALIFIED = `(?:(${IDENT})\\s*\\.\\s*)?(${IDENT})`;
|
|
4
|
+
const CREATE_TABLE = new RegExp(`create\\s+table\\s+(if\\s+not\\s+exists\\s+)?${QUALIFIED}`, 'gi');
|
|
5
|
+
const RLS_STMT = new RegExp(`alter\\s+table\\s+(?:only\\s+)?${QUALIFIED}\\s+(enable|disable)\\s+row\\s+level\\s+security`, 'gi');
|
|
6
|
+
const DROP_TABLE = new RegExp(`drop\\s+table\\s+(?:if\\s+exists\\s+)?${QUALIFIED}`, 'gi');
|
|
7
|
+
// SELECT ... INTO <table> also creates a table.
|
|
8
|
+
const SELECT_INTO = new RegExp(`\\bselect\\b[^;]{0,400}?\\binto\\s+${QUALIFIED}`, 'gis');
|
|
9
|
+
/** Normalize one SQL identifier: quoted keeps case, unquoted folds to lowercase. */
|
|
10
|
+
function normIdent(raw) {
|
|
11
|
+
if (raw.startsWith('"') && raw.endsWith('"'))
|
|
12
|
+
return raw.slice(1, -1);
|
|
13
|
+
if (raw.startsWith('`') && raw.endsWith('`'))
|
|
14
|
+
return raw.slice(1, -1);
|
|
15
|
+
return raw.toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
const keyOf = (schema, table) => `${schema ? normIdent(schema) : 'public'}.${normIdent(table)}`;
|
|
18
|
+
const displayOf = (schema, table) => (schema ? `${schema}.${table}` : table);
|
|
19
|
+
/**
|
|
20
|
+
* Blank out comments and string literals (keeping newlines and offsets) so that
|
|
21
|
+
* SQL inside a string — e.g. SELECT 'ALTER TABLE x ENABLE ROW LEVEL SECURITY' —
|
|
22
|
+
* or inside a comment is never mistaken for an executed statement.
|
|
23
|
+
* Handles -- and block comments, '...' with '' escapes (and E'...'), and
|
|
24
|
+
* $$ / $tag$ dollar-quoted strings. Double-quoted identifiers are kept.
|
|
25
|
+
*/
|
|
26
|
+
export function maskSql(sql) {
|
|
27
|
+
const out = sql.split('');
|
|
28
|
+
const n = sql.length;
|
|
29
|
+
const blank = (from, to) => {
|
|
30
|
+
for (let k = from; k < to && k < n; k++)
|
|
31
|
+
if (out[k] !== '\n')
|
|
32
|
+
out[k] = ' ';
|
|
33
|
+
};
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < n) {
|
|
36
|
+
const ch = sql[i];
|
|
37
|
+
const next = sql[i + 1];
|
|
38
|
+
if (ch === '-' && next === '-') {
|
|
39
|
+
let j = i;
|
|
40
|
+
while (j < n && sql[j] !== '\n')
|
|
41
|
+
j++;
|
|
42
|
+
blank(i, j);
|
|
43
|
+
i = j;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '/' && next === '*') {
|
|
47
|
+
const close = sql.indexOf('*/', i + 2);
|
|
48
|
+
const end = close === -1 ? n : close + 2;
|
|
49
|
+
blank(i, end);
|
|
50
|
+
i = end;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === "'") {
|
|
54
|
+
let j = i + 1;
|
|
55
|
+
while (j < n) {
|
|
56
|
+
if (sql[j] === "'") {
|
|
57
|
+
if (sql[j + 1] === "'") {
|
|
58
|
+
j += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
j++;
|
|
64
|
+
}
|
|
65
|
+
const end = Math.min(j + 1, n);
|
|
66
|
+
blank(i, end);
|
|
67
|
+
i = end;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === '$') {
|
|
71
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i, i + 64));
|
|
72
|
+
if (m) {
|
|
73
|
+
const tag = m[0];
|
|
74
|
+
const close = sql.indexOf(tag, i + tag.length);
|
|
75
|
+
const end = close === -1 ? n : close + tag.length;
|
|
76
|
+
// `DO $$ ... $$` is executed procedural code: its DDL is real, so keep the
|
|
77
|
+
// body visible. Other dollar-quoted strings (EXECUTE format(...)) are blanked.
|
|
78
|
+
const isDoBlock = /\bdo\s*$/i.test(sql.slice(Math.max(0, i - 8), i));
|
|
79
|
+
if (isDoBlock) {
|
|
80
|
+
const bodyStart = i + tag.length;
|
|
81
|
+
const bodyEnd = close === -1 ? n : close;
|
|
82
|
+
// Mask inside the body too (a string literal there is still a string),
|
|
83
|
+
// then jump past the CLOSING delimiter — scanning from just after the
|
|
84
|
+
// opening one would read that closing `$$` as a new opening tag and
|
|
85
|
+
// blank the entire rest of the file, hiding every later statement.
|
|
86
|
+
const inner = maskSql(sql.slice(bodyStart, bodyEnd));
|
|
87
|
+
for (let k = 0; k < inner.length; k++)
|
|
88
|
+
out[bodyStart + k] = inner[k];
|
|
89
|
+
blank(i, bodyStart);
|
|
90
|
+
blank(bodyEnd, end);
|
|
91
|
+
i = end;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
blank(i, end);
|
|
95
|
+
i = end;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
return out.join('');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Blank out only comments, preserving length and the `$$` delimiters that
|
|
105
|
+
* `maskSql` removes. Guard analysis needs the delimiters to find DO blocks, but
|
|
106
|
+
* must not read `-- end if` in a comment as a real block terminator.
|
|
107
|
+
*/
|
|
108
|
+
function maskSqlComments(sql) {
|
|
109
|
+
const out = sql.split('');
|
|
110
|
+
const n = sql.length;
|
|
111
|
+
const blank = (from, to) => {
|
|
112
|
+
for (let k = from; k < to && k < n; k++)
|
|
113
|
+
if (out[k] !== '\n')
|
|
114
|
+
out[k] = ' ';
|
|
115
|
+
};
|
|
116
|
+
let i = 0;
|
|
117
|
+
while (i < n) {
|
|
118
|
+
if (sql[i] === '-' && sql[i + 1] === '-') {
|
|
119
|
+
let j = i;
|
|
120
|
+
while (j < n && sql[j] !== '\n')
|
|
121
|
+
j++;
|
|
122
|
+
blank(i, j);
|
|
123
|
+
i = j;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (sql[i] === '/' && sql[i + 1] === '*') {
|
|
127
|
+
const c = sql.indexOf('*/', i + 2);
|
|
128
|
+
const end = c === -1 ? n : c + 2;
|
|
129
|
+
blank(i, end);
|
|
130
|
+
i = end;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
return out.join('');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Spans inside a `DO $$ … $$` body that sit between an `IF … THEN` and its
|
|
139
|
+
* `END IF`. DDL there runs only when the condition holds, and deciding that
|
|
140
|
+
* needs an interpreter — so statements in these spans are reported as
|
|
141
|
+
* "cannot be confirmed" rather than assumed to have run.
|
|
142
|
+
*
|
|
143
|
+
* IFs nest, so this matches them with a stack: taking the first `END IF` as the
|
|
144
|
+
* outer block's terminator ends the guard early and lets a statement after the
|
|
145
|
+
* inner `END IF` look unconditional. `ELSIF` is not an opener (`\bif\b` does not
|
|
146
|
+
* match inside it). Offsets are preserved, so they line up with `maskSql` output.
|
|
147
|
+
*/
|
|
148
|
+
function conditionalRanges(sql) {
|
|
149
|
+
const src = maskSqlComments(sql);
|
|
150
|
+
const ranges = [];
|
|
151
|
+
for (const m of src.matchAll(/\bdo\s*(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)/gi)) {
|
|
152
|
+
const tag = m[1];
|
|
153
|
+
const bodyStart = (m.index ?? 0) + m[0].length;
|
|
154
|
+
const close = src.indexOf(tag, bodyStart);
|
|
155
|
+
const bodyEnd = close === -1 ? src.length : close;
|
|
156
|
+
const body = src.slice(bodyStart, bodyEnd);
|
|
157
|
+
const open = [];
|
|
158
|
+
for (const t of body.matchAll(/\bend\s+if\b|\bif\b/gi)) {
|
|
159
|
+
const at = t.index ?? 0;
|
|
160
|
+
if (/^end/i.test(t[0])) {
|
|
161
|
+
const from = open.pop();
|
|
162
|
+
if (from !== undefined)
|
|
163
|
+
ranges.push([bodyStart + from, bodyStart + at]);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
open.push(at);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// An IF left unterminated guards everything to the end of the block.
|
|
170
|
+
for (const from of open)
|
|
171
|
+
ranges.push([bodyStart + from, bodyEnd]);
|
|
172
|
+
}
|
|
173
|
+
return ranges;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Flags each table whose latest state after replaying the migrations is
|
|
177
|
+
* "created and RLS not enabled". Migrations are replayed in apply order
|
|
178
|
+
* (files by name, statements by offset); CREATE / ENABLE / DISABLE / DROP are
|
|
179
|
+
* all modeled; CREATE IF NOT EXISTS on an existing table is a no-op; comments
|
|
180
|
+
* and string literals are ignored; quoted and schema-qualified names work.
|
|
181
|
+
*/
|
|
182
|
+
export const rlsMigrationsChecker = {
|
|
183
|
+
id: 'rls-migrations',
|
|
184
|
+
title: 'Tables created without RLS',
|
|
185
|
+
level: 0,
|
|
186
|
+
run(ctx) {
|
|
187
|
+
const sqlFiles = ctx.files.filter((f) => f.ext === '.sql').sort((a, b) => a.rel.localeCompare(b.rel));
|
|
188
|
+
if (sqlFiles.length === 0)
|
|
189
|
+
return [];
|
|
190
|
+
// RLS is a PostgreSQL feature. Do not tell a MySQL/SQLite project to enable it.
|
|
191
|
+
const allSql = sqlFiles.map((f) => f.content).join('\n');
|
|
192
|
+
const pkg = ctx.files.find((f) => f.rel === 'package.json')?.content ?? '';
|
|
193
|
+
const postgresish = ctx.detection.backends.includes('supabase') ||
|
|
194
|
+
/\b(pg|postgres|postgresql|@supabase\/|drizzle-orm|postgres\.js|node-postgres)\b/i.test(pkg) ||
|
|
195
|
+
/(enable\s+row\s+level\s+security|gen_random_uuid|\bserial\b|::\s*\w+|\bjsonb\b)/i.test(allSql) ||
|
|
196
|
+
/\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(ctx.files.find((f) => f.rel === 'requirements.txt')?.content ?? '');
|
|
197
|
+
const otherEngine = /(engine\s*=\s*innodb|auto_increment|\bpragma\b|`\w+`\s*varchar)/i.test(allSql) ||
|
|
198
|
+
/\b(mysql2?|sqlite3|better-sqlite3|mariadb)\b/i.test(pkg);
|
|
199
|
+
if (otherEngine && !postgresish)
|
|
200
|
+
return [];
|
|
201
|
+
const events = [];
|
|
202
|
+
sqlFiles.forEach((f, fileIdx) => {
|
|
203
|
+
const masked = maskSql(f.content);
|
|
204
|
+
// Computed on the raw text: maskSql blanks the `$$` delimiters themselves,
|
|
205
|
+
// so the DO blocks are no longer findable there. It preserves length, so
|
|
206
|
+
// offsets from `masked` line up with these ranges exactly.
|
|
207
|
+
const guards = conditionalRanges(f.content);
|
|
208
|
+
const push = (kind, schema, table, offset, ifNotExists = false) => events.push({
|
|
209
|
+
kind, key: keyOf(schema, table), ifNotExists, file: f.rel, line: lineAt(f.content, offset),
|
|
210
|
+
display: displayOf(schema, table), fileIdx, offset,
|
|
211
|
+
conditional: guards.some(([a, b]) => offset >= a && offset < b),
|
|
212
|
+
});
|
|
213
|
+
for (const m of masked.matchAll(CREATE_TABLE))
|
|
214
|
+
push('create', m[2], m[3] ?? '', m.index ?? 0, !!m[1]);
|
|
215
|
+
for (const m of masked.matchAll(RLS_STMT))
|
|
216
|
+
push((m[3] ?? '').toLowerCase() === 'disable' ? 'disable' : 'enable', m[1], m[2] ?? '', m.index ?? 0);
|
|
217
|
+
for (const m of masked.matchAll(DROP_TABLE))
|
|
218
|
+
push('drop', m[1], m[2] ?? '', m.index ?? 0);
|
|
219
|
+
for (const m of masked.matchAll(SELECT_INTO))
|
|
220
|
+
push('create', m[1], m[2] ?? '', m.index ?? 0);
|
|
221
|
+
});
|
|
222
|
+
// True apply order: by migration file, then by statement position in the file.
|
|
223
|
+
events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
|
|
224
|
+
const state = new Map();
|
|
225
|
+
for (const e of events) {
|
|
226
|
+
const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false };
|
|
227
|
+
// Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
|
|
228
|
+
// A conditional DROP used to delete the table from the model outright, so
|
|
229
|
+
// a table left unprotected vanished from the report entirely. A later
|
|
230
|
+
// unconditional statement settles the state and clears the doubt.
|
|
231
|
+
if (e.conditional) {
|
|
232
|
+
// Keep the table in the model: assume the guarded branch did NOT run
|
|
233
|
+
// (the outcome that leaves data exposed), and record the uncertainty.
|
|
234
|
+
if (e.kind === 'create' && !cur.created) {
|
|
235
|
+
cur.created = true;
|
|
236
|
+
cur.enabled = false;
|
|
237
|
+
cur.file = e.file;
|
|
238
|
+
cur.line = e.line;
|
|
239
|
+
cur.display = e.display;
|
|
240
|
+
}
|
|
241
|
+
if (e.kind === 'enable')
|
|
242
|
+
cur.enabled = true;
|
|
243
|
+
if (e.kind === 'disable')
|
|
244
|
+
cur.enabled = false;
|
|
245
|
+
cur.guarded = true;
|
|
246
|
+
cur.stateFile = e.file;
|
|
247
|
+
state.set(e.key, cur);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
switch (e.kind) {
|
|
251
|
+
case 'create':
|
|
252
|
+
if (e.ifNotExists && cur.created)
|
|
253
|
+
break; // existing table: no-op, keep RLS state
|
|
254
|
+
cur.created = true;
|
|
255
|
+
cur.enabled = false;
|
|
256
|
+
cur.file = e.file;
|
|
257
|
+
cur.line = e.line;
|
|
258
|
+
cur.display = e.display;
|
|
259
|
+
cur.stateFile = e.file;
|
|
260
|
+
cur.guarded = false;
|
|
261
|
+
break;
|
|
262
|
+
case 'enable':
|
|
263
|
+
cur.enabled = true;
|
|
264
|
+
cur.stateFile = e.file;
|
|
265
|
+
cur.guarded = false;
|
|
266
|
+
break;
|
|
267
|
+
case 'disable':
|
|
268
|
+
cur.enabled = false;
|
|
269
|
+
cur.stateFile = e.file;
|
|
270
|
+
cur.guarded = false;
|
|
271
|
+
break;
|
|
272
|
+
case 'drop':
|
|
273
|
+
cur.created = false;
|
|
274
|
+
cur.enabled = false;
|
|
275
|
+
cur.stateFile = e.file;
|
|
276
|
+
cur.guarded = false;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
state.set(e.key, cur);
|
|
280
|
+
}
|
|
281
|
+
// Within one directory, filename order IS the apply order, so the replay above
|
|
282
|
+
// is authoritative. Across directories it is a guess (a root-level
|
|
283
|
+
// `rls_policies.sql` sorts before `supabase/migrations/003_*.sql`).
|
|
284
|
+
//
|
|
285
|
+
// The ambiguity is symmetric: it matters whenever a statement of the OPPOSITE
|
|
286
|
+
// polarity to the final state lives in another directory — an ENABLE that the
|
|
287
|
+
// sort happened to put last is no more trustworthy than one it put first.
|
|
288
|
+
// Checking only the RLS-off direction let `a/…DISABLE` + `z/…ENABLE` pass clean.
|
|
289
|
+
const dirOf = (rel) => { const i = rel.lastIndexOf('/'); return i === -1 ? '' : rel.slice(0, i); };
|
|
290
|
+
const turnsOff = (k) => k === 'disable' || k === 'create';
|
|
291
|
+
const findings = [];
|
|
292
|
+
for (const [key, s] of state) {
|
|
293
|
+
if (!s.created)
|
|
294
|
+
continue;
|
|
295
|
+
const ambiguous = events.some((e) => e.key === key && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
|
|
296
|
+
if (s.enabled && !ambiguous && !s.guarded)
|
|
297
|
+
continue; // provably protected
|
|
298
|
+
// `guarded` means a statement we had to GUESS about decided this table's
|
|
299
|
+
// state — a conditional ENABLE, DISABLE, CREATE or DROP. Calling that
|
|
300
|
+
// "critical" would be the same false confidence as calling it clean, so it
|
|
301
|
+
// is reported as unconfirmed. A warning still fails CI (exit 1); it just
|
|
302
|
+
// does not claim to know what only the database can tell.
|
|
303
|
+
const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
|
|
304
|
+
findings.push({
|
|
305
|
+
id: 'rls_missing',
|
|
306
|
+
severity: kind === 'missing' ? 'critical' : 'warning',
|
|
307
|
+
title: kind === 'missing' ? `Table "${s.display}" created without RLS`
|
|
308
|
+
: kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
|
|
309
|
+
: `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
|
|
310
|
+
detail: kind === 'missing'
|
|
311
|
+
? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. If this table holds user data on Supabase, the anon key can read every row.`
|
|
312
|
+
: kind === 'order'
|
|
313
|
+
? `"${s.display}" has statements in directories other than "${s.stateFile}" that contradict its final RLS state. Files in separate directories have no reliable apply order, so this cannot be decided statically — check the deployed state.`
|
|
314
|
+
: `"${s.display}" has RLS statements inside an "IF … THEN" guard in a DO block. Whether that branch runs cannot be decided without executing the migration, so its RLS state is NOT confirmed — check the deployed state.`,
|
|
315
|
+
fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). This is a static hint — confirm the deployed state.`,
|
|
316
|
+
checker: 'rls-migrations',
|
|
317
|
+
level: 0,
|
|
318
|
+
file: s.file,
|
|
319
|
+
line: s.line,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return findings;
|
|
323
|
+
},
|
|
324
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { collectEndpoints } from '../../endpoints.js';
|
|
2
|
+
/**
|
|
3
|
+
* Advisory: enumerate the app's HTTP endpoints as targets for the Level 2 live
|
|
4
|
+
* probe. It deliberately does NOT claim any of them lack auth — access checks
|
|
5
|
+
* usually live in middleware, so a static verdict here would be pure noise.
|
|
6
|
+
*/
|
|
7
|
+
export const routeInventoryChecker = {
|
|
8
|
+
id: 'route-inventory',
|
|
9
|
+
title: 'Endpoint inventory (targets for live probe)',
|
|
10
|
+
level: 0,
|
|
11
|
+
run(ctx) {
|
|
12
|
+
const endpoints = collectEndpoints(ctx.files);
|
|
13
|
+
if (endpoints.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const preview = endpoints
|
|
16
|
+
.slice(0, 12)
|
|
17
|
+
.map((e) => `${e.method} ${e.path}`)
|
|
18
|
+
.join(', ');
|
|
19
|
+
const more = endpoints.length > 12 ? ` (+${endpoints.length - 12} more)` : '';
|
|
20
|
+
const finding = {
|
|
21
|
+
id: 'endpoint_inventory',
|
|
22
|
+
severity: 'advisory',
|
|
23
|
+
title: `${endpoints.length} endpoint(s) discovered`,
|
|
24
|
+
detail: `Targets for the Level 2 live probe: ${preview}${more}. Static analysis cannot tell if these enforce access control — run the live probe to confirm.`,
|
|
25
|
+
fix: 'Run EasyVibeGate Level 2 against the running app to test each endpoint without auth and, with two accounts, for cross-user access (IDOR).',
|
|
26
|
+
checker: 'route-inventory',
|
|
27
|
+
level: 0,
|
|
28
|
+
};
|
|
29
|
+
return [finding];
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
|
|
2
|
+
/** Real tokens mix case and digits; kebab-case identifiers do not. */
|
|
3
|
+
function looksRandom(s) {
|
|
4
|
+
const body = s.replace(/^[a-z]+[-_]/i, '');
|
|
5
|
+
return /[A-Z]/.test(body) && /[0-9]/.test(body) && shannonEntropy(body) >= 3.2;
|
|
6
|
+
}
|
|
7
|
+
const PATTERNS = [
|
|
8
|
+
{
|
|
9
|
+
id: 'openai_key',
|
|
10
|
+
title: 'OpenAI API key',
|
|
11
|
+
re: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
12
|
+
severity: 'critical',
|
|
13
|
+
fix: 'Remove the key from source, read it from a server-side env var, and rotate it in the OpenAI dashboard.',
|
|
14
|
+
// ".sk-chase-dot-before-animation-delay" is a CSS class, not a key.
|
|
15
|
+
validate: looksRandom,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'anthropic_key',
|
|
19
|
+
title: 'Anthropic API key',
|
|
20
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
21
|
+
severity: 'critical',
|
|
22
|
+
fix: 'Move the key to a server-side env var and rotate it in the Anthropic console.',
|
|
23
|
+
validate: looksRandom,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'aws_key',
|
|
27
|
+
title: 'AWS access key ID',
|
|
28
|
+
re: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
29
|
+
severity: 'critical',
|
|
30
|
+
fix: 'Deactivate the key in IAM, rotate it, and never commit AWS credentials.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'stripe_live',
|
|
34
|
+
title: 'Stripe live secret key',
|
|
35
|
+
re: /\b[sr]k_live_[0-9a-zA-Z]{20,}\b/g,
|
|
36
|
+
severity: 'critical',
|
|
37
|
+
fix: 'Roll the key in the Stripe dashboard immediately and keep it server-side only.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'github_token',
|
|
41
|
+
title: 'GitHub token',
|
|
42
|
+
re: /\b(?:ghp|gho|ghu|ghs|ghr)_[0-9A-Za-z]{36,}\b|\bgithub_pat_[0-9A-Za-z_]{22,}\b/g,
|
|
43
|
+
severity: 'critical',
|
|
44
|
+
fix: 'Revoke the token in GitHub settings and use a secret store instead.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'slack_token',
|
|
48
|
+
title: 'Slack token',
|
|
49
|
+
re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g,
|
|
50
|
+
severity: 'critical',
|
|
51
|
+
fix: 'Revoke the token in the Slack app settings and rotate it.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'sendgrid_key',
|
|
55
|
+
title: 'SendGrid API key',
|
|
56
|
+
re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g,
|
|
57
|
+
severity: 'critical',
|
|
58
|
+
fix: 'Revoke the key in SendGrid and store it server-side only.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'hf_token',
|
|
62
|
+
title: 'Hugging Face token',
|
|
63
|
+
re: /\bhf_[A-Za-z0-9]{30,}\b/g,
|
|
64
|
+
severity: 'critical',
|
|
65
|
+
fix: 'Revoke the token in Hugging Face settings and keep it server-side.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'npm_token',
|
|
69
|
+
title: 'npm access token',
|
|
70
|
+
re: /\bnpm_[A-Za-z0-9]{30,}\b/g,
|
|
71
|
+
severity: 'critical',
|
|
72
|
+
fix: 'Revoke the token on npmjs.com and use a CI secret instead.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'google_api_key',
|
|
76
|
+
title: 'Google API key',
|
|
77
|
+
re: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
78
|
+
severity: 'warning',
|
|
79
|
+
fix: 'Restrict the key by API and referrer, or move it server-side, then rotate it.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'telegram_bot',
|
|
83
|
+
title: 'Telegram bot token',
|
|
84
|
+
re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g,
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
fix: 'Revoke the token via BotFather and keep it server-side.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'db_url_password',
|
|
90
|
+
title: 'Database URL with an inline password',
|
|
91
|
+
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s:/@"']+:([^\s:/@"']{4,})@[^\s"']+/gi,
|
|
92
|
+
severity: 'critical',
|
|
93
|
+
fix: 'Move the connection string to a server-side env var and rotate the database password — a committed DB URL grants full data access.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'supabase_secret_key',
|
|
97
|
+
title: 'Supabase secret key',
|
|
98
|
+
re: /\bsb_secret_[A-Za-z0-9_-]{10,}\b/g,
|
|
99
|
+
severity: 'critical',
|
|
100
|
+
fix: 'This is a Supabase secret key (full DB access). Remove it, rotate it in Supabase settings, and keep it server-side only.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'private_key',
|
|
104
|
+
title: 'Private key material',
|
|
105
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
|
|
106
|
+
severity: 'critical',
|
|
107
|
+
fix: 'Remove the private key from the repo, rotate the key pair, and store secrets outside source control.',
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
const GENERIC = /(?:api[_-]?key|secret|token|passwd|password|pwd|auth[_-]?token|access[_-]?token|client[_-]?secret|credential)["']?\s*[:=]\s*["']([^"']{8,})["']/gi;
|
|
111
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
112
|
+
// KEY=value / key: value / Dockerfile ENV|ARG KEY=value, with a secret-looking NAME.
|
|
113
|
+
const ASSIGN = /^[ \t]*(?:export[ \t]+|ENV[ \t]+|ARG[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*(.+)$/gm;
|
|
114
|
+
const SECRET_NAME = /(secret|token|password|passwd|private[_-]?key|api[_-]?key|access[_-]?key|credential)/i;
|
|
115
|
+
/** Only .env* files are "server env by design" — a secret there is a warning
|
|
116
|
+
* (env-git flags committing it). In real code/config it stays a source leak. */
|
|
117
|
+
function isEnvFile(rel) {
|
|
118
|
+
return /(^|\/)\.env($|\.)/.test(rel) && !/\.(example|sample|template)$/.test(rel);
|
|
119
|
+
}
|
|
120
|
+
/** Files where name=value secrets are worth scanning (env + common config). */
|
|
121
|
+
function isConfigish(rel) {
|
|
122
|
+
return /(^|\/)\.env($|\.)/.test(rel) || /\.(ya?ml|toml|ini|conf|properties|npmrc|netrc)$/.test(rel)
|
|
123
|
+
|| /(^|\/)(Dockerfile|\.npmrc|\.netrc)$/.test(rel) || /docker-compose\.ya?ml$/.test(rel);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Documentation, examples and test fixtures are where sample keys legitimately
|
|
127
|
+
* live. A hit there is worth mentioning but is not a credential leak.
|
|
128
|
+
*/
|
|
129
|
+
function isExampleContext(rel) {
|
|
130
|
+
return /\.(md|txt|mdx|rst)$/i.test(rel)
|
|
131
|
+
|| /\.(example|sample|template|dist)$/i.test(rel)
|
|
132
|
+
|| /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
|
|
133
|
+
|| /\.(test|spec)\.[a-z]+$/i.test(rel);
|
|
134
|
+
}
|
|
135
|
+
export const secretsChecker = {
|
|
136
|
+
id: 'secrets',
|
|
137
|
+
title: 'Hardcoded secrets',
|
|
138
|
+
level: 0,
|
|
139
|
+
run(ctx) {
|
|
140
|
+
const findings = [];
|
|
141
|
+
for (const file of ctx.files) {
|
|
142
|
+
const { rel } = file;
|
|
143
|
+
const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
|
|
144
|
+
const env = isEnvFile(rel);
|
|
145
|
+
const example = isExampleContext(rel);
|
|
146
|
+
const push = (f) => {
|
|
147
|
+
if (example) {
|
|
148
|
+
findings.push({
|
|
149
|
+
...f,
|
|
150
|
+
severity: 'info',
|
|
151
|
+
title: `${f.title} (in docs/example file)`,
|
|
152
|
+
detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
findings.push(f);
|
|
157
|
+
};
|
|
158
|
+
for (const p of PATTERNS) {
|
|
159
|
+
for (const m of content.matchAll(p.re)) {
|
|
160
|
+
const hit = m[0];
|
|
161
|
+
if (looksLikePlaceholder(hit))
|
|
162
|
+
continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
|
|
163
|
+
if (p.validate && !p.validate(hit))
|
|
164
|
+
continue;
|
|
165
|
+
// A secret in a server env/config file is expected — the risk is
|
|
166
|
+
// committing it (env-git flags that), so it is a warning, not a leak.
|
|
167
|
+
const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
|
|
168
|
+
push({
|
|
169
|
+
id: p.id,
|
|
170
|
+
severity,
|
|
171
|
+
title: env ? `${p.title} (in env/config file)` : p.title,
|
|
172
|
+
detail: env
|
|
173
|
+
? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
|
|
174
|
+
: `${p.title} found in source: ${redact(hit)}`,
|
|
175
|
+
fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
|
|
176
|
+
checker: 'secrets',
|
|
177
|
+
level: 0,
|
|
178
|
+
file: rel,
|
|
179
|
+
line: lineAt(content, m.index ?? 0),
|
|
180
|
+
evidence: redact(hit),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Supabase service_role key (a JWT whose payload role is service_role).
|
|
185
|
+
for (const m of content.matchAll(JWT)) {
|
|
186
|
+
const payload = decodeJwtPayload(m[0]);
|
|
187
|
+
if (payload && payload['role'] === 'service_role') {
|
|
188
|
+
push({
|
|
189
|
+
id: 'supabase_service_role_key',
|
|
190
|
+
severity: env ? 'warning' : 'critical',
|
|
191
|
+
title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
|
|
192
|
+
detail: env
|
|
193
|
+
? `A service_role JWT is in ${rel}. Fine for server env only — never commit it or ship it to the client; keep the file gitignored.`
|
|
194
|
+
: 'A service_role JWT bypasses Row Level Security entirely and is in source/client code. It must never ship to the client or the repo.',
|
|
195
|
+
fix: env
|
|
196
|
+
? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
|
|
197
|
+
: 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
|
|
198
|
+
checker: 'secrets',
|
|
199
|
+
level: 0,
|
|
200
|
+
file: rel,
|
|
201
|
+
line: lineAt(content, m.index ?? 0),
|
|
202
|
+
evidence: redact(m[0]),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Quoted key/secret assignments in code, filtered by placeholder + entropy.
|
|
207
|
+
for (const m of content.matchAll(GENERIC)) {
|
|
208
|
+
const value = m[1] ?? '';
|
|
209
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
|
|
210
|
+
continue;
|
|
211
|
+
push({
|
|
212
|
+
id: 'generic_secret',
|
|
213
|
+
severity: 'warning',
|
|
214
|
+
title: 'Possible hardcoded secret',
|
|
215
|
+
detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
|
|
216
|
+
fix: 'If this is a real credential, move it to a server-side env var and rotate it. If not, rename the variable or add `// easyvibegate-ignore`.',
|
|
217
|
+
checker: 'secrets',
|
|
218
|
+
level: 0,
|
|
219
|
+
file: rel,
|
|
220
|
+
line: lineAt(content, m.index ?? 0),
|
|
221
|
+
evidence: redact(value),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
|
|
225
|
+
if (isConfigish(rel)) {
|
|
226
|
+
for (const m of content.matchAll(ASSIGN)) {
|
|
227
|
+
const name = m[1] ?? '';
|
|
228
|
+
if (!SECRET_NAME.test(name))
|
|
229
|
+
continue;
|
|
230
|
+
const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
231
|
+
if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
232
|
+
continue;
|
|
233
|
+
push({
|
|
234
|
+
id: 'env_secret',
|
|
235
|
+
severity: 'warning',
|
|
236
|
+
title: env ? 'Secret in env file' : 'Secret in config file',
|
|
237
|
+
detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
|
|
238
|
+
fix: env
|
|
239
|
+
? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
|
|
240
|
+
: 'Move this secret out of committed config into a server-side secret store, and rotate it.',
|
|
241
|
+
checker: 'secrets',
|
|
242
|
+
level: 0,
|
|
243
|
+
file: rel,
|
|
244
|
+
line: lineAt(content, m.index ?? 0),
|
|
245
|
+
evidence: redact(value),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// De-duplicate only true repeats: the same secret, same place, same rule.
|
|
251
|
+
// (Keying on file:line alone hid every extra key on a minified line.)
|
|
252
|
+
const rank = { critical: 0, warning: 1, info: 2, advisory: 3 };
|
|
253
|
+
const seen = new Map();
|
|
254
|
+
for (const f of findings) {
|
|
255
|
+
const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
|
|
256
|
+
const cur = seen.get(key);
|
|
257
|
+
if (!cur || rank[f.severity] < rank[cur.severity])
|
|
258
|
+
seen.set(key, f);
|
|
259
|
+
}
|
|
260
|
+
return [...seen.values()];
|
|
261
|
+
},
|
|
262
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const DEFAULT_CONFIG = { ignore: [], ignorePaths: [] };
|
|
4
|
+
const CONFIG_NAMES = ['easyvibegate.config.json', '.easyvibegaterc.json'];
|
|
5
|
+
export function loadConfig(root, explicitPath) {
|
|
6
|
+
const candidates = explicitPath ? [explicitPath] : CONFIG_NAMES.map((n) => join(root, n));
|
|
7
|
+
for (const p of candidates) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = readFileSync(p, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
continue; // no such config here — try the next candidate
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(text);
|
|
17
|
+
return {
|
|
18
|
+
ignore: Array.isArray(raw.ignore) ? raw.ignore : [],
|
|
19
|
+
ignorePaths: Array.isArray(raw.ignorePaths) ? raw.ignorePaths : [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
// The file exists but is broken: say so instead of silently ignoring every rule.
|
|
24
|
+
return { ...DEFAULT_CONFIG, problem: `${p}: invalid JSON (${e instanceof Error ? e.message : String(e)})` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_CONFIG;
|
|
28
|
+
}
|
|
29
|
+
const INLINE_MARKER = 'easyvibegate-ignore';
|
|
30
|
+
/** Drop findings suppressed by config rules or inline `// easyvibegate-ignore` markers. */
|
|
31
|
+
export function applyIgnores(findings, config, files) {
|
|
32
|
+
const byRel = new Map(files.map((f) => [f.rel, f.content.split('\n')]));
|
|
33
|
+
const isConfigIgnored = (f) => {
|
|
34
|
+
const keys = [f.id, f.checker];
|
|
35
|
+
if (f.file)
|
|
36
|
+
keys.push(`${f.id}:${f.file}`, `${f.checker}:${f.file}`);
|
|
37
|
+
if (config.ignore.some((rule) => keys.includes(rule)))
|
|
38
|
+
return true;
|
|
39
|
+
if (f.file && config.ignorePaths.some((sub) => f.file.includes(sub)))
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
const isInlineIgnored = (f) => {
|
|
44
|
+
if (!f.file || !f.line)
|
|
45
|
+
return false;
|
|
46
|
+
const lines = byRel.get(f.file);
|
|
47
|
+
if (!lines)
|
|
48
|
+
return false;
|
|
49
|
+
const current = lines[f.line - 1] ?? '';
|
|
50
|
+
const prev = f.line >= 2 ? lines[f.line - 2] ?? '' : '';
|
|
51
|
+
return current.includes(INLINE_MARKER) || prev.includes(INLINE_MARKER);
|
|
52
|
+
};
|
|
53
|
+
return findings.filter((f) => !isConfigIgnored(f) && !isInlineIgnored(f));
|
|
54
|
+
}
|