easyvibegate 0.4.4 → 0.6.2
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/README.en.md +5 -9
- package/README.md +30 -12
- package/dist/cli/index.js +40 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +79 -55
- package/dist/engine/checkers/backend/supabase.js +117 -91
- package/dist/engine/checkers/deep/deps.js +87 -29
- package/dist/engine/checkers/live/endpoint-probe.js +29 -33
- package/dist/engine/checkers/live/http-checks.js +23 -15
- package/dist/engine/checkers/live/idor.js +65 -49
- package/dist/engine/checkers/static/client-exposure.js +58 -3
- package/dist/engine/checkers/static/config-risks.js +70 -10
- package/dist/engine/checkers/static/rls-migrations.js +706 -192
- package/dist/engine/checkers/static/secrets.js +259 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +24 -12
- package/dist/engine/endpoints.js +219 -13
- package/dist/engine/i18n.js +6 -2
- package/dist/engine/net/http.js +68 -0
- package/dist/engine/report.js +11 -1
- package/dist/engine/scan.js +16 -7
- package/dist/engine/util/code-lex.js +260 -0
- package/dist/engine/util/git-exposure.js +33 -0
- package/dist/engine/util/mask.js +32 -44
- package/dist/engine/util/sql-lex.js +172 -0
- package/dist/engine/util/text.js +12 -0
- package/dist/engine/walk.js +52 -7
- package/dist/orchestrator/flow.js +137 -82
- package/package.json +1 -1
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
|
|
2
|
+
import { createExposure } from '../../util/git-exposure.js';
|
|
3
|
+
import { partitionIgnores } from '../../config.js';
|
|
4
|
+
/** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
|
|
5
|
+
const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
|
|
6
|
+
/** Lower number = more severe. One copy, used both to cap a severity and to rank duplicates. */
|
|
7
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2, advisory: 3 };
|
|
8
|
+
const atMost = (sev, cap) => (SEVERITY_RANK[sev] < SEVERITY_RANK[cap] ? cap : sev);
|
|
2
9
|
/** Real tokens mix case and digits; kebab-case identifiers do not. */
|
|
3
10
|
function looksRandom(s) {
|
|
4
11
|
const body = s.replace(/^[a-z]+[-_]/i, '');
|
|
5
12
|
return /[A-Z]/.test(body) && /[0-9]/.test(body) && shannonEntropy(body) >= 3.2;
|
|
6
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Detectors whose match is key MATERIAL by structure (a vendor prefix plus a
|
|
16
|
+
* random body, a PEM block, a DB password), as opposed to the name-based
|
|
17
|
+
* heuristics (generic_secret / env_secret). A hit from one of these in a
|
|
18
|
+
* docs/fixtures path is still a leak if the value is real — a fresh RSA key
|
|
19
|
+
* pasted into docs/deploy.md was once downgraded to info and PASSed the gate.
|
|
20
|
+
*/
|
|
21
|
+
const HIGH_CONFIDENCE = new Set([
|
|
22
|
+
'openai_key', 'anthropic_key', 'aws_key', 'stripe_live', 'github_token', 'slack_token',
|
|
23
|
+
'sendgrid_key', 'hf_token', 'npm_token', 'db_url_password', 'supabase_secret_key', 'private_key',
|
|
24
|
+
]);
|
|
25
|
+
const PEM_END = /-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a high-confidence hit has the body of a real credential, so a
|
|
28
|
+
* docs/example path alone must not silence it. Anything obviously hand-typed
|
|
29
|
+
* (a PEM with no base64 body, a DB URL whose password is a plain word, a key
|
|
30
|
+
* with no digits) is treated as a sample and may still go to info.
|
|
31
|
+
*/
|
|
32
|
+
function looksLikeRealMaterial(id, hit, content, index, group) {
|
|
33
|
+
if (id === 'private_key') {
|
|
34
|
+
// The regex only matches the header; the material is what follows it.
|
|
35
|
+
const rest = content.slice(index + hit.length);
|
|
36
|
+
const endAt = rest.search(PEM_END);
|
|
37
|
+
const body = (endAt >= 0 ? rest.slice(0, endAt) : rest.slice(0, 4096)).replace(/\s+/g, '');
|
|
38
|
+
return body.length >= 64 && /^[A-Za-z0-9+/=]+$/.test(body);
|
|
39
|
+
}
|
|
40
|
+
if (id === 'db_url_password') {
|
|
41
|
+
const pw = group ?? '';
|
|
42
|
+
return /[0-9]/.test(pw) && /[A-Za-z]/.test(pw) && shannonEntropy(pw) >= 3.0;
|
|
43
|
+
}
|
|
44
|
+
if (id === 'aws_key')
|
|
45
|
+
return /[0-9]/.test(hit);
|
|
46
|
+
return looksRandom(hit);
|
|
47
|
+
}
|
|
48
|
+
const DEFAULT_PASSWORDS = new Set(['postgres', 'password', 'passw0rd', 'secret', 'root', 'admin', 'test', 'dev', 'changeme', 'example', 'mysql', 'redis', 'mongo', 'user', 'guest', '123456', '12345678']);
|
|
7
49
|
const PATTERNS = [
|
|
8
50
|
{
|
|
9
51
|
id: 'openai_key',
|
|
@@ -91,6 +133,22 @@ const PATTERNS = [
|
|
|
91
133
|
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s:/@"']+:([^\s:/@"']{4,})@[^\s"']+/gi,
|
|
92
134
|
severity: 'critical',
|
|
93
135
|
fix: 'Move the connection string to a server-side env var and rotate the database password — a committed DB URL grants full data access.',
|
|
136
|
+
// `postgres://opencut:opencut@localhost` in docker-compose / CI / a Dockerfile
|
|
137
|
+
// is a local container's default login, not a credential. A password that
|
|
138
|
+
// equals the user name, or is a well-known default, is never reported; a
|
|
139
|
+
// weak password on a local/single-label host (a compose service name) is
|
|
140
|
+
// not either. A real-looking password on a real host still is.
|
|
141
|
+
validate: (hit) => {
|
|
142
|
+
const m = /^[a-z+]+:\/\/([^\s:/@"']+):([^\s:/@"']+)@([^/\s:"']+)/i.exec(hit);
|
|
143
|
+
if (!m)
|
|
144
|
+
return true;
|
|
145
|
+
const [, user = '', pw = '', host = ''] = m;
|
|
146
|
+
if (pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()))
|
|
147
|
+
return false;
|
|
148
|
+
const local = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|[a-z0-9_-]+)$/i.test(host);
|
|
149
|
+
const strong = /[0-9]/.test(pw) && /[A-Za-z]/.test(pw) && shannonEntropy(pw) >= 3.0;
|
|
150
|
+
return !(local && !strong);
|
|
151
|
+
},
|
|
94
152
|
},
|
|
95
153
|
{
|
|
96
154
|
id: 'supabase_secret_key',
|
|
@@ -107,11 +165,37 @@ const PATTERNS = [
|
|
|
107
165
|
fix: 'Remove the private key from the repo, rotate the key pair, and store secrets outside source control.',
|
|
108
166
|
},
|
|
109
167
|
];
|
|
110
|
-
const GENERIC =
|
|
168
|
+
const GENERIC = /\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passwd|password|pwd|credential))["']?\s*[:=]\s*["']([^"']{8,})["']/gi;
|
|
169
|
+
/**
|
|
170
|
+
* Names that contain "token"/"secret" but are not credentials: API cursors,
|
|
171
|
+
* tracking ids, CSRF nonces, push tokens. 559 of 569 generic hits in one real
|
|
172
|
+
* project were `tracking_token` / `pagination_token` inside cached API
|
|
173
|
+
* responses.
|
|
174
|
+
*/
|
|
175
|
+
const NON_SECRET_NAME = /(pagination|tracking|page|next|prev|continuation|cursor|csrf|xsrf|cancel|request|device|push|fcm|expo|invite|share|verification|unsubscribe|reset|confirm|session)/i;
|
|
176
|
+
/** Base64 blobs (thumbnails, binary) and anything longer than a real token. */
|
|
177
|
+
const looksLikeBlob = (v) => v.length > 200 || /^(\/9j\/|iVBOR|data:|R0lGOD|UklGR)/.test(v);
|
|
111
178
|
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
112
179
|
// KEY=value / key: value / Dockerfile ENV|ARG KEY=value, with a secret-looking NAME.
|
|
113
180
|
const ASSIGN = /^[ \t]*(?:export[ \t]+|ENV[ \t]+|ARG[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*(.+)$/gm;
|
|
114
181
|
const SECRET_NAME = /(secret|token|password|passwd|private[_-]?key|api[_-]?key|access[_-]?key|credential)/i;
|
|
182
|
+
/**
|
|
183
|
+
* Shell-style `NAME=value` anywhere in a line, in any file: `PGPASSWORD=… psql`,
|
|
184
|
+
* `TOKEN=… curl`, a permission rule in .claude/settings.local.json. Upper-case
|
|
185
|
+
* names only, so prose like "password=…" in docs does not count.
|
|
186
|
+
*
|
|
187
|
+
* The leading class is `*` (zero or more), not `+`: a bare name that IS one of
|
|
188
|
+
* the keywords (`SECRET=…`, `TOKEN=…`, `API_KEY=…`, no prefix at all — an
|
|
189
|
+
* extremely common shape in real scripts) needs zero characters before the
|
|
190
|
+
* keyword, and requiring at least one meant those never matched at all. There
|
|
191
|
+
* is deliberately no trailing lookahead: `{8,}` on a fixed character class
|
|
192
|
+
* already stops at the first character outside it, so a value followed by
|
|
193
|
+
* `;`, `)`, `,` or `]` (`export TOKEN=abc123;`, `foo(SECRET=abc123)`) still
|
|
194
|
+
* matches correctly — an earlier version's trailing negative lookahead
|
|
195
|
+
* rejected exactly those common shell/call-site endings.
|
|
196
|
+
*/
|
|
197
|
+
// Values are ASCII token characters: `PASSWORD=та_же_что_и_выше` in a README is prose.
|
|
198
|
+
const INLINE_ENV = /\b([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*)=([A-Za-z0-9_\-./+=:@]{8,})/g;
|
|
115
199
|
/** Only .env* files are "server env by design" — a secret there is a warning
|
|
116
200
|
* (env-git flags committing it). In real code/config it stays a source leak. */
|
|
117
201
|
function isEnvFile(rel) {
|
|
@@ -138,22 +222,100 @@ export const secretsChecker = {
|
|
|
138
222
|
level: 0,
|
|
139
223
|
run(ctx) {
|
|
140
224
|
const findings = [];
|
|
225
|
+
const exposure = createExposure(ctx.root);
|
|
226
|
+
// Values from the REAL env files, per directory. A committed .env.example
|
|
227
|
+
// that carries the same value as its sibling .env is not an example — it is
|
|
228
|
+
// the key, published. (Seen in the wild: OPENAI/ANTHROPIC keys identical
|
|
229
|
+
// in .env and a committed .env.example.)
|
|
230
|
+
const realEnvValues = new Map();
|
|
231
|
+
for (const f of ctx.files) {
|
|
232
|
+
if (!isEnvFile(f.rel))
|
|
233
|
+
continue;
|
|
234
|
+
const dir = f.rel.includes('/') ? f.rel.slice(0, f.rel.lastIndexOf('/')) : '';
|
|
235
|
+
const set = realEnvValues.get(dir) ?? new Set();
|
|
236
|
+
for (const m of f.content.matchAll(ASSIGN)) {
|
|
237
|
+
const v = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
238
|
+
if (v.length >= 8)
|
|
239
|
+
set.add(v);
|
|
240
|
+
}
|
|
241
|
+
realEnvValues.set(dir, set);
|
|
242
|
+
}
|
|
243
|
+
const dirOf = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
|
|
244
|
+
const isExampleEnv = (rel) => /(^|\/)\.env[^/]*\.(example|sample|template|dist)$/i.test(rel) || /(^|\/)(example|sample)\.env$/i.test(rel);
|
|
141
245
|
for (const file of ctx.files) {
|
|
142
246
|
const { rel } = file;
|
|
143
247
|
const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
|
|
144
248
|
const env = isEnvFile(rel);
|
|
145
249
|
const example = isExampleContext(rel);
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
250
|
+
const ex = exposure(rel);
|
|
251
|
+
// `proven` = the hit is structurally real key material (see
|
|
252
|
+
// looksLikeRealMaterial). A docs/fixture path may only downgrade a
|
|
253
|
+
// heuristic or sample-looking hit to info; proven material stays at
|
|
254
|
+
// warning there — the path lowers confidence, it does not make it safe.
|
|
255
|
+
/**
|
|
256
|
+
* Severity = what the value is × how it can leak. The pattern says what it
|
|
257
|
+
* is; the example/docs path lowers confidence; git exposure decides how
|
|
258
|
+
* bad it is. `raw` is the unredacted value, only compared, never stored.
|
|
259
|
+
*/
|
|
260
|
+
const push = (f, proven = false, raw) => {
|
|
261
|
+
let out = f;
|
|
262
|
+
const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
|
|
263
|
+
if (realValueInExample) {
|
|
264
|
+
// Not an example at all: the live value copied into the example file.
|
|
265
|
+
out = {
|
|
266
|
+
...f,
|
|
267
|
+
severity: ex === 'committed' ? 'critical' : 'warning',
|
|
268
|
+
title: `${f.title} — the REAL value from .env, in an example file${ex === 'committed' ? ' that is committed' : ''}`,
|
|
269
|
+
detail: `${f.detail} This value is identical to the one in the sibling .env: the example file carries the real credential${ex === 'committed' ? ', and it is committed to git' : ''}.`,
|
|
270
|
+
fix: 'Replace the value in the example file with a placeholder, and rotate the credential — it has been published.',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
else if (example && proven) {
|
|
274
|
+
out = {
|
|
275
|
+
...f,
|
|
276
|
+
severity: f.severity === 'critical' ? 'warning' : f.severity,
|
|
277
|
+
title: `${f.title} (in docs/example path — looks real)`,
|
|
278
|
+
detail: `${f.detail} The path suggests documentation or a fixture, but the value has the structure of real key material — verify it, and rotate it if it is genuine.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
else if (example) {
|
|
282
|
+
out = {
|
|
149
283
|
...f,
|
|
150
284
|
severity: 'info',
|
|
151
285
|
title: `${f.title} (in docs/example file)`,
|
|
152
286
|
detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
|
|
153
|
-
}
|
|
287
|
+
};
|
|
154
288
|
}
|
|
155
|
-
|
|
156
|
-
|
|
289
|
+
// Git exposure. A committed secret keeps its full severity. Everything
|
|
290
|
+
// else cannot leak through the repository right now: an ignored file is
|
|
291
|
+
// doing exactly what it should (advisory); an untracked file in a repo
|
|
292
|
+
// or a folder that is not a repo is hygiene, capped at warning.
|
|
293
|
+
if (!realValueInExample && out.severity !== 'info') {
|
|
294
|
+
// A .env that is not committed is doing its job: secrets belong there.
|
|
295
|
+
// Whether it WILL be committed (untracked, no .gitignore entry) is the
|
|
296
|
+
// env-git check's finding, not this one's — reporting it twice at
|
|
297
|
+
// warning is what made people stop reading.
|
|
298
|
+
if (env && ex !== 'committed') {
|
|
299
|
+
// This also covers `ex === 'ignored'`: an env file is always the
|
|
300
|
+
// right place for a secret whether or not it's ALSO gitignored, so
|
|
301
|
+
// there is nothing a separate ignored-env-file branch would add.
|
|
302
|
+
out = { ...out, severity: 'advisory', detail: `${out.detail} Not committed — this is where the value belongs; keep the file out of git and out of client bundles.` };
|
|
303
|
+
}
|
|
304
|
+
else if (ex === 'ignored') {
|
|
305
|
+
// Gitignored, but not an env file: a credential pasted into a tool
|
|
306
|
+
// config or a script (a prod DB password inside a permission rule in
|
|
307
|
+
// .claude/settings.local.json was reported as a mere advisory). It
|
|
308
|
+
// cannot leak through git today; it still does not belong there.
|
|
309
|
+
out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} The file is gitignored, so this cannot leak through git — but it is a credential pasted into a config/source file, not an env var. Move it to .env (also gitignored) so one \`git add -A\` or a shared zip never carries it.` };
|
|
310
|
+
}
|
|
311
|
+
else if (ex === 'untracked') {
|
|
312
|
+
out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} The file is not committed yet — it is one \`git add -A\` away from being. Add it to .gitignore or move the value to an env var.` };
|
|
313
|
+
}
|
|
314
|
+
else if (ex === 'no-git') {
|
|
315
|
+
out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} This folder is not a git repository, so nothing leaks through git; the risk is copying or zipping the folder. Move the value to an env var before this becomes a repo.` };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
findings.push(out);
|
|
157
319
|
};
|
|
158
320
|
for (const p of PATTERNS) {
|
|
159
321
|
for (const m of content.matchAll(p.re)) {
|
|
@@ -165,6 +327,7 @@ export const secretsChecker = {
|
|
|
165
327
|
// A secret in a server env/config file is expected — the risk is
|
|
166
328
|
// committing it (env-git flags that), so it is a warning, not a leak.
|
|
167
329
|
const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
|
|
330
|
+
const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
|
|
168
331
|
push({
|
|
169
332
|
id: p.id,
|
|
170
333
|
severity,
|
|
@@ -178,10 +341,11 @@ export const secretsChecker = {
|
|
|
178
341
|
file: rel,
|
|
179
342
|
line: lineAt(content, m.index ?? 0),
|
|
180
343
|
evidence: redact(hit),
|
|
181
|
-
});
|
|
344
|
+
}, proven, hit);
|
|
182
345
|
}
|
|
183
346
|
}
|
|
184
347
|
// Supabase service_role key (a JWT whose payload role is service_role).
|
|
348
|
+
// The decoded role proves what it is, so it is never a docs-only info.
|
|
185
349
|
for (const m of content.matchAll(JWT)) {
|
|
186
350
|
const payload = decodeJwtPayload(m[0]);
|
|
187
351
|
if (payload && payload['role'] === 'service_role') {
|
|
@@ -200,12 +364,19 @@ export const secretsChecker = {
|
|
|
200
364
|
file: rel,
|
|
201
365
|
line: lineAt(content, m.index ?? 0),
|
|
202
366
|
evidence: redact(m[0]),
|
|
203
|
-
});
|
|
367
|
+
}, true, m[0]);
|
|
204
368
|
}
|
|
205
369
|
}
|
|
206
370
|
// Quoted key/secret assignments in code, filtered by placeholder + entropy.
|
|
207
371
|
for (const m of content.matchAll(GENERIC)) {
|
|
208
|
-
const
|
|
372
|
+
const name = m[1] ?? '';
|
|
373
|
+
const value = m[2] ?? '';
|
|
374
|
+
if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
|
|
375
|
+
continue;
|
|
376
|
+
// Credentials have no whitespace ("Show password" is UI text) and carry
|
|
377
|
+
// digits or real length ("build-time-secret" is a label).
|
|
378
|
+
if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
|
|
379
|
+
continue;
|
|
209
380
|
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
|
|
210
381
|
continue;
|
|
211
382
|
push({
|
|
@@ -219,7 +390,32 @@ export const secretsChecker = {
|
|
|
219
390
|
file: rel,
|
|
220
391
|
line: lineAt(content, m.index ?? 0),
|
|
221
392
|
evidence: redact(value),
|
|
222
|
-
});
|
|
393
|
+
}, false, value);
|
|
394
|
+
}
|
|
395
|
+
// Inline shell-style assignments mid-line, outside env/config files (those
|
|
396
|
+
// go through ASSIGN below). A prod DB password inside a permission rule in
|
|
397
|
+
// .claude/settings.local.json was invisible to both other name-based rules.
|
|
398
|
+
if (!isConfigish(rel)) {
|
|
399
|
+
for (const m of content.matchAll(INLINE_ENV)) {
|
|
400
|
+
const name = m[1] ?? '';
|
|
401
|
+
const value = m[2] ?? '';
|
|
402
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
403
|
+
continue;
|
|
404
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
405
|
+
continue;
|
|
406
|
+
push({
|
|
407
|
+
id: 'env_secret',
|
|
408
|
+
severity: 'warning',
|
|
409
|
+
title: 'Secret in an inline assignment',
|
|
410
|
+
detail: `"${name}" is assigned a high-entropy value inline in ${rel}: ${redact(value)}`,
|
|
411
|
+
fix: 'Move the value to a gitignored .env and reference it by name; rotate it if the file was ever shared.',
|
|
412
|
+
checker: 'secrets',
|
|
413
|
+
level: 0,
|
|
414
|
+
file: rel,
|
|
415
|
+
line: lineAt(content, m.index ?? 0),
|
|
416
|
+
evidence: redact(value),
|
|
417
|
+
}, false, value);
|
|
418
|
+
}
|
|
223
419
|
}
|
|
224
420
|
// name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
|
|
225
421
|
if (isConfigish(rel)) {
|
|
@@ -230,6 +426,9 @@ export const secretsChecker = {
|
|
|
230
426
|
const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
231
427
|
if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
232
428
|
continue;
|
|
429
|
+
// "build-time-secret" is a label, not a credential: real values carry digits or length.
|
|
430
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
431
|
+
continue;
|
|
233
432
|
push({
|
|
234
433
|
id: 'env_secret',
|
|
235
434
|
severity: 'warning',
|
|
@@ -243,20 +442,65 @@ export const secretsChecker = {
|
|
|
243
442
|
file: rel,
|
|
244
443
|
line: lineAt(content, m.index ?? 0),
|
|
245
444
|
evidence: redact(value),
|
|
246
|
-
});
|
|
445
|
+
}, false, value);
|
|
247
446
|
}
|
|
248
447
|
}
|
|
249
448
|
}
|
|
250
449
|
// De-duplicate only true repeats: the same secret, same place, same rule.
|
|
251
450
|
// (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
451
|
const seen = new Map();
|
|
254
452
|
for (const f of findings) {
|
|
255
453
|
const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
|
|
256
454
|
const cur = seen.get(key);
|
|
257
|
-
if (!cur ||
|
|
455
|
+
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
258
456
|
seen.set(key, f);
|
|
259
457
|
}
|
|
260
|
-
|
|
458
|
+
// A vendor pattern or a decoded JWT already identifies the value on a line;
|
|
459
|
+
// the name-based env_secret / generic_secret there is the same fact twice.
|
|
460
|
+
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
461
|
+
const specificAt = new Set([...seen.values()].filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}`));
|
|
462
|
+
const kept = [...seen.values()].filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}`)));
|
|
463
|
+
// Eleven advisories saying "secret in gitignored .env.local — fine" are one
|
|
464
|
+
// observation printed eleven times. Fold them into one line per file that
|
|
465
|
+
// names the variables; anything above advisory stays line by line.
|
|
466
|
+
//
|
|
467
|
+
// The final `applyIgnores` pass (scan.ts) only sees whatever this checker
|
|
468
|
+
// returns — once N findings become one summary Finding with a single
|
|
469
|
+
// `line`, a `// easyvibegate-ignore` comment placed above any secret but
|
|
470
|
+
// the first in the group can no longer reach it. Filter each candidate
|
|
471
|
+
// through the SAME inline-marker check first (an empty config so only the
|
|
472
|
+
// marker, not project-wide `ignore`/`ignorePaths` rules, applies here —
|
|
473
|
+
// those still run again, correctly, against whatever this returns), so an
|
|
474
|
+
// individually silenced secret is dropped before folding, not after.
|
|
475
|
+
const byFile = new Map();
|
|
476
|
+
for (const f of kept) {
|
|
477
|
+
if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
|
|
478
|
+
continue;
|
|
479
|
+
byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
|
|
480
|
+
}
|
|
481
|
+
const folded = new Set();
|
|
482
|
+
const summaries = [];
|
|
483
|
+
for (const [file, allCandidates] of byFile) {
|
|
484
|
+
const group = partitionIgnores(allCandidates, EMPTY_CONFIG, ctx.files).kept;
|
|
485
|
+
// A member the marker silenced must not reappear individually either —
|
|
486
|
+
// it is done with, not merely "too few left to fold".
|
|
487
|
+
for (const f of allCandidates)
|
|
488
|
+
if (!group.includes(f))
|
|
489
|
+
folded.add(f);
|
|
490
|
+
if (group.length < 2)
|
|
491
|
+
continue;
|
|
492
|
+
for (const f of group)
|
|
493
|
+
folded.add(f);
|
|
494
|
+
const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
|
|
495
|
+
const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
|
|
496
|
+
summaries.push({
|
|
497
|
+
...first,
|
|
498
|
+
title: `${group.length} secrets in ${file} (not committed — where they belong)`,
|
|
499
|
+
detail: `${file} holds ${group.length} secret-looking values (${names.join(', ')}). The file is not committed, so nothing leaks through git; keep it that way and out of client bundles.`,
|
|
500
|
+
fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
|
|
501
|
+
evidence: undefined,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return [...kept.filter((f) => !folded.has(f)), ...summaries];
|
|
261
505
|
},
|
|
262
506
|
};
|
package/dist/engine/config.js
CHANGED
|
@@ -1,35 +1,119 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { langForFile, lexCode } from './util/code-lex.js';
|
|
4
|
+
import { lineAt } from './util/text.js';
|
|
3
5
|
const DEFAULT_CONFIG = { ignore: [], ignorePaths: [] };
|
|
4
6
|
const CONFIG_NAMES = ['easyvibegate.config.json', '.easyvibegaterc.json'];
|
|
7
|
+
const KNOWN_KEYS = ['ignore', 'ignorePaths'];
|
|
8
|
+
/**
|
|
9
|
+
* Validate a parsed config object. Returns a problem message or null.
|
|
10
|
+
* One validator for the explicit `--config` file and the auto-discovered one:
|
|
11
|
+
* a typo like "ignorePath" must be rejected the same way in both, otherwise it
|
|
12
|
+
* silently does nothing and the user believes their rules are in effect.
|
|
13
|
+
*/
|
|
14
|
+
export function validateConfig(parsed) {
|
|
15
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
16
|
+
return 'must be a JSON object';
|
|
17
|
+
const cfg = parsed;
|
|
18
|
+
for (const key of KNOWN_KEYS) {
|
|
19
|
+
const v = cfg[key];
|
|
20
|
+
if (v === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
|
|
23
|
+
return `"${key}" must be an array of strings`;
|
|
24
|
+
}
|
|
25
|
+
const unknown = Object.keys(cfg).filter((k) => !KNOWN_KEYS.includes(k));
|
|
26
|
+
if (unknown.length)
|
|
27
|
+
return `unknown key(s): ${unknown.join(', ')} (expected ${KNOWN_KEYS.join(', ')})`;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Read + parse + validate one config file. Every failure is a message, never a silent default. */
|
|
31
|
+
function readConfigFile(path) {
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return { problem: 'file not found' };
|
|
35
|
+
if (!statSync(path).isFile())
|
|
36
|
+
return { problem: 'not a file' };
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { problem: `cannot read (${e instanceof Error ? e.message : String(e)})` };
|
|
40
|
+
}
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(path, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
return { problem: `cannot read (${e instanceof Error ? e.message : String(e)})` };
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return { problem: `invalid JSON (${e instanceof Error ? e.message : String(e)})` };
|
|
54
|
+
}
|
|
55
|
+
const problem = validateConfig(parsed);
|
|
56
|
+
if (problem)
|
|
57
|
+
return { problem };
|
|
58
|
+
const raw = parsed;
|
|
59
|
+
return { config: { ignore: raw.ignore ?? [], ignorePaths: raw.ignorePaths ?? [] } };
|
|
60
|
+
}
|
|
61
|
+
/** Problem with a config file the user pointed at (missing, directory, bad JSON, bad shape), or null. */
|
|
62
|
+
export function validateConfigFile(path) {
|
|
63
|
+
const r = readConfigFile(path);
|
|
64
|
+
return 'problem' in r ? r.problem : null;
|
|
65
|
+
}
|
|
5
66
|
export function loadConfig(root, explicitPath) {
|
|
6
67
|
const candidates = explicitPath ? [explicitPath] : CONFIG_NAMES.map((n) => join(root, n));
|
|
7
68
|
for (const p of candidates) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
}
|
|
69
|
+
// An auto-discovered name that is simply absent means "no config here".
|
|
70
|
+
// Anything else (present but unreadable, a directory, broken, misspelled
|
|
71
|
+
// keys) is a problem to report — the user's rules were NOT applied.
|
|
72
|
+
if (!explicitPath && !existsSync(p))
|
|
73
|
+
continue;
|
|
74
|
+
const r = readConfigFile(p);
|
|
75
|
+
return 'problem' in r ? { ...DEFAULT_CONFIG, problem: `${p}: ${r.problem}` } : r.config;
|
|
26
76
|
}
|
|
27
77
|
return DEFAULT_CONFIG;
|
|
28
78
|
}
|
|
29
79
|
const INLINE_MARKER = 'easyvibegate-ignore';
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
80
|
+
/** Optional scope right after the marker: `easyvibegate-ignore: jwt_alg_none, config-risks`. */
|
|
81
|
+
const SCOPE_RE = /^:[ \t]*([\w-]+(?:[ \t]*,[ \t]*[\w-]+)*)/;
|
|
82
|
+
/**
|
|
83
|
+
* Lines that carry a suppression directive, with the ids it is scoped to
|
|
84
|
+
* (null = everything). Only a COMMENT token counts: the marker inside a string
|
|
85
|
+
* literal (`const label = "easyvibegate-ignore"`) is data and must not switch
|
|
86
|
+
* the scanner off for the next line. The lexer decides what a comment is per
|
|
87
|
+
* language: `//`, `#`, a block comment, `--` for SQL.
|
|
88
|
+
*/
|
|
89
|
+
function inlineDirectives(file) {
|
|
90
|
+
const lines = new Map();
|
|
91
|
+
for (const t of lexCode(file.content, langForFile(file.rel))) {
|
|
92
|
+
if (t.type !== 'comment' || !t.value.includes(INLINE_MARKER))
|
|
93
|
+
continue;
|
|
94
|
+
let at = t.value.indexOf(INLINE_MARKER);
|
|
95
|
+
while (at !== -1) {
|
|
96
|
+
const line = lineAt(file.content, t.start + at);
|
|
97
|
+
const scope = SCOPE_RE.exec(t.value.slice(at + INLINE_MARKER.length));
|
|
98
|
+
const ids = scope?.[1] ? scope[1].split(',').map((s) => s.trim()) : null;
|
|
99
|
+
const prev = lines.get(line);
|
|
100
|
+
if (ids === null || prev === null)
|
|
101
|
+
lines.set(line, null);
|
|
102
|
+
else
|
|
103
|
+
lines.set(line, new Set([...(prev ?? []), ...ids]));
|
|
104
|
+
at = t.value.indexOf(INLINE_MARKER, at + INLINE_MARKER.length);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Split findings into kept and suppressed (config rules or inline markers).
|
|
111
|
+
* The suppressed half is returned so it can be counted — a rule that silences
|
|
112
|
+
* a finding should be visible somewhere, never just make it disappear.
|
|
113
|
+
*/
|
|
114
|
+
export function partitionIgnores(findings, config, files) {
|
|
115
|
+
const byRel = new Map(files.map((f) => [f.rel, f]));
|
|
116
|
+
const directives = new Map();
|
|
33
117
|
const isConfigIgnored = (f) => {
|
|
34
118
|
const keys = [f.id, f.checker];
|
|
35
119
|
if (f.file)
|
|
@@ -43,12 +127,31 @@ export function applyIgnores(findings, config, files) {
|
|
|
43
127
|
const isInlineIgnored = (f) => {
|
|
44
128
|
if (!f.file || !f.line)
|
|
45
129
|
return false;
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
130
|
+
const file = byRel.get(f.file);
|
|
131
|
+
if (!file)
|
|
48
132
|
return false;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
133
|
+
let lines = directives.get(f.file);
|
|
134
|
+
if (!lines) {
|
|
135
|
+
lines = inlineDirectives(file);
|
|
136
|
+
directives.set(f.file, lines);
|
|
137
|
+
}
|
|
138
|
+
// The marker applies to its own line or the line right below it.
|
|
139
|
+
for (const line of [f.line, f.line - 1]) {
|
|
140
|
+
if (!lines.has(line))
|
|
141
|
+
continue;
|
|
142
|
+
const scope = lines.get(line) ?? null;
|
|
143
|
+
if (scope === null || scope.has(f.id) || scope.has(f.checker))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
52
147
|
};
|
|
53
|
-
|
|
148
|
+
const kept = [];
|
|
149
|
+
const suppressed = [];
|
|
150
|
+
for (const f of findings)
|
|
151
|
+
(isConfigIgnored(f) || isInlineIgnored(f) ? suppressed : kept).push(f);
|
|
152
|
+
return { kept, suppressed };
|
|
153
|
+
}
|
|
154
|
+
/** Drop findings suppressed by config rules or inline `// easyvibegate-ignore` markers. */
|
|
155
|
+
export function applyIgnores(findings, config, files) {
|
|
156
|
+
return partitionIgnores(findings, config, files).kept;
|
|
54
157
|
}
|
package/dist/engine/detect.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { DNS_LABEL } from './util/text.js';
|
|
4
|
+
const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
|
|
3
5
|
/** Infer the project's frameworks, backends, languages and package managers. */
|
|
4
6
|
export function detect(root, files) {
|
|
5
7
|
const relSet = new Set(files.map((f) => f.rel));
|
|
@@ -26,30 +28,37 @@ export function detect(root, files) {
|
|
|
26
28
|
else if (f.ext === '.rs')
|
|
27
29
|
languages.add('rust');
|
|
28
30
|
}
|
|
29
|
-
if (has('pnpm-lock.yaml'))
|
|
30
|
-
pms.add('pnpm');
|
|
31
|
-
if (has('package-lock.json'))
|
|
32
|
-
pms.add('npm');
|
|
33
|
-
if (has('yarn.lock'))
|
|
34
|
-
pms.add('yarn');
|
|
35
|
-
if (has('bun.lockb') || has('bun.lock'))
|
|
36
|
-
pms.add('bun');
|
|
37
31
|
const pkg = files.find((f) => f.rel === 'package.json');
|
|
38
32
|
let deps = {};
|
|
33
|
+
let declaredPackageManager;
|
|
39
34
|
if (pkg) {
|
|
40
35
|
try {
|
|
41
36
|
const j = JSON.parse(pkg.content);
|
|
42
37
|
deps = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
|
|
43
|
-
// The `packageManager` field (Corepack) is authoritative when present.
|
|
44
38
|
const pmField = j.packageManager?.split('@')[0];
|
|
45
39
|
if (pmField === 'pnpm' || pmField === 'yarn' || pmField === 'npm' || pmField === 'bun')
|
|
46
|
-
|
|
40
|
+
declaredPackageManager = pmField;
|
|
47
41
|
}
|
|
48
42
|
catch {
|
|
49
43
|
/* ignore malformed package.json */
|
|
50
44
|
}
|
|
51
45
|
}
|
|
52
46
|
const dep = (n) => n in deps;
|
|
47
|
+
// The `packageManager` field (Corepack) is authoritative, so it goes FIRST:
|
|
48
|
+
// `packageManagers[0]` is the manager the audit runs with. Adding it after
|
|
49
|
+
// the lockfiles let a stale pnpm-lock.yaml win over `packageManager: npm@10`.
|
|
50
|
+
// Lockfiles follow in preference order (a leftover package-lock.json beside
|
|
51
|
+
// pnpm-lock.yaml is the common stale one).
|
|
52
|
+
if (declaredPackageManager)
|
|
53
|
+
pms.add(declaredPackageManager);
|
|
54
|
+
if (has('pnpm-lock.yaml'))
|
|
55
|
+
pms.add('pnpm');
|
|
56
|
+
if (has('yarn.lock'))
|
|
57
|
+
pms.add('yarn');
|
|
58
|
+
if (has('bun.lockb') || has('bun.lock'))
|
|
59
|
+
pms.add('bun');
|
|
60
|
+
if (has('package-lock.json'))
|
|
61
|
+
pms.add('npm');
|
|
53
62
|
if (dep('next'))
|
|
54
63
|
frameworks.add('next');
|
|
55
64
|
if (dep('react'))
|
|
@@ -92,8 +101,10 @@ export function detect(root, files) {
|
|
|
92
101
|
frameworks.add('rails');
|
|
93
102
|
}
|
|
94
103
|
// Content fallback for projects without a package.json — kept strict to avoid
|
|
95
|
-
// false positives from code that merely mentions a backend by name.
|
|
96
|
-
|
|
104
|
+
// false positives from code that merely mentions a backend by name. Uses the
|
|
105
|
+
// shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
|
|
106
|
+
// a long run of matching characters in a large file.
|
|
107
|
+
if (!backends.has('supabase') && files.some((f) => SUPABASE_URL_FALLBACK.test(f.content))) {
|
|
97
108
|
backends.add('supabase');
|
|
98
109
|
}
|
|
99
110
|
if (!backends.has('firebase') && files.some((f) => /\binitializeApp\s*\(/.test(f.content) && /firebase/i.test(f.content))) {
|
|
@@ -104,6 +115,7 @@ export function detect(root, files) {
|
|
|
104
115
|
backends: [...backends],
|
|
105
116
|
languages: [...languages],
|
|
106
117
|
packageManagers: [...pms],
|
|
118
|
+
declaredPackageManager,
|
|
107
119
|
hasEnv: files.some((f) => f.rel === '.env' || /(^|\/)\.env(\.|$)/.test(f.rel)),
|
|
108
120
|
hasGitignore: has('.gitignore'),
|
|
109
121
|
};
|