easyvibegate 0.6.2 → 0.6.8
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/dist/cli/index.js +27 -6
- 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/rls-migrations.js +112 -44
- package/dist/engine/checkers/static/secrets.js +343 -290
- package/dist/engine/detect.js +17 -3
- package/dist/engine/util/text.js +14 -0
- package/dist/engine/walk.js +22 -12
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { planTargets, runFlow } from '../orchestrator/flow.js';
|
|
@@ -262,6 +262,12 @@ async function main() {
|
|
|
262
262
|
// Reports land next to the scanned project by default, so scanning several
|
|
263
263
|
// projects from one shell never overwrites another project's report.
|
|
264
264
|
const outDir = args.output !== undefined ? resolve(args.output) : join(root, 'easyvibegate-report');
|
|
265
|
+
// Excluding outDir from the scan only makes sense when something is
|
|
266
|
+
// actually going to be written there. With --no-report (format 'none'),
|
|
267
|
+
// nothing is ever written to it — so a `--no-report --output <project>/src`
|
|
268
|
+
// run must not exclude real source just because it shares a path with an
|
|
269
|
+
// output directory nothing will use.
|
|
270
|
+
const excludeOutDir = args.format !== 'none' ? [outDir] : [];
|
|
265
271
|
// The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
|
|
266
272
|
// has its stale report files deleted on every run. Both are safe for a
|
|
267
273
|
// directory that exists only to hold reports — neither is safe for the
|
|
@@ -292,7 +298,7 @@ async function main() {
|
|
|
292
298
|
let result;
|
|
293
299
|
if (useWizard) {
|
|
294
300
|
result = await runWizard({
|
|
295
|
-
excludeAbs:
|
|
301
|
+
excludeAbs: excludeOutDir,
|
|
296
302
|
path: args.path,
|
|
297
303
|
config: args.config,
|
|
298
304
|
lang,
|
|
@@ -320,7 +326,7 @@ async function main() {
|
|
|
320
326
|
};
|
|
321
327
|
// Same contract as the wizard: plan the concrete targets once, ask about
|
|
322
328
|
// exactly those, execute exactly those.
|
|
323
|
-
const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs:
|
|
329
|
+
const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: excludeOutDir });
|
|
324
330
|
const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
|
|
325
331
|
appUrl: args.appUrl,
|
|
326
332
|
supabaseUrl: args.supabaseUrl,
|
|
@@ -373,18 +379,33 @@ async function main() {
|
|
|
373
379
|
// Same contract in every mode: 2 critical, 1 warning, 3 incomplete, 0 clean.
|
|
374
380
|
process.exit(exitCodeFor(summary));
|
|
375
381
|
}
|
|
382
|
+
// Written verbatim (never partially, never appended-to) so an exact match on
|
|
383
|
+
// disk is proof WE created this directory, not the user re-pointing --output
|
|
384
|
+
// at a folder of their own. Checking "does outDir === project root" only was
|
|
385
|
+
// not enough: `--output docs` on a real docs/ folder replaced its real
|
|
386
|
+
// .gitignore (losing rules like `drafts/`) and deleted a real docs/report.md.
|
|
387
|
+
const OUTPUT_DIR_MARKER = '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n';
|
|
376
388
|
/** Create the report dir and clear our own stale files, or explain why we cannot. */
|
|
377
389
|
function prepareOutputDir(dir) {
|
|
378
390
|
try {
|
|
379
|
-
|
|
391
|
+
const existed = existsSync(dir);
|
|
392
|
+
if (existed && !statSync(dir).isDirectory())
|
|
380
393
|
return 'exists and is not a directory';
|
|
394
|
+
if (existed) {
|
|
395
|
+
const ownsIt = existsSync(join(dir, '.gitignore')) && readFileSync(join(dir, '.gitignore'), 'utf8') === OUTPUT_DIR_MARKER;
|
|
396
|
+
if (!ownsIt && readdirSync(dir).length > 0) {
|
|
397
|
+
return 'already exists, is not empty, and was not created by a previous EasyVibeGate run — refusing to overwrite its .gitignore or delete files in it; point --output at an empty or dedicated directory';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
381
400
|
mkdirSync(dir, { recursive: true });
|
|
382
401
|
// The report names secret prefixes, database hosts and every endpoint —
|
|
383
402
|
// exactly what must not be committed. A `.gitignore` containing `*` inside
|
|
384
403
|
// the directory makes git ignore it wherever the project's own .gitignore
|
|
385
404
|
// stands (the trick node_modules-style caches use); the user's files are
|
|
386
|
-
// never edited. Rewritten every run so a stray edit cannot un-ignore it
|
|
387
|
-
|
|
405
|
+
// never edited. Rewritten every run so a stray edit cannot un-ignore it —
|
|
406
|
+
// safe now because we only ever reach this line for a directory that was
|
|
407
|
+
// either empty or already marked as ours.
|
|
408
|
+
writeFileSync(join(dir, '.gitignore'), OUTPUT_DIR_MARKER, 'utf8');
|
|
388
409
|
// Old report.md next to a fresh report.json told two different stories.
|
|
389
410
|
for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
|
|
390
411
|
const p = join(dir, name);
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
|
|
2
|
+
import { maskCode } from '../../util/mask.js';
|
|
2
3
|
import { classifyKey } from '../backend/supabase.js';
|
|
3
4
|
// Public env prefixes are inlined into the browser bundle by the bundler.
|
|
4
5
|
const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
|
|
5
6
|
// Every public-prefixed assignment; what it carries is decided by the VALUE
|
|
6
|
-
// first and the NAME second (below).
|
|
7
|
-
|
|
7
|
+
// first and the NAME second (below). The name gets an optional closing quote
|
|
8
|
+
// before the `:`/`=` — a JSON/object literal's `"NEXT_PUBLIC_KEY": "value"`
|
|
9
|
+
// otherwise never matched at all, since nothing after the bare name expected
|
|
10
|
+
// that stray `"`. The gap between `:`/`=` and the value is same-line
|
|
11
|
+
// whitespace only (`[ \t]*`, not `\s*`) — an EMPTY-valued var followed by
|
|
12
|
+
// blank/commented lines otherwise let the value group skip straight past
|
|
13
|
+
// them onto the NEXT var's name, reporting that name as a leaked secret
|
|
14
|
+
// value. Once comments started getting masked to blank space (below), a
|
|
15
|
+
// `.env.example`'s usual "KEY=\n\n# comment\nNEXT_KEY=" shape hit this on
|
|
16
|
+
// real projects.
|
|
17
|
+
const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)["']?\\s*[:=][ \\t]*["']?([^"'\\s]{6,})`, 'gi');
|
|
8
18
|
// A public var whose NAME implies a real secret (not an anon/publishable key).
|
|
9
19
|
const SECRET_NAME = /(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)/i;
|
|
10
20
|
// Value shapes that are server secrets no matter what the variable is called.
|
|
@@ -40,7 +50,10 @@ export const clientExposureChecker = {
|
|
|
40
50
|
const advised = new Set();
|
|
41
51
|
for (const file of ctx.files) {
|
|
42
52
|
const { content, rel } = file;
|
|
43
|
-
|
|
53
|
+
// Commented-out code is not a live exposure; masking preserves length
|
|
54
|
+
// and newlines so match offsets/line numbers still point at the source.
|
|
55
|
+
const scan = maskCode(content, { file: rel });
|
|
56
|
+
for (const m of scan.matchAll(PUBLIC_ASSIGN)) {
|
|
44
57
|
const name = m[1] ?? '';
|
|
45
58
|
const value = m[2] ?? '';
|
|
46
59
|
if (looksLikePlaceholder(value))
|
|
@@ -37,7 +37,13 @@ const RULES = [
|
|
|
37
37
|
// inside one string literal — so prose mentioning "insert" won't match.
|
|
38
38
|
// Quantifiers are length-bounded ({0,200}) to prevent catastrophic
|
|
39
39
|
// backtracking (ReDoS) on very long / minified lines.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// The f-string branch tracks its OWN opening quote via a backreference
|
|
42
|
+
// (`(?:(?!\1)[^\n])` — any char that isn't a newline or that quote) rather
|
|
43
|
+
// than excluding both quote characters outright: `f"SELECT … WHERE name =
|
|
44
|
+
// '{x}'"` nests a single-quoted SQL string value inside a double-quoted
|
|
45
|
+
// f-string, and excluding `'` too made the whole pattern never match.
|
|
46
|
+
re: /`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^`]{0,200}\$\{|f(["'])\s*(?:SELECT|INSERT|UPDATE|DELETE)\b(?:(?!\1)[^\n]){0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b(?:(?!\1)[^\n]){0,200}\{/gi,
|
|
41
47
|
severity: 'warning',
|
|
42
48
|
detail: 'Interpolating values into SQL invites SQL injection.',
|
|
43
49
|
fix: 'Use parameterized queries / prepared statements instead of string interpolation.',
|
|
@@ -73,7 +79,11 @@ function isParameterizedTemplate(scan, backtickAt) {
|
|
|
73
79
|
* from request data, so it is reported as info, not as an injection. About half
|
|
74
80
|
* of the real-world hits were structural.
|
|
75
81
|
*/
|
|
76
|
-
|
|
82
|
+
// A trailing `["']?` handles the equally common quoted-value shape
|
|
83
|
+
// (`name = '${name}'`, `WHERE id = "${id}"`) — the SQL literal's own quote
|
|
84
|
+
// sits directly before the hole, between the operator and `${`/`{`, and
|
|
85
|
+
// without it that shape read as unquoted structure, not a value.
|
|
86
|
+
const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*["']?\s*$/i;
|
|
77
87
|
const PLACEHOLDER_EXPR = /^\s*(?:\w+\.)?(placeholders?|params?|marks|questions|qs|values|binds?)\b/i;
|
|
78
88
|
function onlyStructuralInterpolation(scan, at) {
|
|
79
89
|
// The literal: from the opening delimiter to its close (bounded).
|
|
@@ -592,26 +592,6 @@ export const rlsMigrationsChecker = {
|
|
|
592
592
|
level: 0,
|
|
593
593
|
run(ctx) {
|
|
594
594
|
const sqlFiles = ctx.files.filter((f) => f.ext === '.sql').sort((a, b) => a.rel.localeCompare(b.rel));
|
|
595
|
-
if (sqlFiles.length === 0) {
|
|
596
|
-
// A project that clearly talks to Supabase but ships no .sql migrations
|
|
597
|
-
// (schema managed from the dashboard, or migrations live in another repo)
|
|
598
|
-
// used to just say nothing — a silent, undeserved clean score on the exact
|
|
599
|
-
// check the tool exists for. Say what could not be checked instead.
|
|
600
|
-
const looksSupabase = ctx.detection.backends.includes('supabase') ||
|
|
601
|
-
ctx.files.some((f) => !/\.(md|mdx|txt|rst)$/i.test(f.rel) &&
|
|
602
|
-
/(SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_|\/rest\/v1\b|postgrest)/i.test(f.content));
|
|
603
|
-
if (!looksSupabase)
|
|
604
|
-
return [];
|
|
605
|
-
return [{
|
|
606
|
-
id: 'rls_unverifiable_no_migrations',
|
|
607
|
-
severity: 'info',
|
|
608
|
-
title: 'No SQL migrations found — RLS could not be checked statically',
|
|
609
|
-
detail: 'This project talks to Supabase, but no .sql migration files exist in this repository (the schema may be managed from the dashboard, or migrations live in a different repo). A static scan can only see table/RLS state from files it can read, so no table here could be verified this way — this is missing information, not a clean result.',
|
|
610
|
-
fix: 'Run the live probe (consent + --i-own-this) to enumerate which tables the anon key can read, or check pg_class.relrowsecurity and pg_policies directly in the Supabase SQL editor.',
|
|
611
|
-
checker: 'rls-migrations',
|
|
612
|
-
level: 0,
|
|
613
|
-
}];
|
|
614
|
-
}
|
|
615
595
|
// RLS is a PostgreSQL feature. The engine is decided per FILE, not per
|
|
616
596
|
// project: a monorepo can hold a Postgres schema next to a Cloudflare D1
|
|
617
597
|
// (SQLite) one, and a SQLite schema must never be told to enable RLS — that
|
|
@@ -681,8 +661,14 @@ export const rlsMigrationsChecker = {
|
|
|
681
661
|
file: skipped[0]?.file ?? '',
|
|
682
662
|
line: 1,
|
|
683
663
|
}];
|
|
684
|
-
|
|
685
|
-
|
|
664
|
+
// No early return here even when every SQL file present was a foreign
|
|
665
|
+
// engine (`analyzed.length === 0 && skipped.length > 0`): that settles
|
|
666
|
+
// those FILES (SQLite/MySQL truly have no RLS), but not the PROJECT — a
|
|
667
|
+
// Supabase project whose only local .sql happens to be a SQLite cache
|
|
668
|
+
// file has learned nothing about its real Postgres schema either. Both
|
|
669
|
+
// this case and zero SQL files at all fall through to the checks below,
|
|
670
|
+
// which decide from what was actually parsed (nothing, here) whether
|
|
671
|
+
// anything about the applicable backend's schema was learned.
|
|
686
672
|
// "No RLS" is a hole only when untrusted clients reach the database directly
|
|
687
673
|
// (Supabase/PostgREST with the anon key, Hasura). A Postgres that only server
|
|
688
674
|
// code talks to has no RLS by default and is not exposed by that — a critical
|
|
@@ -694,7 +680,10 @@ export const rlsMigrationsChecker = {
|
|
|
694
680
|
const serverOnly = !exposed &&
|
|
695
681
|
(/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
|
|
696
682
|
/\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
|
|
697
|
-
|
|
683
|
+
// Server-only is 'advisory', not 'warning': the fix text for this exact
|
|
684
|
+
// case says "no action needed unless X" — a warning (−8 points, shown as
|
|
685
|
+
// a problem to look into) contradicted its own explanation of itself.
|
|
686
|
+
const missingSeverity = exposed || !serverOnly ? 'critical' : 'advisory';
|
|
698
687
|
const events = [];
|
|
699
688
|
const notUnderstood = [];
|
|
700
689
|
analyzed.forEach((f, fileIdx) => {
|
|
@@ -708,7 +697,10 @@ export const rlsMigrationsChecker = {
|
|
|
708
697
|
events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
|
|
709
698
|
const state = new Map();
|
|
710
699
|
for (const e of events) {
|
|
711
|
-
const cur = state.get(e.key) ?? {
|
|
700
|
+
const cur = state.get(e.key) ?? {
|
|
701
|
+
created: false, everCreated: false, everDropped: false, enabled: false, file: e.file, line: e.line,
|
|
702
|
+
display: e.display, stateFile: e.file, stateLine: e.line, guarded: false, keys: new Set([e.key]),
|
|
703
|
+
};
|
|
712
704
|
if (e.kind === 'rename') {
|
|
713
705
|
const to = e.toKey;
|
|
714
706
|
cur.keys.add(to);
|
|
@@ -716,13 +708,14 @@ export const rlsMigrationsChecker = {
|
|
|
716
708
|
// Which name the table ends up under is unknown: keep it under both, both in doubt.
|
|
717
709
|
cur.guarded = true;
|
|
718
710
|
cur.stateFile = e.file;
|
|
711
|
+
cur.stateLine = e.line;
|
|
719
712
|
state.set(e.key, cur);
|
|
720
713
|
if (!state.has(to))
|
|
721
714
|
state.set(to, { ...cur, keys: new Set(cur.keys), display: e.toDisplay ?? cur.display });
|
|
722
715
|
continue;
|
|
723
716
|
}
|
|
724
717
|
state.delete(e.key);
|
|
725
|
-
state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file });
|
|
718
|
+
state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file, stateLine: e.line });
|
|
726
719
|
continue;
|
|
727
720
|
}
|
|
728
721
|
// Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
|
|
@@ -734,6 +727,7 @@ export const rlsMigrationsChecker = {
|
|
|
734
727
|
// (the outcome that leaves data exposed), and record the uncertainty.
|
|
735
728
|
if (e.kind === 'create' && !cur.created) {
|
|
736
729
|
cur.created = true;
|
|
730
|
+
cur.everCreated = true;
|
|
737
731
|
cur.enabled = false;
|
|
738
732
|
cur.file = e.file;
|
|
739
733
|
cur.line = e.line;
|
|
@@ -745,6 +739,7 @@ export const rlsMigrationsChecker = {
|
|
|
745
739
|
cur.enabled = false;
|
|
746
740
|
cur.guarded = true;
|
|
747
741
|
cur.stateFile = e.file;
|
|
742
|
+
cur.stateLine = e.line;
|
|
748
743
|
state.set(e.key, cur);
|
|
749
744
|
continue;
|
|
750
745
|
}
|
|
@@ -753,27 +748,33 @@ export const rlsMigrationsChecker = {
|
|
|
753
748
|
if (e.ifNotExists && cur.created)
|
|
754
749
|
break; // existing table: no-op, keep RLS state
|
|
755
750
|
cur.created = true;
|
|
751
|
+
cur.everCreated = true;
|
|
756
752
|
cur.enabled = false;
|
|
757
753
|
cur.file = e.file;
|
|
758
754
|
cur.line = e.line;
|
|
759
755
|
cur.display = e.display;
|
|
760
756
|
cur.stateFile = e.file;
|
|
757
|
+
cur.stateLine = e.line;
|
|
761
758
|
cur.guarded = false;
|
|
762
759
|
break;
|
|
763
760
|
case 'enable':
|
|
764
761
|
cur.enabled = true;
|
|
765
762
|
cur.stateFile = e.file;
|
|
763
|
+
cur.stateLine = e.line;
|
|
766
764
|
cur.guarded = false;
|
|
767
765
|
break;
|
|
768
766
|
case 'disable':
|
|
769
767
|
cur.enabled = false;
|
|
770
768
|
cur.stateFile = e.file;
|
|
769
|
+
cur.stateLine = e.line;
|
|
771
770
|
cur.guarded = false;
|
|
772
771
|
break;
|
|
773
772
|
case 'drop':
|
|
774
773
|
cur.created = false;
|
|
774
|
+
cur.everDropped = true;
|
|
775
775
|
cur.enabled = false;
|
|
776
776
|
cur.stateFile = e.file;
|
|
777
|
+
cur.stateLine = e.line;
|
|
777
778
|
cur.guarded = false;
|
|
778
779
|
break;
|
|
779
780
|
}
|
|
@@ -791,40 +792,107 @@ export const rlsMigrationsChecker = {
|
|
|
791
792
|
const turnsOff = (k) => k === 'disable' || k === 'create';
|
|
792
793
|
const findings = [];
|
|
793
794
|
for (const [key, s] of state) {
|
|
794
|
-
if (
|
|
795
|
+
if (isTempKey(key))
|
|
795
796
|
continue; // temp tables are session-only: PostgREST never sees them
|
|
797
|
+
// Confirmed gone: created here then legitimately DROPped (the original,
|
|
798
|
+
// well-tested case), OR never created here at all but still reached by
|
|
799
|
+
// an unconditional DROP this scan DID see — an external table whose
|
|
800
|
+
// removal this repo's migrations confirm, not merely reference. Either
|
|
801
|
+
// way there is nothing left to hold RLS state, on or off.
|
|
802
|
+
if (!s.created && s.everDropped)
|
|
803
|
+
continue;
|
|
804
|
+
// Reaching here means one of two things: `s.created` (created here and
|
|
805
|
+
// still exists — the original, well-tested case), or `!s.everCreated`
|
|
806
|
+
// (this key is only ever the target of ALTER/RENAME — an EXTERNAL
|
|
807
|
+
// table this repo never creates, managed from the dashboard or a
|
|
808
|
+
// migration outside this scan). Both get the SAME ambiguity/guard
|
|
809
|
+
// evaluation below: a DISABLE right there in the scanned file is not
|
|
810
|
+
// "no information" just because we cannot confirm the table exists,
|
|
811
|
+
// and an unconditional ENABLE on either is equally provably safe.
|
|
812
|
+
// Reusing one model — instead of a second, separate one for external
|
|
813
|
+
// tables that has to relearn DROP/RENAME/guard/cross-directory order
|
|
814
|
+
// on its own — is what keeps them from disagreeing with each other.
|
|
815
|
+
const external = !s.everCreated;
|
|
796
816
|
const ambiguous = events.some((e) => s.keys.has(e.key) && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
|
|
797
817
|
if (s.enabled && !ambiguous && !s.guarded)
|
|
798
|
-
continue; // provably protected
|
|
818
|
+
continue; // provably protected (or provably fine, if external)
|
|
799
819
|
// `guarded` means a statement we had to GUESS about decided this table's
|
|
800
820
|
// state — a conditional ENABLE, DISABLE, CREATE, DROP or RENAME. Calling
|
|
801
821
|
// that "critical" would be the same false confidence as calling it clean,
|
|
802
822
|
// so it is reported as unconfirmed. A warning still fails CI (exit 1); it
|
|
803
823
|
// just does not claim to know what only the database can tell.
|
|
804
|
-
const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
|
|
824
|
+
const kind = external ? 'external' : ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
|
|
805
825
|
findings.push({
|
|
806
826
|
id: 'rls_missing',
|
|
807
827
|
severity: kind === 'missing' ? missingSeverity : 'warning',
|
|
808
|
-
title: kind === '
|
|
809
|
-
|
|
810
|
-
?
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
: `Table "${s.display}"
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
828
|
+
title: kind === 'external' ? `Table "${s.display}" may have RLS disabled here, but is never created in this repo`
|
|
829
|
+
: kind === 'missing'
|
|
830
|
+
? (missingSeverity === 'critical'
|
|
831
|
+
? `Table "${s.display}" created without RLS`
|
|
832
|
+
: `Table "${s.display}" has no RLS (server-only database)`)
|
|
833
|
+
: kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
|
|
834
|
+
: `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
|
|
835
|
+
detail: kind === 'external'
|
|
836
|
+
? `"${s.display}" is not created by any migration this scan can see, but this project's SQL leaves it with Row Level Security off — or its state cannot be confirmed statically (a conditional branch, or statements in different directories with no reliable apply order). Either the table is real — created from the dashboard, or in a migration outside this repo — and RLS on it may now be off, or it does not exist and this is a no-op. This cannot be told apart statically — check the deployed state.`
|
|
837
|
+
: kind === 'missing'
|
|
838
|
+
? (missingSeverity === 'critical'
|
|
839
|
+
? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. On Supabase the anon role has full table privileges by default, so with RLS off the public key can READ, INSERT, UPDATE and DELETE every row through the REST API. Static view only: this covers tables created by migrations in this repository — tables created from the dashboard or elsewhere are not listed here; the live probe enumerates all of them.`
|
|
840
|
+
: `"${s.display}" is created without Row Level Security. Only server code (pg/Prisma/…) appears to talk to this database, so nothing hands its rows to clients directly — that is normal, not a hole. It becomes CRITICAL the moment a client-facing data API (Supabase/PostgREST anon key, Hasura) is put in front of the same database.`)
|
|
841
|
+
: kind === 'order'
|
|
842
|
+
? `"${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.`
|
|
843
|
+
: `"${s.display}" has a table or RLS statement whose execution cannot be confirmed statically: inside an IF/LOOP branch or a block with an EXCEPTION handler, after a RETURN, in a rolled-back or unterminated transaction, or on a name a TEMP table may shadow. Whether it took effect cannot be decided without running the migration, so its RLS state is NOT confirmed — check the deployed state.`,
|
|
844
|
+
// The REVOKE-from-anon step only makes sense where an `anon` role is a
|
|
845
|
+
// real thing to revoke from — the same condition `missingSeverity`
|
|
846
|
+
// itself uses. Recommending it unconditionally told a server-only
|
|
847
|
+
// Postgres user (no Supabase, no anon role) to revoke privileges from
|
|
848
|
+
// a role that does not exist in their database, right next to a
|
|
849
|
+
// detail explaining that RLS is not needed there at all. An external
|
|
850
|
+
// table gets its own fix: we do not even know it exists, so neither
|
|
851
|
+
// the anon-revoke step nor the server-only "nothing to do" fits.
|
|
852
|
+
fix: kind === 'external'
|
|
853
|
+
? `Confirm whether "${s.display}" exists in the deployed database. If it does, re-enable RLS: ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; and add an owner/tenant policy.`
|
|
854
|
+
: exposed || !serverOnly
|
|
855
|
+
? `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). Until policies exist, stop the bleeding without breaking reads: REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM anon; This is a static hint — confirm the deployed state.`
|
|
856
|
+
: `No action needed unless a client-facing data API (Supabase/PostgREST, Hasura) is ever put in front of this database — if it is, first ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY and add an owner/tenant policy, then REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM the role that API uses to connect.`,
|
|
822
857
|
checker: 'rls-migrations',
|
|
823
858
|
level: 0,
|
|
824
|
-
file: s.file,
|
|
825
|
-
line: s.line,
|
|
859
|
+
file: external ? s.stateFile : s.file,
|
|
860
|
+
line: external ? s.stateLine : s.line,
|
|
826
861
|
});
|
|
827
862
|
}
|
|
863
|
+
// Zero findings can mean two very different things: every table here has
|
|
864
|
+
// RLS handled correctly, or no table definition was ever found in the SQL
|
|
865
|
+
// at all — a maintenance script (`SELECT now();`), a seed file with no
|
|
866
|
+
// CREATE TABLE (schema itself made from the dashboard), or literally zero
|
|
867
|
+
// .sql files. `sqlFiles.length === 0` alone used to gate this and missed
|
|
868
|
+
// the first two: presence of a .sql file is not presence of schema
|
|
869
|
+
// information. `events` tells the two apart directly — a table that was
|
|
870
|
+
// found and IS clean still produced a 'create' event, just no finding.
|
|
871
|
+
//
|
|
872
|
+
// A TEMP table's own 'create' does NOT count: it is session-only and
|
|
873
|
+
// PostgREST never sees it (the same reason it is excluded from findings
|
|
874
|
+
// below), so a migration that creates only a staging TEMP table — even
|
|
875
|
+
// one that also DISABLEs RLS on a persistent table this repo never
|
|
876
|
+
// defines — learned nothing about the real, permanent schema either.
|
|
877
|
+
// `findings.length > 0` also counts as "learned something": an external
|
|
878
|
+
// table (no local CREATE at all) can still produce a real finding — an
|
|
879
|
+
// explicit DISABLE sitting right in the scanned file — and that finding
|
|
880
|
+
// must not be thrown away just because no CREATE ever ran anywhere.
|
|
881
|
+
const sawAnyTable = findings.length > 0 || events.some((e) => e.kind === 'create' && !isTempKey(e.key));
|
|
882
|
+
if (!sawAnyTable && notUnderstood.length === 0 && ctx.detection.backends.includes('supabase')) {
|
|
883
|
+
return {
|
|
884
|
+
findings: [...notApplicable, {
|
|
885
|
+
id: 'rls_unverifiable_no_migrations',
|
|
886
|
+
severity: 'info',
|
|
887
|
+
title: 'No table definitions found in SQL — RLS could not be checked statically',
|
|
888
|
+
detail: 'This project talks to Supabase, but no CREATE TABLE (or SELECT ... INTO) was found in any .sql file here — either there are no migrations, or the ones present (a seed script, a maintenance query) do not define a schema. The schema may be managed from the dashboard, or migrations may live in a different repo. A static scan can only see table/RLS state from files it can read, so no table here could be verified this way — this is missing information, not a clean result.',
|
|
889
|
+
fix: 'Run the live probe (consent + --i-own-this) to enumerate which tables the anon key can read, or check pg_class.relrowsecurity and pg_policies directly in the Supabase SQL editor.',
|
|
890
|
+
checker: 'rls-migrations',
|
|
891
|
+
level: 0,
|
|
892
|
+
}],
|
|
893
|
+
partial: 'no table definitions found in SQL for a Supabase project — RLS could not be checked for any table',
|
|
894
|
+
};
|
|
895
|
+
}
|
|
828
896
|
const all = [...findings, ...notApplicable];
|
|
829
897
|
if (notUnderstood.length === 0)
|
|
830
898
|
return all;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
|
|
1
|
+
import { decodeJwtPayload, lineAt, looksLikePlaceholder, looksLikeTestOrDocPath, redact, shannonEntropy } from '../../util/text.js';
|
|
2
2
|
import { createExposure } 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. */
|
|
@@ -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,320 @@ 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 }) {
|
|
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') {
|
|
295
|
+
// Gitignored, but not an env file: a credential pasted into a tool
|
|
296
|
+
// config or a script (a prod DB password inside a permission rule in
|
|
297
|
+
// .claude/settings.local.json was reported as a mere advisory). It
|
|
298
|
+
// cannot leak through git today; it still does not belong there.
|
|
299
|
+
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.` };
|
|
300
|
+
}
|
|
301
|
+
else if (ex === 'untracked') {
|
|
302
|
+
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.` };
|
|
303
|
+
}
|
|
304
|
+
else if (ex === 'no-git') {
|
|
305
|
+
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.` };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
findings.push(out);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Vendor-shaped keys (OpenAI, AWS, Stripe, a DB URL password, a PEM block, …). */
|
|
312
|
+
function scanVendorPatterns(content, rel, env, push) {
|
|
313
|
+
for (const p of PATTERNS) {
|
|
314
|
+
for (const m of content.matchAll(p.re)) {
|
|
315
|
+
const hit = m[0];
|
|
316
|
+
if (looksLikePlaceholder(hit))
|
|
317
|
+
continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
|
|
318
|
+
if (p.validate && !p.validate(hit))
|
|
319
|
+
continue;
|
|
320
|
+
// A secret in a server env/config file is expected — the risk is
|
|
321
|
+
// committing it (env-git flags that), so it is a warning, not a leak.
|
|
322
|
+
const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
|
|
323
|
+
const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
|
|
324
|
+
push({
|
|
325
|
+
id: p.id,
|
|
326
|
+
severity,
|
|
327
|
+
title: env ? `${p.title} (in env/config file)` : p.title,
|
|
328
|
+
detail: env
|
|
329
|
+
? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
|
|
330
|
+
: `${p.title} found in source: ${redact(hit)}`,
|
|
331
|
+
fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
|
|
332
|
+
checker: 'secrets',
|
|
333
|
+
level: 0,
|
|
334
|
+
file: rel,
|
|
335
|
+
line: lineAt(content, m.index ?? 0),
|
|
336
|
+
evidence: redact(hit),
|
|
337
|
+
}, proven, hit);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Supabase service_role key (a JWT whose payload role is service_role). The
|
|
343
|
+
* decoded role proves what it is, so it is never a docs-only info.
|
|
344
|
+
*/
|
|
345
|
+
function scanServiceRoleJwt(content, rel, env, push) {
|
|
346
|
+
for (const m of content.matchAll(JWT)) {
|
|
347
|
+
const payload = decodeJwtPayload(m[0]);
|
|
348
|
+
if (payload && payload['role'] === 'service_role') {
|
|
349
|
+
push({
|
|
350
|
+
id: 'supabase_service_role_key',
|
|
351
|
+
severity: env ? 'warning' : 'critical',
|
|
352
|
+
title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
|
|
353
|
+
detail: env
|
|
354
|
+
? `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.`
|
|
355
|
+
: '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.',
|
|
356
|
+
fix: env
|
|
357
|
+
? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
|
|
358
|
+
: 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
|
|
359
|
+
checker: 'secrets',
|
|
360
|
+
level: 0,
|
|
361
|
+
file: rel,
|
|
362
|
+
line: lineAt(content, m.index ?? 0),
|
|
363
|
+
evidence: redact(m[0]),
|
|
364
|
+
}, true, m[0]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Quoted key/secret assignments in code, filtered by placeholder + entropy. */
|
|
369
|
+
function scanGenericAssignments(content, rel, push) {
|
|
370
|
+
for (const m of content.matchAll(GENERIC)) {
|
|
371
|
+
const name = m[1] ?? '';
|
|
372
|
+
const value = m[2] ?? '';
|
|
373
|
+
if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
|
|
374
|
+
continue;
|
|
375
|
+
// Credentials have no whitespace ("Show password" is UI text) and carry
|
|
376
|
+
// digits or real length ("build-time-secret" is a label).
|
|
377
|
+
if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
|
|
378
|
+
continue;
|
|
379
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
|
|
380
|
+
continue;
|
|
381
|
+
push({
|
|
382
|
+
id: 'generic_secret',
|
|
383
|
+
severity: 'warning',
|
|
384
|
+
title: 'Possible hardcoded secret',
|
|
385
|
+
detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
|
|
386
|
+
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`.',
|
|
387
|
+
checker: 'secrets',
|
|
388
|
+
level: 0,
|
|
389
|
+
file: rel,
|
|
390
|
+
line: lineAt(content, m.index ?? 0),
|
|
391
|
+
evidence: redact(value),
|
|
392
|
+
}, false, value);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* name=value / key: value assignments (env, config, Dockerfile ENV/ARG). Runs
|
|
397
|
+
* before scanInlineEnv so a plain, single-assignment line keeps this rule's
|
|
398
|
+
* more specific title/fix on the dedup tie in dedupeFindings, rather than the
|
|
399
|
+
* inline-assignment rule's generic one.
|
|
400
|
+
*/
|
|
401
|
+
function scanConfigAssignments(content, rel, env, push) {
|
|
402
|
+
if (!isConfigish(rel))
|
|
403
|
+
return;
|
|
404
|
+
for (const m of content.matchAll(ASSIGN)) {
|
|
405
|
+
const name = m[1] ?? '';
|
|
406
|
+
if (!SECRET_NAME.test(name))
|
|
407
|
+
continue;
|
|
408
|
+
const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
|
|
409
|
+
if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
410
|
+
continue;
|
|
411
|
+
// "build-time-secret" is a label, not a credential: real values carry digits or length.
|
|
412
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
413
|
+
continue;
|
|
414
|
+
push({
|
|
415
|
+
id: 'env_secret',
|
|
416
|
+
severity: 'warning',
|
|
417
|
+
title: env ? 'Secret in env file' : 'Secret in config file',
|
|
418
|
+
detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
|
|
419
|
+
fix: env
|
|
420
|
+
? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
|
|
421
|
+
: 'Move this secret out of committed config into a server-side secret store, and rotate it.',
|
|
422
|
+
checker: 'secrets',
|
|
423
|
+
level: 0,
|
|
424
|
+
file: rel,
|
|
425
|
+
line: lineAt(content, m.index ?? 0),
|
|
426
|
+
evidence: redact(value),
|
|
427
|
+
}, false, value);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Inline shell-style assignments mid-line, in every file including
|
|
432
|
+
* config-ish ones. scanConfigAssignments is start-of-line and one-per-line,
|
|
433
|
+
* so it never sees a Compose `environment:` list item (`- TOKEN=…`), a CI
|
|
434
|
+
* step's inline `run: TOKEN=… cmd`, or a second KEY=VALUE later on the same
|
|
435
|
+
* Dockerfile ENV line — all real leaks it silently missed while this ran
|
|
436
|
+
* only for non-config files. Also still needed for the original case: a prod
|
|
437
|
+
* DB password inside a permission rule in .claude/settings.local.json,
|
|
438
|
+
* invisible to every other name-based rule.
|
|
439
|
+
*/
|
|
440
|
+
function scanInlineEnv(content, rel, push) {
|
|
441
|
+
for (const m of content.matchAll(INLINE_ENV)) {
|
|
442
|
+
const name = m[1] ?? '';
|
|
443
|
+
const value = m[2] ?? '';
|
|
444
|
+
if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
|
|
445
|
+
continue;
|
|
446
|
+
if (!/[0-9]/.test(value) && value.length < 24)
|
|
447
|
+
continue;
|
|
448
|
+
push({
|
|
449
|
+
id: 'env_secret',
|
|
450
|
+
severity: 'warning',
|
|
451
|
+
title: 'Secret in an inline assignment',
|
|
452
|
+
detail: `"${name}" is assigned a high-entropy value inline in ${rel}: ${redact(value)}`,
|
|
453
|
+
fix: 'Move the value to a gitignored .env and reference it by name; rotate it if the file was ever shared.',
|
|
454
|
+
checker: 'secrets',
|
|
455
|
+
level: 0,
|
|
456
|
+
file: rel,
|
|
457
|
+
line: lineAt(content, m.index ?? 0),
|
|
458
|
+
evidence: redact(value),
|
|
459
|
+
}, false, value);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* De-duplicate only true repeats: the same secret, same place, same rule.
|
|
464
|
+
* (Keying on file:line alone hid every extra key on a minified line.)
|
|
465
|
+
*/
|
|
466
|
+
function dedupeFindings(findings) {
|
|
467
|
+
const seen = new Map();
|
|
468
|
+
for (const f of findings) {
|
|
469
|
+
const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
|
|
470
|
+
const cur = seen.get(key);
|
|
471
|
+
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
472
|
+
seen.set(key, f);
|
|
473
|
+
}
|
|
474
|
+
return [...seen.values()];
|
|
475
|
+
}
|
|
476
|
+
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
477
|
+
/**
|
|
478
|
+
* A vendor pattern or a decoded JWT already identifies a VALUE on a line; the
|
|
479
|
+
* name-based env_secret / generic_secret finding for that SAME value is the
|
|
480
|
+
* same fact twice. Keyed on file:line:evidence, not just file:line — two
|
|
481
|
+
* different secrets assigned on one line (`const key="sk-proj-…",
|
|
482
|
+
* password="…"`) are two different facts, and dropping the second because
|
|
483
|
+
* the first has a specific pattern hid it entirely.
|
|
484
|
+
*/
|
|
485
|
+
function dropRedundantNameBased(findings) {
|
|
486
|
+
const specificAt = new Set(findings.filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`));
|
|
487
|
+
return findings.filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`)));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Eleven advisories saying "secret in gitignored .env.local — fine" are one
|
|
491
|
+
* observation printed eleven times. Fold them into one line per file that
|
|
492
|
+
* names the variables; anything above advisory stays line by line.
|
|
493
|
+
*
|
|
494
|
+
* The final `applyIgnores` pass (scan.ts) only sees whatever this checker
|
|
495
|
+
* returns — once N findings become one summary Finding with a single `line`,
|
|
496
|
+
* a `// easyvibegate-ignore` comment placed above any secret but the first in
|
|
497
|
+
* the group can no longer reach it. Filter each candidate through the SAME
|
|
498
|
+
* inline-marker check first (an empty config so only the marker, not
|
|
499
|
+
* project-wide `ignore`/`ignorePaths` rules, applies here — those still run
|
|
500
|
+
* again, correctly, against whatever this returns), so an individually
|
|
501
|
+
* silenced secret is dropped before folding, not after.
|
|
502
|
+
*/
|
|
503
|
+
function foldEnvAdvisories(kept, allFiles) {
|
|
504
|
+
const byFile = new Map();
|
|
505
|
+
for (const f of kept) {
|
|
506
|
+
if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
|
|
507
|
+
continue;
|
|
508
|
+
byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
|
|
509
|
+
}
|
|
510
|
+
const folded = new Set();
|
|
511
|
+
const summaries = [];
|
|
512
|
+
for (const [file, allCandidates] of byFile) {
|
|
513
|
+
const group = partitionIgnores(allCandidates, EMPTY_CONFIG, allFiles).kept;
|
|
514
|
+
// A member the marker silenced must not reappear individually either —
|
|
515
|
+
// it is done with, not merely "too few left to fold".
|
|
516
|
+
for (const f of allCandidates)
|
|
517
|
+
if (!group.includes(f))
|
|
518
|
+
folded.add(f);
|
|
519
|
+
if (group.length < 2)
|
|
520
|
+
continue;
|
|
521
|
+
for (const f of group)
|
|
522
|
+
folded.add(f);
|
|
523
|
+
const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
|
|
524
|
+
const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
|
|
525
|
+
summaries.push({
|
|
526
|
+
...first,
|
|
527
|
+
title: `${group.length} secrets in ${file} (not committed — where they belong)`,
|
|
528
|
+
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.`,
|
|
529
|
+
fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
|
|
530
|
+
evidence: undefined,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return [...kept.filter((f) => !folded.has(f)), ...summaries];
|
|
218
534
|
}
|
|
219
535
|
export const secretsChecker = {
|
|
220
536
|
id: 'secrets',
|
|
@@ -223,284 +539,21 @@ export const secretsChecker = {
|
|
|
223
539
|
run(ctx) {
|
|
224
540
|
const findings = [];
|
|
225
541
|
const exposure = createExposure(ctx.root);
|
|
226
|
-
|
|
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);
|
|
542
|
+
const realEnvValues = collectRealEnvValues(ctx.files);
|
|
245
543
|
for (const file of ctx.files) {
|
|
246
544
|
const { rel } = file;
|
|
247
545
|
const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
|
|
248
546
|
const env = isEnvFile(rel);
|
|
249
|
-
const example =
|
|
547
|
+
const example = looksLikeTestOrDocPath(rel);
|
|
250
548
|
const ex = exposure(rel);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 = {
|
|
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
|
-
});
|
|
549
|
+
const push = makePush({ rel, env, example, ex, realEnvValues, findings });
|
|
550
|
+
scanVendorPatterns(content, rel, env, push);
|
|
551
|
+
scanServiceRoleJwt(content, rel, env, push);
|
|
552
|
+
scanGenericAssignments(content, rel, push);
|
|
553
|
+
scanConfigAssignments(content, rel, env, push);
|
|
554
|
+
scanInlineEnv(content, rel, push);
|
|
503
555
|
}
|
|
504
|
-
|
|
556
|
+
const kept = dropRedundantNameBased(dedupeFindings(findings));
|
|
557
|
+
return foldEnvAdvisories(kept, ctx.files);
|
|
505
558
|
},
|
|
506
559
|
};
|
package/dist/engine/detect.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { DNS_LABEL } from './util/text.js';
|
|
3
|
+
import { DNS_LABEL, looksLikeTestOrDocPath } from './util/text.js';
|
|
4
|
+
import { maskCode } from './util/mask.js';
|
|
4
5
|
const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
|
|
5
6
|
/** Infer the project's frameworks, backends, languages and package managers. */
|
|
6
7
|
export function detect(root, files) {
|
|
@@ -104,10 +105,23 @@ export function detect(root, files) {
|
|
|
104
105
|
// false positives from code that merely mentions a backend by name. Uses the
|
|
105
106
|
// shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
|
|
106
107
|
// a long run of matching characters in a large file.
|
|
107
|
-
|
|
108
|
+
//
|
|
109
|
+
// Two independent guards against a project's own code making itself look
|
|
110
|
+
// like it uses a backend it only mentions: test/fixture files are excluded
|
|
111
|
+
// (a test suite for code that talks to Supabase routinely contains
|
|
112
|
+
// synthetic mock URLs shaped like a real project's own), and comments are
|
|
113
|
+
// masked before matching (an explanatory comment describing that exact URL
|
|
114
|
+
// shape is otherwise itself a match). Both were needed: this fallback once
|
|
115
|
+
// made EasyVibeGate's own test suite, and separately its own source
|
|
116
|
+
// comments, detect EasyVibeGate itself as a Supabase project — a security
|
|
117
|
+
// scanner flagging itself is exactly what dogfooding exists to catch.
|
|
118
|
+
// Strings are NOT masked: a real URL/call normally appears in one.
|
|
119
|
+
const nonTestFiles = files.filter((f) => !looksLikeTestOrDocPath(f.rel));
|
|
120
|
+
const codeOnly = (f) => maskCode(f.content, { file: f.rel });
|
|
121
|
+
if (!backends.has('supabase') && nonTestFiles.some((f) => SUPABASE_URL_FALLBACK.test(codeOnly(f)))) {
|
|
108
122
|
backends.add('supabase');
|
|
109
123
|
}
|
|
110
|
-
if (!backends.has('firebase') &&
|
|
124
|
+
if (!backends.has('firebase') && nonTestFiles.some((f) => { const c = codeOnly(f); return /\binitializeApp\s*\(/.test(c) && /firebase/i.test(c); })) {
|
|
111
125
|
backends.add('firebase');
|
|
112
126
|
}
|
|
113
127
|
return {
|
package/dist/engine/util/text.js
CHANGED
|
@@ -60,3 +60,17 @@ export function decodeJwtPayload(token) {
|
|
|
60
60
|
* milliseconds — and no real hostname label is longer than this anyway.
|
|
61
61
|
*/
|
|
62
62
|
export const DNS_LABEL = '[a-z0-9-]{1,63}';
|
|
63
|
+
/**
|
|
64
|
+
* Documentation, examples and test/fixture paths — where a sample config
|
|
65
|
+
* value (a fake Supabase URL, a placeholder key) legitimately lives without
|
|
66
|
+
* describing anything about the real project. A test suite for code that
|
|
67
|
+
* TALKS to a backend routinely contains literal-looking URLs/keys for that
|
|
68
|
+
* backend as mock data; without this, scanning a project's own tests can
|
|
69
|
+
* make the project appear to use a backend it merely tests against.
|
|
70
|
+
*/
|
|
71
|
+
export function looksLikeTestOrDocPath(rel) {
|
|
72
|
+
return /\.(md|txt|mdx|rst)$/i.test(rel)
|
|
73
|
+
|| /\.(example|sample|template|dist)$/i.test(rel)
|
|
74
|
+
|| /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
|
|
75
|
+
|| /\.(test|spec)\.[a-z]+$/i.test(rel);
|
|
76
|
+
}
|
package/dist/engine/walk.js
CHANGED
|
@@ -15,22 +15,32 @@ const SKIP_DIRS = new Set([
|
|
|
15
15
|
]);
|
|
16
16
|
/**
|
|
17
17
|
* Names ambiguous enough that they are sometimes real source (a module named
|
|
18
|
-
* "cache", a package called "tmp") and sometimes pure data.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* "cache", a package called "tmp") and sometimes pure data. Path depth alone
|
|
19
|
+
* does not tell them apart: `src/cache` and `data/cache` are both 2 segments,
|
|
20
|
+
* but the first is exactly as likely to be a project's own source directory
|
|
21
|
+
* (a caching module) as the second is to be scan output. What DOES tell them
|
|
22
|
+
* apart is the parent: a data-ish parent name (`data`, `var`, `storage`) means
|
|
23
|
+
* generated/fetched content; an ordinary source-tree parent does not. Skipped
|
|
24
|
+
* only when the ambiguous name is the project root's own child (`cache/`,
|
|
25
|
+
* `tmp/` at the top level), or its immediate parent is one of those data-ish
|
|
26
|
+
* names (`data/cache`, `var/tmp`). The real-world evidence for this was
|
|
27
|
+
* `data/cache/*.json` full of API pagination tokens, 559 of 569 "generic
|
|
28
|
+
* secrets" in one real project; blanket name-matching at any depth once made
|
|
29
|
+
* a source directory invisible to every check with no visible coverage gap,
|
|
30
|
+
* and matching by depth alone reopened that exact gap for `src/cache`.
|
|
27
31
|
*/
|
|
28
32
|
const AMBIGUOUS_DATA_DIRS = new Set(['cache', 'caches', 'tmp', 'temp', '.tmp']);
|
|
29
|
-
const
|
|
33
|
+
const DATA_ISH_PARENTS = new Set(['data', 'var', 'storage']);
|
|
34
|
+
const isShallowDataDir = (name, relDir) => {
|
|
35
|
+
if (!AMBIGUOUS_DATA_DIRS.has(name))
|
|
36
|
+
return false;
|
|
37
|
+
const segs = relDir.split('/');
|
|
38
|
+
return segs.length === 1 || (segs.length === 2 && DATA_ISH_PARENTS.has((segs[0] ?? '').toLowerCase()));
|
|
39
|
+
};
|
|
30
40
|
const TEXT_EXT = new Set([
|
|
31
|
-
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
|
|
41
|
+
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
|
|
32
42
|
'.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
|
|
33
|
-
'.html', '.css', '.scss', '.json', '.yml', '.yaml', '.toml',
|
|
43
|
+
'.html', '.css', '.scss', '.json', '.jsonc', '.json5', '.yml', '.yaml', '.toml',
|
|
34
44
|
'.env', '.sh', '.sql', '.md', '.txt', '.ini', '.conf', '.tf',
|
|
35
45
|
'.pem', '.key', '.crt', '.cert', '.pkcs8',
|
|
36
46
|
'.astro', '.properties', '.plist', '.swift', '.dart', '.ipynb', '.bash', '.zsh', '.mdx',
|
package/package.json
CHANGED