easyvibegate 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/cli/index.js +17 -0
- package/dist/engine/checkers/backend/firebase.js +14 -4
- package/dist/engine/checkers/backend/supabase.js +5 -1
- package/dist/engine/checkers/static/rls-migrations.js +38 -12
- package/dist/engine/checkers/static/secrets.js +100 -6
- package/dist/engine/detect.js +6 -2
- package/dist/engine/util/text.js +12 -0
- package/dist/engine/walk.js +22 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,10 @@ jobs:
|
|
|
106
106
|
|
|
107
107
|
Плюс: `postgres://app:app@localhost` в docker-compose / CI — дефолтный логин локального контейнера, не секрет; `tracking_token` и `pagination_token` из кэша API — не секреты; папки `cache/`, `tmp/` не сканируются.
|
|
108
108
|
|
|
109
|
+
Секрет в gitignored-файле, который **не** `.env` (например, пароль от прода в `.claude/settings.local.json`), — предупреждение: через git не утечёт, но лежит не там. Папка `easyvibegate-report/` сама пишет себе `.gitignore` — отчёт содержит префиксы секретов и карту эндпоинтов, коммитить его нельзя.
|
|
110
|
+
|
|
111
|
+
Статическая проверка RLS видит только `CREATE TABLE` из миграций в репозитории. Таблицы, созданные из дашборда или из другого репо, перечисляет только живая проверка — если отказаться от неё, в отчёте будет часть картины, и он об этом скажет.
|
|
112
|
+
|
|
109
113
|
Оценка — это отметка, а не счётчик: после пяти предупреждений балл перестаёт падать (число всё равно показано), критичные могут обнулить его по-прежнему.
|
|
110
114
|
|
|
111
115
|
## Оценка и статусы
|
package/dist/cli/index.js
CHANGED
|
@@ -262,6 +262,17 @@ 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
|
+
// The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
|
|
266
|
+
// has its stale report files deleted on every run. Both are safe for a
|
|
267
|
+
// directory that exists only to hold reports — neither is safe for the
|
|
268
|
+
// scanned project itself: `--output .` from the project root would replace
|
|
269
|
+
// the project's real .gitignore with a bare `*`, silently un-tracking the
|
|
270
|
+
// whole repo.
|
|
271
|
+
if (args.output !== undefined && outDir === root) {
|
|
272
|
+
process.stderr.write(`easyvibegate: --output must not be the scanned project itself (${root}) — pick a subdirectory, e.g. --output ${join(root, 'easyvibegate-report')}
|
|
273
|
+
`);
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
265
276
|
// Use the friendly wizard when a human runs it in a terminal without
|
|
266
277
|
// automation flags; --wizard forces it. Either way the pipeline below is shared.
|
|
267
278
|
// --ci is a non-interactive contract: never ask questions there.
|
|
@@ -368,6 +379,12 @@ function prepareOutputDir(dir) {
|
|
|
368
379
|
if (existsSync(dir) && !statSync(dir).isDirectory())
|
|
369
380
|
return 'exists and is not a directory';
|
|
370
381
|
mkdirSync(dir, { recursive: true });
|
|
382
|
+
// The report names secret prefixes, database hosts and every endpoint —
|
|
383
|
+
// exactly what must not be committed. A `.gitignore` containing `*` inside
|
|
384
|
+
// the directory makes git ignore it wherever the project's own .gitignore
|
|
385
|
+
// 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
|
+
writeFileSync(join(dir, '.gitignore'), '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n', 'utf8');
|
|
371
388
|
// Old report.md next to a fresh report.json told two different stories.
|
|
372
389
|
for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
|
|
373
390
|
const p = join(dir, name);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { classifyBody, request, sleep } from '../../net/http.js';
|
|
2
|
-
import { looksLikePlaceholder } from '../../util/text.js';
|
|
2
|
+
import { DNS_LABEL, looksLikePlaceholder } from '../../util/text.js';
|
|
3
|
+
const FIREBASEIO_URL = new RegExp(`https://${DNS_LABEL}(?:-default-rtdb)?\\.firebaseio\\.com`);
|
|
4
|
+
const FIREBASEAPP_DOMAIN = new RegExp(`(${DNS_LABEL})\\.firebaseapp\\.com`);
|
|
5
|
+
const FIREBASEDATABASE_APP = new RegExp(`^${DNS_LABEL}\\.firebasedatabase\\.app$`);
|
|
3
6
|
const COMMON_COLLECTIONS = [
|
|
4
7
|
'users', 'user', 'profiles', 'accounts', 'messages', 'chats', 'posts',
|
|
5
8
|
'orders', 'payments', 'products', 'items', 'settings', 'admin', 'config',
|
|
@@ -11,13 +14,16 @@ export function discoverFirebase(all) {
|
|
|
11
14
|
let projectId;
|
|
12
15
|
let databaseURL;
|
|
13
16
|
let storageBucket;
|
|
17
|
+
// This loop runs against every project file's raw content, including large
|
|
18
|
+
// ones — see `DNS_LABEL` in util/text.ts for why the two host regexes below
|
|
19
|
+
// are bounded instead of `[a-z0-9-]+`.
|
|
14
20
|
for (const f of files) {
|
|
15
21
|
projectId ??= f.content.match(/projectId\s*:\s*["']([^"']+)["']/)?.[1];
|
|
16
22
|
databaseURL ??= f.content.match(/databaseURL\s*:\s*["']([^"']+)["']/)?.[1]
|
|
17
|
-
?? f.content.match(
|
|
23
|
+
?? f.content.match(FIREBASEIO_URL)?.[0];
|
|
18
24
|
storageBucket ??= f.content.match(/storageBucket\s*:\s*["']([^"']+)["']/)?.[1];
|
|
19
25
|
if (!projectId) {
|
|
20
|
-
const dom = f.content.match(
|
|
26
|
+
const dom = f.content.match(FIREBASEAPP_DOMAIN)?.[1];
|
|
21
27
|
if (dom)
|
|
22
28
|
projectId = dom;
|
|
23
29
|
}
|
|
@@ -37,7 +43,11 @@ function isOwnRtdbHost(host, projectId) {
|
|
|
37
43
|
const regional = `${p}-default-rtdb.`; // <project>-default-rtdb.<region>.firebasedatabase.app
|
|
38
44
|
return host === `${p}.firebaseio.com`
|
|
39
45
|
|| host === `${p}-default-rtdb.firebaseio.com`
|
|
40
|
-
|
|
46
|
+
// `host` here is already `new URL(...).hostname` (bounded by the caller,
|
|
47
|
+
// ownDatabaseURL below), not raw file content — but it's bounded via the
|
|
48
|
+
// shared constant anyway rather than `+`, so this stays safe even if that
|
|
49
|
+
// ever changes and nothing here looks unlike its two siblings above.
|
|
50
|
+
|| (host.startsWith(regional) && FIREBASEDATABASE_APP.test(host.slice(regional.length)))
|
|
41
51
|
|| host === `${p}.firebaseapp.com`;
|
|
42
52
|
}
|
|
43
53
|
/**
|
|
@@ -21,7 +21,11 @@ export function classifyKey(key) {
|
|
|
21
21
|
return 'jwt-service';
|
|
22
22
|
return 'unknown';
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
// Bounded for the same reason as the shared `DNS_LABEL` in util/text.ts (a
|
|
25
|
+
// quadratic-time regex on raw file content) — but a Supabase project ref is
|
|
26
|
+
// its own format (no hyphens, {16,63} not {1,63}), so this stays a literal
|
|
27
|
+
// rather than reusing that constant.
|
|
28
|
+
const BARE_URL = /https:\/\/[a-z0-9]{16,63}\.supabase\.co/;
|
|
25
29
|
const BARE_PUBLISHABLE = /\bsb_publishable_[A-Za-z0-9_-]{10,}\b/;
|
|
26
30
|
const BARE_JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
27
31
|
function isPublicKey(key) {
|
|
@@ -592,27 +592,53 @@ 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
|
-
|
|
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
|
+
}
|
|
597
615
|
// RLS is a PostgreSQL feature. The engine is decided per FILE, not per
|
|
598
616
|
// project: a monorepo can hold a Postgres schema next to a Cloudflare D1
|
|
599
617
|
// (SQLite) one, and a SQLite schema must never be told to enable RLS — that
|
|
600
618
|
// engine has no such thing. A real D1 project once got five "critical" RLS
|
|
601
619
|
// findings and a 0/100 because the old gate only knew MySQL's
|
|
602
620
|
// `auto_increment` (with the underscore) and never heard of D1/wrangler.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
621
|
+
// A monorepo's dependencies often live in apps/*/package.json, not the
|
|
622
|
+
// workspace root. Reading only the root file missed a D1/SQLite worker's
|
|
623
|
+
// `wrangler`/`better-sqlite3` signal whenever the SQL itself carried no
|
|
624
|
+
// dialect marker either, and defaulted it to Postgres — a phantom critical
|
|
625
|
+
// on a database that has no RLS to enable.
|
|
626
|
+
const pkg = ctx.files.filter((f) => f.rel.endsWith('package.json')).map((f) => f.content).join('\n');
|
|
627
|
+
const reqs = ctx.files.filter((f) => f.rel === 'requirements.txt' || f.rel.endsWith('/requirements.txt')).map((f) => f.content).join('\n');
|
|
628
|
+
// Checked with .some() against the files themselves, not a pre-joined
|
|
629
|
+
// string: joining nearly the whole project's non-doc/non-SQL content into
|
|
630
|
+
// one string on every scan doubled peak memory for the full scanned corpus
|
|
631
|
+
// to answer what is, per project, a handful of existence checks that can
|
|
632
|
+
// each stop at the first matching file.
|
|
633
|
+
const codeFiles = ctx.files.filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel));
|
|
634
|
+
const matchesAnyCodeFile = (re) => codeFiles.some((f) => re.test(f.content));
|
|
609
635
|
const pgProject = ctx.detection.backends.includes('supabase') ||
|
|
610
636
|
/\b(pg|postgres|postgresql|@supabase\/|postgres\.js|node-postgres|pg-promise)\b/i.test(pkg) ||
|
|
611
637
|
/\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(reqs);
|
|
612
638
|
const hasD1 = ctx.files.some((f) => /(^|\/)wrangler\.(toml|jsonc?)$/.test(f.rel) && /d1_databases/.test(f.content));
|
|
613
639
|
const sqliteProject = hasD1 ||
|
|
614
640
|
/"(better-sqlite3|sqlite3|@libsql\/client)"/i.test(pkg) ||
|
|
615
|
-
/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]
|
|
641
|
+
matchesAnyCodeFile(/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/);
|
|
616
642
|
const mysqlProject = /"(mysql2?|mariadb)"/i.test(pkg);
|
|
617
643
|
const sqliteLabel = hasD1 ? 'Cloudflare D1 (SQLite)' : 'SQLite';
|
|
618
644
|
const PG_MARK = /(row\s+level\s+security|gen_random_uuid|\b(?:big)?serial\b|::\s*\w+|\bjsonb\b|\bplpgsql\b|create\s+policy|uuid_generate)/i;
|
|
@@ -664,7 +690,7 @@ export const rlsMigrationsChecker = {
|
|
|
664
690
|
// the tool's audience is mostly Supabase, and unknown must not read as safe.
|
|
665
691
|
const exposed = ctx.detection.backends.includes('supabase') ||
|
|
666
692
|
/@supabase\//i.test(pkg) ||
|
|
667
|
-
/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i
|
|
693
|
+
matchesAnyCodeFile(/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i);
|
|
668
694
|
const serverOnly = !exposed &&
|
|
669
695
|
(/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
|
|
670
696
|
/\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
|
|
@@ -787,12 +813,12 @@ export const rlsMigrationsChecker = {
|
|
|
787
813
|
: `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
|
|
788
814
|
detail: kind === 'missing'
|
|
789
815
|
? (missingSeverity === 'critical'
|
|
790
|
-
? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security.
|
|
816
|
+
? `"${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.`
|
|
791
817
|
: `"${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.`)
|
|
792
818
|
: kind === 'order'
|
|
793
819
|
? `"${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.`
|
|
794
820
|
: `"${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.`,
|
|
795
|
-
fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). This is a static hint — confirm the deployed state.`,
|
|
821
|
+
fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). 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.`,
|
|
796
822
|
checker: 'rls-migrations',
|
|
797
823
|
level: 0,
|
|
798
824
|
file: s.file,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
|
|
2
2
|
import { createExposure } from '../../util/git-exposure.js';
|
|
3
|
+
import { partitionIgnores } from '../../config.js';
|
|
4
|
+
/** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
|
|
5
|
+
const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
|
|
6
|
+
/** Lower number = more severe. One copy, used both to cap a severity and to rank duplicates. */
|
|
7
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2, advisory: 3 };
|
|
8
|
+
const atMost = (sev, cap) => (SEVERITY_RANK[sev] < SEVERITY_RANK[cap] ? cap : sev);
|
|
3
9
|
/** Real tokens mix case and digits; kebab-case identifiers do not. */
|
|
4
10
|
function looksRandom(s) {
|
|
5
11
|
const body = s.replace(/^[a-z]+[-_]/i, '');
|
|
@@ -173,6 +179,23 @@ const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g
|
|
|
173
179
|
// KEY=value / key: value / Dockerfile ENV|ARG KEY=value, with a secret-looking NAME.
|
|
174
180
|
const ASSIGN = /^[ \t]*(?:export[ \t]+|ENV[ \t]+|ARG[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*(.+)$/gm;
|
|
175
181
|
const SECRET_NAME = /(secret|token|password|passwd|private[_-]?key|api[_-]?key|access[_-]?key|credential)/i;
|
|
182
|
+
/**
|
|
183
|
+
* Shell-style `NAME=value` anywhere in a line, in any file: `PGPASSWORD=… psql`,
|
|
184
|
+
* `TOKEN=… curl`, a permission rule in .claude/settings.local.json. Upper-case
|
|
185
|
+
* names only, so prose like "password=…" in docs does not count.
|
|
186
|
+
*
|
|
187
|
+
* The leading class is `*` (zero or more), not `+`: a bare name that IS one of
|
|
188
|
+
* the keywords (`SECRET=…`, `TOKEN=…`, `API_KEY=…`, no prefix at all — an
|
|
189
|
+
* extremely common shape in real scripts) needs zero characters before the
|
|
190
|
+
* keyword, and requiring at least one meant those never matched at all. There
|
|
191
|
+
* is deliberately no trailing lookahead: `{8,}` on a fixed character class
|
|
192
|
+
* already stops at the first character outside it, so a value followed by
|
|
193
|
+
* `;`, `)`, `,` or `]` (`export TOKEN=abc123;`, `foo(SECRET=abc123)`) still
|
|
194
|
+
* matches correctly — an earlier version's trailing negative lookahead
|
|
195
|
+
* rejected exactly those common shell/call-site endings.
|
|
196
|
+
*/
|
|
197
|
+
// Values are ASCII token characters: `PASSWORD=та_же_что_и_выше` in a README is prose.
|
|
198
|
+
const INLINE_ENV = /\b([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*)=([A-Za-z0-9_\-./+=:@]{8,})/g;
|
|
176
199
|
/** Only .env* files are "server env by design" — a secret there is a warning
|
|
177
200
|
* (env-git flags committing it). In real code/config it stays a source leak. */
|
|
178
201
|
function isEnvFile(rel) {
|
|
@@ -229,8 +252,6 @@ export const secretsChecker = {
|
|
|
229
252
|
// looksLikeRealMaterial). A docs/fixture path may only downgrade a
|
|
230
253
|
// heuristic or sample-looking hit to info; proven material stays at
|
|
231
254
|
// warning there — the path lowers confidence, it does not make it safe.
|
|
232
|
-
const rank = { critical: 0, warning: 1, info: 2, advisory: 3 };
|
|
233
|
-
const atMost = (sev, cap) => (rank[sev] < rank[cap] ? cap : sev);
|
|
234
255
|
/**
|
|
235
256
|
* Severity = what the value is × how it can leak. The pattern says what it
|
|
236
257
|
* is; the example/docs path lowers confidence; git exposure decides how
|
|
@@ -275,10 +296,17 @@ export const secretsChecker = {
|
|
|
275
296
|
// env-git check's finding, not this one's — reporting it twice at
|
|
276
297
|
// warning is what made people stop reading.
|
|
277
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.
|
|
278
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.` };
|
|
279
303
|
}
|
|
280
304
|
else if (ex === 'ignored') {
|
|
281
|
-
|
|
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.` };
|
|
282
310
|
}
|
|
283
311
|
else if (ex === 'untracked') {
|
|
284
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.` };
|
|
@@ -364,6 +392,31 @@ export const secretsChecker = {
|
|
|
364
392
|
evidence: redact(value),
|
|
365
393
|
}, false, value);
|
|
366
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
|
+
}
|
|
367
420
|
// name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
|
|
368
421
|
if (isConfigish(rel)) {
|
|
369
422
|
for (const m of content.matchAll(ASSIGN)) {
|
|
@@ -395,18 +448,59 @@ export const secretsChecker = {
|
|
|
395
448
|
}
|
|
396
449
|
// De-duplicate only true repeats: the same secret, same place, same rule.
|
|
397
450
|
// (Keying on file:line alone hid every extra key on a minified line.)
|
|
398
|
-
const order = { critical: 0, warning: 1, info: 2, advisory: 3 };
|
|
399
451
|
const seen = new Map();
|
|
400
452
|
for (const f of findings) {
|
|
401
453
|
const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
|
|
402
454
|
const cur = seen.get(key);
|
|
403
|
-
if (!cur ||
|
|
455
|
+
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
404
456
|
seen.set(key, f);
|
|
405
457
|
}
|
|
406
458
|
// A vendor pattern or a decoded JWT already identifies the value on a line;
|
|
407
459
|
// the name-based env_secret / generic_secret there is the same fact twice.
|
|
408
460
|
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
409
461
|
const specificAt = new Set([...seen.values()].filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}`));
|
|
410
|
-
|
|
462
|
+
const kept = [...seen.values()].filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}`)));
|
|
463
|
+
// Eleven advisories saying "secret in gitignored .env.local — fine" are one
|
|
464
|
+
// observation printed eleven times. Fold them into one line per file that
|
|
465
|
+
// names the variables; anything above advisory stays line by line.
|
|
466
|
+
//
|
|
467
|
+
// The final `applyIgnores` pass (scan.ts) only sees whatever this checker
|
|
468
|
+
// returns — once N findings become one summary Finding with a single
|
|
469
|
+
// `line`, a `// easyvibegate-ignore` comment placed above any secret but
|
|
470
|
+
// the first in the group can no longer reach it. Filter each candidate
|
|
471
|
+
// through the SAME inline-marker check first (an empty config so only the
|
|
472
|
+
// marker, not project-wide `ignore`/`ignorePaths` rules, applies here —
|
|
473
|
+
// those still run again, correctly, against whatever this returns), so an
|
|
474
|
+
// individually silenced secret is dropped before folding, not after.
|
|
475
|
+
const byFile = new Map();
|
|
476
|
+
for (const f of kept) {
|
|
477
|
+
if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
|
|
478
|
+
continue;
|
|
479
|
+
byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
|
|
480
|
+
}
|
|
481
|
+
const folded = new Set();
|
|
482
|
+
const summaries = [];
|
|
483
|
+
for (const [file, allCandidates] of byFile) {
|
|
484
|
+
const group = partitionIgnores(allCandidates, EMPTY_CONFIG, ctx.files).kept;
|
|
485
|
+
// A member the marker silenced must not reappear individually either —
|
|
486
|
+
// it is done with, not merely "too few left to fold".
|
|
487
|
+
for (const f of allCandidates)
|
|
488
|
+
if (!group.includes(f))
|
|
489
|
+
folded.add(f);
|
|
490
|
+
if (group.length < 2)
|
|
491
|
+
continue;
|
|
492
|
+
for (const f of group)
|
|
493
|
+
folded.add(f);
|
|
494
|
+
const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
|
|
495
|
+
const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
|
|
496
|
+
summaries.push({
|
|
497
|
+
...first,
|
|
498
|
+
title: `${group.length} secrets in ${file} (not committed — where they belong)`,
|
|
499
|
+
detail: `${file} holds ${group.length} secret-looking values (${names.join(', ')}). The file is not committed, so nothing leaks through git; keep it that way and out of client bundles.`,
|
|
500
|
+
fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
|
|
501
|
+
evidence: undefined,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return [...kept.filter((f) => !folded.has(f)), ...summaries];
|
|
411
505
|
},
|
|
412
506
|
};
|
package/dist/engine/detect.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { DNS_LABEL } from './util/text.js';
|
|
4
|
+
const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
|
|
3
5
|
/** Infer the project's frameworks, backends, languages and package managers. */
|
|
4
6
|
export function detect(root, files) {
|
|
5
7
|
const relSet = new Set(files.map((f) => f.rel));
|
|
@@ -99,8 +101,10 @@ export function detect(root, files) {
|
|
|
99
101
|
frameworks.add('rails');
|
|
100
102
|
}
|
|
101
103
|
// Content fallback for projects without a package.json — kept strict to avoid
|
|
102
|
-
// false positives from code that merely mentions a backend by name.
|
|
103
|
-
|
|
104
|
+
// false positives from code that merely mentions a backend by name. Uses the
|
|
105
|
+
// shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
|
|
106
|
+
// a long run of matching characters in a large file.
|
|
107
|
+
if (!backends.has('supabase') && files.some((f) => SUPABASE_URL_FALLBACK.test(f.content))) {
|
|
104
108
|
backends.add('supabase');
|
|
105
109
|
}
|
|
106
110
|
if (!backends.has('firebase') && files.some((f) => /\binitializeApp\s*\(/.test(f.content) && /firebase/i.test(f.content))) {
|
package/dist/engine/util/text.js
CHANGED
|
@@ -48,3 +48,15 @@ export function decodeJwtPayload(token) {
|
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* A DNS label, bounded to its real maximum (RFC 1035, 63 octets) — never `+`.
|
|
53
|
+
* Used right before a literal host suffix (`.firebaseapp.com`, `.supabase.co`)
|
|
54
|
+
* in a regex run against raw, unbounded file content. An open `[a-z0-9-]+` in
|
|
55
|
+
* that position is a classic quadratic-time regex: at every position inside a
|
|
56
|
+
* long run of matching characters (a minified bundle, a base64 blob, a lockfile
|
|
57
|
+
* hash) the engine greedily consumes to the end and backtracks one character at
|
|
58
|
+
* a time looking for a literal that never comes. Measured: a 500KB matching run
|
|
59
|
+
* took over two minutes unbounded; bounded, the same file scans in single-digit
|
|
60
|
+
* milliseconds — and no real hostname label is longer than this anyway.
|
|
61
|
+
*/
|
|
62
|
+
export const DNS_LABEL = '[a-z0-9-]{1,63}';
|
package/dist/engine/walk.js
CHANGED
|
@@ -8,11 +8,25 @@ const SKIP_DIRS = new Set([
|
|
|
8
8
|
// folder names above misses a venv called anything else (tools/ytenv/...),
|
|
9
9
|
// and then every key inside a vendored library is reported as the user's leak.
|
|
10
10
|
'site-packages', '__pypackages__', 'bower_components', 'Pods',
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
'
|
|
11
|
+
// Yarn Berry vendors its own release script and zips dependencies here —
|
|
12
|
+
// tool-managed, not the user's code (and often full of high-entropy blobs
|
|
13
|
+
// that would otherwise read as secrets).
|
|
14
|
+
'.yarn',
|
|
15
15
|
]);
|
|
16
|
+
/**
|
|
17
|
+
* Names ambiguous enough that they are sometimes real source (a module named
|
|
18
|
+
* "cache", a package called "tmp") and sometimes pure data. Skipped only when
|
|
19
|
+
* `relDir` (the ambiguous directory's own path, relative to the project root,
|
|
20
|
+
* e.g. "cache" or "data/cache") has at most 2 path segments — i.e. the
|
|
21
|
+
* directory IS the root's own child, or is nested exactly one level below it.
|
|
22
|
+
* The real-world evidence for this was `data/cache/*.json` full of API
|
|
23
|
+
* pagination tokens, 559 of 569 "generic secrets" in one real project. Deeper
|
|
24
|
+
* nesting (`apps/api/src/lib/cache/`, 4 segments) is ordinary source and stays
|
|
25
|
+
* scanned; blanket name-matching at any depth once made a source directory
|
|
26
|
+
* invisible to every check with no visible coverage gap.
|
|
27
|
+
*/
|
|
28
|
+
const AMBIGUOUS_DATA_DIRS = new Set(['cache', 'caches', 'tmp', 'temp', '.tmp']);
|
|
29
|
+
const isShallowDataDir = (name, relDir) => AMBIGUOUS_DATA_DIRS.has(name) && relDir.split('/').length <= 2;
|
|
16
30
|
const TEXT_EXT = new Set([
|
|
17
31
|
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
|
|
18
32
|
'.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
|
|
@@ -63,7 +77,8 @@ export function walk(root, opts = {}) {
|
|
|
63
77
|
// link to a directory or a scannable file is unchecked content, and
|
|
64
78
|
// silently dropping it let a project whose `migrations` was a symlink
|
|
65
79
|
// PASS with full coverage. Count it so the walk is reported partial.
|
|
66
|
-
|
|
80
|
+
const relLink = relative(root, full).split(sep).join('/');
|
|
81
|
+
if (SKIP_DIRS.has(ent.name) || isShallowDataDir(ent.name, relLink))
|
|
67
82
|
continue;
|
|
68
83
|
try {
|
|
69
84
|
const target = statSync(full); // follows the link
|
|
@@ -76,7 +91,8 @@ export function walk(root, opts = {}) {
|
|
|
76
91
|
continue;
|
|
77
92
|
}
|
|
78
93
|
if (ent.isDirectory()) {
|
|
79
|
-
|
|
94
|
+
const relDir = relative(root, full).split(sep).join('/');
|
|
95
|
+
if (!SKIP_DIRS.has(ent.name) && !isShallowDataDir(ent.name, relDir) && !excluded.has(resolve(full)))
|
|
80
96
|
stack.push(full);
|
|
81
97
|
continue;
|
|
82
98
|
}
|
package/package.json
CHANGED