easyvibegate 0.6.2 → 0.6.9
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 +10 -2
- package/README.md +11 -2
- package/dist/cli/index.js +27 -6
- package/dist/engine/checkers/deep/deps.js +198 -54
- package/dist/engine/checkers/index.js +2 -0
- package/dist/engine/checkers/static/client-exposure.js +16 -3
- package/dist/engine/checkers/static/config-risks.js +12 -2
- package/dist/engine/checkers/static/next-image-ssrf.js +137 -0
- package/dist/engine/checkers/static/rls-migrations.js +112 -44
- package/dist/engine/checkers/static/secrets.js +372 -291
- package/dist/engine/detect.js +17 -3
- package/dist/engine/util/git-exposure.js +41 -0
- package/dist/engine/util/text.js +25 -1
- package/dist/engine/walk.js +22 -12
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
|
|
2
|
-
import { createExposure } from '../../util/git-exposure.js';
|
|
1
|
+
import { decodeJwtPayload, lineAt, looksLikePlaceholder, looksLikeTestOrDocPath, redact, shannonEntropy } from '../../util/text.js';
|
|
2
|
+
import { createExposure, createHistoryChecker } from '../../util/git-exposure.js';
|
|
3
3
|
import { partitionIgnores } from '../../config.js';
|
|
4
4
|
/** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
|
|
5
5
|
const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
|
|
@@ -130,24 +130,29 @@ const PATTERNS = [
|
|
|
130
130
|
{
|
|
131
131
|
id: 'db_url_password',
|
|
132
132
|
title: 'Database URL with an inline password',
|
|
133
|
-
|
|
133
|
+
// The trailing char classes exclude backtick/paren/bracket/comma/semicolon
|
|
134
|
+
// as well as the usual quote+whitespace: a URL written as a markdown-style
|
|
135
|
+
// example (`` `postgres://user:pass@host` `` in a comment or docstring)
|
|
136
|
+
// otherwise swallows the closing backtick into the host, which broke the
|
|
137
|
+
// local-host check below (`localhost\`` never matches `^localhost$`).
|
|
138
|
+
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s:/@"'`)\];,]+:([^\s:/@"'`)\];,]{4,})@[^\s"'`)\];,]+/gi,
|
|
134
139
|
severity: 'critical',
|
|
135
140
|
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
141
|
// `postgres://opencut:opencut@localhost` in docker-compose / CI / a Dockerfile
|
|
137
142
|
// 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
|
|
139
|
-
//
|
|
140
|
-
//
|
|
143
|
+
// equals the user name, or is a well-known default, or is otherwise weak,
|
|
144
|
+
// is only exempt on a local/single-label host (a compose service name) —
|
|
145
|
+
// the exact same password on a real remote host is a live, guessable
|
|
146
|
+
// credential, not a throwaway dev default, and must still be reported.
|
|
141
147
|
validate: (hit) => {
|
|
142
|
-
const m = /^[a-z+]+:\/\/([^\s:/@"']+):([^\s:/@"']+)@([^/\s:"']+)/i.exec(hit);
|
|
148
|
+
const m = /^[a-z+]+:\/\/([^\s:/@"'`)\];,]+):([^\s:/@"'`)\];,]+)@([^/\s:"'`)\];,]+)/i.exec(hit);
|
|
143
149
|
if (!m)
|
|
144
150
|
return true;
|
|
145
151
|
const [, user = '', pw = '', host = ''] = m;
|
|
146
|
-
if (pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()))
|
|
147
|
-
return false;
|
|
148
152
|
const local = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|[a-z0-9_-]+)$/i.test(host);
|
|
149
153
|
const strong = /[0-9]/.test(pw) && /[A-Za-z]/.test(pw) && shannonEntropy(pw) >= 3.0;
|
|
150
|
-
|
|
154
|
+
const weak = pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()) || !strong;
|
|
155
|
+
return !(local && weak);
|
|
151
156
|
},
|
|
152
157
|
},
|
|
153
158
|
{
|
|
@@ -171,8 +176,14 @@ const GENERIC = /\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passwd|password|pw
|
|
|
171
176
|
* tracking ids, CSRF nonces, push tokens. 559 of 569 generic hits in one real
|
|
172
177
|
* project were `tracking_token` / `pagination_token` inside cached API
|
|
173
178
|
* responses.
|
|
179
|
+
*
|
|
180
|
+
* Deliberately NOT here: "session" and "reset". `sessionSecret` (the signing
|
|
181
|
+
* key for express-session/cookie-session — a real, common config secret) and
|
|
182
|
+
* `sessionToken`/`resetToken` (bearer auth / password-reset tokens, both
|
|
183
|
+
* enough to take over an account) are genuine credentials, not opaque ids —
|
|
184
|
+
* unlike `trackingToken`/`paginationToken`, they were being excluded outright.
|
|
174
185
|
*/
|
|
175
|
-
const NON_SECRET_NAME = /(pagination|tracking|page|next|prev|continuation|cursor|csrf|xsrf|cancel|request|device|push|fcm|expo|invite|share|verification|unsubscribe|
|
|
186
|
+
const NON_SECRET_NAME = /(pagination|tracking|page|next|prev|continuation|cursor|csrf|xsrf|cancel|request|device|push|fcm|expo|invite|share|verification|unsubscribe|confirm)/i;
|
|
176
187
|
/** Base64 blobs (thumbnails, binary) and anything longer than a real token. */
|
|
177
188
|
const looksLikeBlob = (v) => v.length > 200 || /^(\/9j\/|iVBOR|data:|R0lGOD|UklGR)/.test(v);
|
|
178
189
|
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
@@ -206,15 +217,342 @@ function isConfigish(rel) {
|
|
|
206
217
|
return /(^|\/)\.env($|\.)/.test(rel) || /\.(ya?ml|toml|ini|conf|properties|npmrc|netrc)$/.test(rel)
|
|
207
218
|
|| /(^|\/)(Dockerfile|\.npmrc|\.netrc)$/.test(rel) || /docker-compose\.ya?ml$/.test(rel);
|
|
208
219
|
}
|
|
220
|
+
const dirOf = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
|
|
221
|
+
const isExampleEnv = (rel) => /(^|\/)\.env[^/]*\.(example|sample|template|dist)$/i.test(rel) || /(^|\/)(example|sample)\.env$/i.test(rel);
|
|
209
222
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
223
|
+
* Values from the REAL env files, per directory. A committed .env.example
|
|
224
|
+
* that carries the same value as its sibling .env is not an example — it is
|
|
225
|
+
* the key, published. (Seen in the wild: OPENAI/ANTHROPIC keys identical
|
|
226
|
+
* in .env and a committed .env.example.)
|
|
212
227
|
*/
|
|
213
|
-
function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
function collectRealEnvValues(files) {
|
|
229
|
+
const realEnvValues = new Map();
|
|
230
|
+
for (const f of files) {
|
|
231
|
+
if (!isEnvFile(f.rel))
|
|
232
|
+
continue;
|
|
233
|
+
const set = realEnvValues.get(dirOf(f.rel)) ?? new Set();
|
|
234
|
+
for (const m of f.content.matchAll(ASSIGN)) {
|
|
235
|
+
const v = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
236
|
+
if (v.length >= 8)
|
|
237
|
+
set.add(v);
|
|
238
|
+
}
|
|
239
|
+
realEnvValues.set(dirOf(f.rel), set);
|
|
240
|
+
}
|
|
241
|
+
return realEnvValues;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Severity = what the value is × how it can leak. The pattern says what it
|
|
245
|
+
* is; the example/docs path lowers confidence; git exposure decides how bad
|
|
246
|
+
* it is. Builds the per-file `push` used by every scan pass below, so that
|
|
247
|
+
* pipeline lives in one place instead of being reimplemented per pass.
|
|
248
|
+
*/
|
|
249
|
+
function makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted }) {
|
|
250
|
+
return (f, proven = false, raw) => {
|
|
251
|
+
let out = f;
|
|
252
|
+
const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
|
|
253
|
+
if (realValueInExample) {
|
|
254
|
+
// Not an example at all: the live value copied into the example file.
|
|
255
|
+
out = {
|
|
256
|
+
...f,
|
|
257
|
+
severity: ex === 'committed' ? 'critical' : 'warning',
|
|
258
|
+
title: `${f.title} — the REAL value from .env, in an example file${ex === 'committed' ? ' that is committed' : ''}`,
|
|
259
|
+
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' : ''}.`,
|
|
260
|
+
fix: 'Replace the value in the example file with a placeholder, and rotate the credential — it has been published.',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
else if (example && proven) {
|
|
264
|
+
out = {
|
|
265
|
+
...f,
|
|
266
|
+
severity: f.severity === 'critical' ? 'warning' : f.severity,
|
|
267
|
+
title: `${f.title} (in docs/example path — looks real)`,
|
|
268
|
+
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.`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
else if (example) {
|
|
272
|
+
out = {
|
|
273
|
+
...f,
|
|
274
|
+
severity: 'info',
|
|
275
|
+
title: `${f.title} (in docs/example file)`,
|
|
276
|
+
detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
// Git exposure. A committed secret keeps its full severity. Everything
|
|
280
|
+
// else cannot leak through the repository right now: an ignored file is
|
|
281
|
+
// doing exactly what it should (advisory); an untracked file in a repo
|
|
282
|
+
// or a folder that is not a repo is hygiene, capped at warning.
|
|
283
|
+
if (!realValueInExample && out.severity !== 'info') {
|
|
284
|
+
// A .env that is not committed is doing its job: secrets belong there.
|
|
285
|
+
// Whether it WILL be committed (untracked, no .gitignore entry) is the
|
|
286
|
+
// env-git check's finding, not this one's — reporting it twice at
|
|
287
|
+
// warning is what made people stop reading.
|
|
288
|
+
if (env && ex !== 'committed') {
|
|
289
|
+
// This also covers `ex === 'ignored'`: an env file is always the
|
|
290
|
+
// right place for a secret whether or not it's ALSO gitignored, so
|
|
291
|
+
// there is nothing a separate ignored-env-file branch would add.
|
|
292
|
+
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.` };
|
|
293
|
+
}
|
|
294
|
+
else if (ex === 'ignored' || ex === 'untracked') {
|
|
295
|
+
// Gitignored or untracked now — but "not committed NOW" is not the
|
|
296
|
+
// same claim as "never committed": a file `git rm --cached` removed
|
|
297
|
+
// after the mistake was noticed still carries the real value forever
|
|
298
|
+
// in history. For a structurally-proven credential (not a name-based
|
|
299
|
+
// guess), check whether THIS value ever showed up in a version of
|
|
300
|
+
// this file that WAS committed — "rotate immediately" is warranted
|
|
301
|
+
// for a real incident and needless (if harmless) urgency otherwise,
|
|
302
|
+
// and rotating a production service_role key across every
|
|
303
|
+
// environment that holds it is not a free action to ask for blindly.
|
|
304
|
+
const committedBefore = proven && !!raw ? wasCommitted(raw) : null;
|
|
305
|
+
const base = ex === 'ignored'
|
|
306
|
+
? `${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.`
|
|
307
|
+
: `${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.`;
|
|
308
|
+
out = {
|
|
309
|
+
...out,
|
|
310
|
+
severity: atMost(out.severity, 'warning'),
|
|
311
|
+
detail: committedBefore === false
|
|
312
|
+
? `${base} Checked this file's git history for this exact value and did not find it — it does not look like it was ever actually committed, so this reads as a hygiene fix, not a confirmed leak.`
|
|
313
|
+
: base,
|
|
314
|
+
fix: committedBefore === false ? `${out.fix} (No evidence this exact value was ever committed, so rotation is precautionary here, not a confirmed-leak emergency.)` : out.fix,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
else if (ex === 'no-git') {
|
|
318
|
+
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.` };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
findings.push(out);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Vendor-shaped keys (OpenAI, AWS, Stripe, a DB URL password, a PEM block, …). */
|
|
325
|
+
function scanVendorPatterns(content, rel, env, push) {
|
|
326
|
+
for (const p of PATTERNS) {
|
|
327
|
+
for (const m of content.matchAll(p.re)) {
|
|
328
|
+
const hit = m[0];
|
|
329
|
+
if (looksLikePlaceholder(hit))
|
|
330
|
+
continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
|
|
331
|
+
if (p.validate && !p.validate(hit))
|
|
332
|
+
continue;
|
|
333
|
+
// A secret in a server env/config file is expected — the risk is
|
|
334
|
+
// committing it (env-git flags that), so it is a warning, not a leak.
|
|
335
|
+
const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
|
|
336
|
+
const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
|
|
337
|
+
push({
|
|
338
|
+
id: p.id,
|
|
339
|
+
severity,
|
|
340
|
+
title: env ? `${p.title} (in env/config file)` : p.title,
|
|
341
|
+
detail: env
|
|
342
|
+
? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
|
|
343
|
+
: `${p.title} found in source: ${redact(hit)}`,
|
|
344
|
+
fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
|
|
345
|
+
checker: 'secrets',
|
|
346
|
+
level: 0,
|
|
347
|
+
file: rel,
|
|
348
|
+
line: lineAt(content, m.index ?? 0),
|
|
349
|
+
evidence: redact(hit),
|
|
350
|
+
}, proven, hit);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Supabase service_role key (a JWT whose payload role is service_role). The
|
|
356
|
+
* decoded role proves what it is, so it is never a docs-only info.
|
|
357
|
+
*/
|
|
358
|
+
function scanServiceRoleJwt(content, rel, env, push) {
|
|
359
|
+
for (const m of content.matchAll(JWT)) {
|
|
360
|
+
const payload = decodeJwtPayload(m[0]);
|
|
361
|
+
if (payload && payload['role'] === 'service_role') {
|
|
362
|
+
push({
|
|
363
|
+
id: 'supabase_service_role_key',
|
|
364
|
+
severity: env ? 'warning' : 'critical',
|
|
365
|
+
title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
|
|
366
|
+
detail: env
|
|
367
|
+
? `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.`
|
|
368
|
+
: '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.',
|
|
369
|
+
fix: env
|
|
370
|
+
? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
|
|
371
|
+
: 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
|
|
372
|
+
checker: 'secrets',
|
|
373
|
+
level: 0,
|
|
374
|
+
file: rel,
|
|
375
|
+
line: lineAt(content, m.index ?? 0),
|
|
376
|
+
evidence: redact(m[0]),
|
|
377
|
+
}, true, m[0]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Quoted key/secret assignments in code, filtered by placeholder + entropy. */
|
|
382
|
+
function scanGenericAssignments(content, rel, push) {
|
|
383
|
+
for (const m of content.matchAll(GENERIC)) {
|
|
384
|
+
const name = m[1] ?? '';
|
|
385
|
+
const value = m[2] ?? '';
|
|
386
|
+
if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
|
|
387
|
+
continue;
|
|
388
|
+
// Credentials have no whitespace ("Show password" is UI text) and carry
|
|
389
|
+
// digits or real length ("build-time-secret" is a label).
|
|
390
|
+
if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
|
|
391
|
+
continue;
|
|
392
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
|
|
393
|
+
continue;
|
|
394
|
+
push({
|
|
395
|
+
id: 'generic_secret',
|
|
396
|
+
severity: 'warning',
|
|
397
|
+
title: 'Possible hardcoded secret',
|
|
398
|
+
detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
|
|
399
|
+
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`.',
|
|
400
|
+
checker: 'secrets',
|
|
401
|
+
level: 0,
|
|
402
|
+
file: rel,
|
|
403
|
+
line: lineAt(content, m.index ?? 0),
|
|
404
|
+
evidence: redact(value),
|
|
405
|
+
}, false, value);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* name=value / key: value assignments (env, config, Dockerfile ENV/ARG). Runs
|
|
410
|
+
* before scanInlineEnv so a plain, single-assignment line keeps this rule's
|
|
411
|
+
* more specific title/fix on the dedup tie in dedupeFindings, rather than the
|
|
412
|
+
* inline-assignment rule's generic one.
|
|
413
|
+
*/
|
|
414
|
+
function scanConfigAssignments(content, rel, env, push) {
|
|
415
|
+
if (!isConfigish(rel))
|
|
416
|
+
return;
|
|
417
|
+
for (const m of content.matchAll(ASSIGN)) {
|
|
418
|
+
const name = m[1] ?? '';
|
|
419
|
+
if (!SECRET_NAME.test(name))
|
|
420
|
+
continue;
|
|
421
|
+
const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
422
|
+
if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
423
|
+
continue;
|
|
424
|
+
// "build-time-secret" is a label, not a credential: real values carry digits or length.
|
|
425
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
426
|
+
continue;
|
|
427
|
+
push({
|
|
428
|
+
id: 'env_secret',
|
|
429
|
+
severity: 'warning',
|
|
430
|
+
title: env ? 'Secret in env file' : 'Secret in config file',
|
|
431
|
+
detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
|
|
432
|
+
fix: env
|
|
433
|
+
? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
|
|
434
|
+
: 'Move this secret out of committed config into a server-side secret store, and rotate it.',
|
|
435
|
+
checker: 'secrets',
|
|
436
|
+
level: 0,
|
|
437
|
+
file: rel,
|
|
438
|
+
line: lineAt(content, m.index ?? 0),
|
|
439
|
+
evidence: redact(value),
|
|
440
|
+
}, false, value);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Inline shell-style assignments mid-line, in every file including
|
|
445
|
+
* config-ish ones. scanConfigAssignments is start-of-line and one-per-line,
|
|
446
|
+
* so it never sees a Compose `environment:` list item (`- TOKEN=…`), a CI
|
|
447
|
+
* step's inline `run: TOKEN=… cmd`, or a second KEY=VALUE later on the same
|
|
448
|
+
* Dockerfile ENV line — all real leaks it silently missed while this ran
|
|
449
|
+
* only for non-config files. Also still needed for the original case: a prod
|
|
450
|
+
* DB password inside a permission rule in .claude/settings.local.json,
|
|
451
|
+
* invisible to every other name-based rule.
|
|
452
|
+
*/
|
|
453
|
+
function scanInlineEnv(content, rel, push) {
|
|
454
|
+
for (const m of content.matchAll(INLINE_ENV)) {
|
|
455
|
+
const name = m[1] ?? '';
|
|
456
|
+
const value = m[2] ?? '';
|
|
457
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
458
|
+
continue;
|
|
459
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
460
|
+
continue;
|
|
461
|
+
push({
|
|
462
|
+
id: 'env_secret',
|
|
463
|
+
severity: 'warning',
|
|
464
|
+
title: 'Secret in an inline assignment',
|
|
465
|
+
detail: `"${name}" is assigned a high-entropy value inline in ${rel}: ${redact(value)}`,
|
|
466
|
+
fix: 'Move the value to a gitignored .env and reference it by name; rotate it if the file was ever shared.',
|
|
467
|
+
checker: 'secrets',
|
|
468
|
+
level: 0,
|
|
469
|
+
file: rel,
|
|
470
|
+
line: lineAt(content, m.index ?? 0),
|
|
471
|
+
evidence: redact(value),
|
|
472
|
+
}, false, value);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
476
|
+
/**
|
|
477
|
+
* De-duplicate only true repeats: the same secret, same place, same rule.
|
|
478
|
+
* (Keying on file:line alone hid every extra key on a minified line.)
|
|
479
|
+
*
|
|
480
|
+
* env_secret and generic_secret are bucketed together (not by their own id):
|
|
481
|
+
* a config-ish file's single assignment runs through BOTH the line-anchored
|
|
482
|
+
* ASSIGN rule and the mid-line GENERIC rule, and when they independently
|
|
483
|
+
* derive the same value from the same line (an `auth_token = "env(NAME)"` in
|
|
484
|
+
* a Supabase config.toml matched both, each producing its own id) that is one
|
|
485
|
+
* fact reported twice, not two facts. Still keyed on evidence, not line alone
|
|
486
|
+
* — two DIFFERENT secrets on one line stay two findings either way.
|
|
487
|
+
*/
|
|
488
|
+
function dedupeFindings(findings) {
|
|
489
|
+
const seen = new Map();
|
|
490
|
+
for (const f of findings) {
|
|
491
|
+
const bucket = NAME_BASED.has(f.id) ? 'name-based' : f.id;
|
|
492
|
+
const key = `${f.file ?? ''}:${f.line ?? 0}:${bucket}:${f.evidence ?? ''}`;
|
|
493
|
+
const cur = seen.get(key);
|
|
494
|
+
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
495
|
+
seen.set(key, f);
|
|
496
|
+
}
|
|
497
|
+
return [...seen.values()];
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* A vendor pattern or a decoded JWT already identifies a VALUE on a line; the
|
|
501
|
+
* name-based env_secret / generic_secret finding for that SAME value is the
|
|
502
|
+
* same fact twice. Keyed on file:line:evidence, not just file:line — two
|
|
503
|
+
* different secrets assigned on one line (`const key="sk-proj-…",
|
|
504
|
+
* password="…"`) are two different facts, and dropping the second because
|
|
505
|
+
* the first has a specific pattern hid it entirely.
|
|
506
|
+
*/
|
|
507
|
+
function dropRedundantNameBased(findings) {
|
|
508
|
+
const specificAt = new Set(findings.filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`));
|
|
509
|
+
return findings.filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`)));
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Eleven advisories saying "secret in gitignored .env.local — fine" are one
|
|
513
|
+
* observation printed eleven times. Fold them into one line per file that
|
|
514
|
+
* names the variables; anything above advisory stays line by line.
|
|
515
|
+
*
|
|
516
|
+
* The final `applyIgnores` pass (scan.ts) only sees whatever this checker
|
|
517
|
+
* returns — once N findings become one summary Finding with a single `line`,
|
|
518
|
+
* a `// easyvibegate-ignore` comment placed above any secret but the first in
|
|
519
|
+
* the group can no longer reach it. Filter each candidate through the SAME
|
|
520
|
+
* inline-marker check first (an empty config so only the marker, not
|
|
521
|
+
* project-wide `ignore`/`ignorePaths` rules, applies here — those still run
|
|
522
|
+
* again, correctly, against whatever this returns), so an individually
|
|
523
|
+
* silenced secret is dropped before folding, not after.
|
|
524
|
+
*/
|
|
525
|
+
function foldEnvAdvisories(kept, allFiles) {
|
|
526
|
+
const byFile = new Map();
|
|
527
|
+
for (const f of kept) {
|
|
528
|
+
if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
|
|
529
|
+
continue;
|
|
530
|
+
byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
|
|
531
|
+
}
|
|
532
|
+
const folded = new Set();
|
|
533
|
+
const summaries = [];
|
|
534
|
+
for (const [file, allCandidates] of byFile) {
|
|
535
|
+
const group = partitionIgnores(allCandidates, EMPTY_CONFIG, allFiles).kept;
|
|
536
|
+
// A member the marker silenced must not reappear individually either —
|
|
537
|
+
// it is done with, not merely "too few left to fold".
|
|
538
|
+
for (const f of allCandidates)
|
|
539
|
+
if (!group.includes(f))
|
|
540
|
+
folded.add(f);
|
|
541
|
+
if (group.length < 2)
|
|
542
|
+
continue;
|
|
543
|
+
for (const f of group)
|
|
544
|
+
folded.add(f);
|
|
545
|
+
const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
|
|
546
|
+
const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
|
|
547
|
+
summaries.push({
|
|
548
|
+
...first,
|
|
549
|
+
title: `${group.length} secrets in ${file} (not committed — where they belong)`,
|
|
550
|
+
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.`,
|
|
551
|
+
fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
|
|
552
|
+
evidence: undefined,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return [...kept.filter((f) => !folded.has(f)), ...summaries];
|
|
218
556
|
}
|
|
219
557
|
export const secretsChecker = {
|
|
220
558
|
id: 'secrets',
|
|
@@ -223,284 +561,27 @@ export const secretsChecker = {
|
|
|
223
561
|
run(ctx) {
|
|
224
562
|
const findings = [];
|
|
225
563
|
const exposure = createExposure(ctx.root);
|
|
226
|
-
|
|
227
|
-
|
|
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);
|
|
564
|
+
const history = createHistoryChecker(ctx.root);
|
|
565
|
+
const realEnvValues = collectRealEnvValues(ctx.files);
|
|
245
566
|
for (const file of ctx.files) {
|
|
246
567
|
const { rel } = file;
|
|
247
568
|
const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
|
|
248
569
|
const env = isEnvFile(rel);
|
|
249
|
-
const example =
|
|
570
|
+
const example = looksLikeTestOrDocPath(rel);
|
|
250
571
|
const ex = exposure(rel);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 = {
|
|
283
|
-
...f,
|
|
284
|
-
severity: 'info',
|
|
285
|
-
title: `${f.title} (in docs/example file)`,
|
|
286
|
-
detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
|
|
287
|
-
};
|
|
288
|
-
}
|
|
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);
|
|
319
|
-
};
|
|
320
|
-
for (const p of PATTERNS) {
|
|
321
|
-
for (const m of content.matchAll(p.re)) {
|
|
322
|
-
const hit = m[0];
|
|
323
|
-
if (looksLikePlaceholder(hit))
|
|
324
|
-
continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
|
|
325
|
-
if (p.validate && !p.validate(hit))
|
|
326
|
-
continue;
|
|
327
|
-
// A secret in a server env/config file is expected — the risk is
|
|
328
|
-
// committing it (env-git flags that), so it is a warning, not a leak.
|
|
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]);
|
|
331
|
-
push({
|
|
332
|
-
id: p.id,
|
|
333
|
-
severity,
|
|
334
|
-
title: env ? `${p.title} (in env/config file)` : p.title,
|
|
335
|
-
detail: env
|
|
336
|
-
? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
|
|
337
|
-
: `${p.title} found in source: ${redact(hit)}`,
|
|
338
|
-
fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
|
|
339
|
-
checker: 'secrets',
|
|
340
|
-
level: 0,
|
|
341
|
-
file: rel,
|
|
342
|
-
line: lineAt(content, m.index ?? 0),
|
|
343
|
-
evidence: redact(hit),
|
|
344
|
-
}, proven, hit);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
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.
|
|
349
|
-
for (const m of content.matchAll(JWT)) {
|
|
350
|
-
const payload = decodeJwtPayload(m[0]);
|
|
351
|
-
if (payload && payload['role'] === 'service_role') {
|
|
352
|
-
push({
|
|
353
|
-
id: 'supabase_service_role_key',
|
|
354
|
-
severity: env ? 'warning' : 'critical',
|
|
355
|
-
title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
|
|
356
|
-
detail: env
|
|
357
|
-
? `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.`
|
|
358
|
-
: '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.',
|
|
359
|
-
fix: env
|
|
360
|
-
? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
|
|
361
|
-
: 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
|
|
362
|
-
checker: 'secrets',
|
|
363
|
-
level: 0,
|
|
364
|
-
file: rel,
|
|
365
|
-
line: lineAt(content, m.index ?? 0),
|
|
366
|
-
evidence: redact(m[0]),
|
|
367
|
-
}, true, m[0]);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// Quoted key/secret assignments in code, filtered by placeholder + entropy.
|
|
371
|
-
for (const m of content.matchAll(GENERIC)) {
|
|
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;
|
|
380
|
-
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
|
|
381
|
-
continue;
|
|
382
|
-
push({
|
|
383
|
-
id: 'generic_secret',
|
|
384
|
-
severity: 'warning',
|
|
385
|
-
title: 'Possible hardcoded secret',
|
|
386
|
-
detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
|
|
387
|
-
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`.',
|
|
388
|
-
checker: 'secrets',
|
|
389
|
-
level: 0,
|
|
390
|
-
file: rel,
|
|
391
|
-
line: lineAt(content, m.index ?? 0),
|
|
392
|
-
evidence: redact(value),
|
|
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
|
-
}
|
|
419
|
-
}
|
|
420
|
-
// name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
|
|
421
|
-
if (isConfigish(rel)) {
|
|
422
|
-
for (const m of content.matchAll(ASSIGN)) {
|
|
423
|
-
const name = m[1] ?? '';
|
|
424
|
-
if (!SECRET_NAME.test(name))
|
|
425
|
-
continue;
|
|
426
|
-
const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
427
|
-
if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
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;
|
|
432
|
-
push({
|
|
433
|
-
id: 'env_secret',
|
|
434
|
-
severity: 'warning',
|
|
435
|
-
title: env ? 'Secret in env file' : 'Secret in config file',
|
|
436
|
-
detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
|
|
437
|
-
fix: env
|
|
438
|
-
? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
|
|
439
|
-
: 'Move this secret out of committed config into a server-side secret store, and rotate it.',
|
|
440
|
-
checker: 'secrets',
|
|
441
|
-
level: 0,
|
|
442
|
-
file: rel,
|
|
443
|
-
line: lineAt(content, m.index ?? 0),
|
|
444
|
-
evidence: redact(value),
|
|
445
|
-
}, false, value);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
// De-duplicate only true repeats: the same secret, same place, same rule.
|
|
450
|
-
// (Keying on file:line alone hid every extra key on a minified line.)
|
|
451
|
-
const seen = new Map();
|
|
452
|
-
for (const f of findings) {
|
|
453
|
-
const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
|
|
454
|
-
const cur = seen.get(key);
|
|
455
|
-
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
456
|
-
seen.set(key, f);
|
|
457
|
-
}
|
|
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
|
-
});
|
|
572
|
+
const wasCommitted = (raw) => history(rel, raw);
|
|
573
|
+
const push = makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted });
|
|
574
|
+
scanVendorPatterns(content, rel, env, push);
|
|
575
|
+
scanServiceRoleJwt(content, rel, env, push);
|
|
576
|
+
// scanConfigAssignments before scanGenericAssignments/scanInlineEnv: on
|
|
577
|
+
// a config-ish file, all three can match the same line, and this one's
|
|
578
|
+
// title/fix is the most specific (it knows the file IS config/env) —
|
|
579
|
+
// dedupeFindings keeps the first-pushed on a tie, so it should win.
|
|
580
|
+
scanConfigAssignments(content, rel, env, push);
|
|
581
|
+
scanGenericAssignments(content, rel, push);
|
|
582
|
+
scanInlineEnv(content, rel, push);
|
|
503
583
|
}
|
|
504
|
-
|
|
584
|
+
const kept = dropRedundantNameBased(dedupeFindings(findings));
|
|
585
|
+
return foldEnvAdvisories(kept, ctx.files);
|
|
505
586
|
},
|
|
506
587
|
};
|