easyvibegate 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +144 -0
  4. package/dist/cli/index.js +402 -0
  5. package/dist/cli/wizard.js +196 -0
  6. package/dist/engine/aifix.js +65 -0
  7. package/dist/engine/checkers/backend/firebase.js +146 -0
  8. package/dist/engine/checkers/backend/supabase.js +249 -0
  9. package/dist/engine/checkers/deep/deps.js +118 -0
  10. package/dist/engine/checkers/index.js +15 -0
  11. package/dist/engine/checkers/live/endpoint-probe.js +72 -0
  12. package/dist/engine/checkers/live/http-checks.js +123 -0
  13. package/dist/engine/checkers/live/idor.js +101 -0
  14. package/dist/engine/checkers/static/client-exposure.js +34 -0
  15. package/dist/engine/checkers/static/config-risks.js +89 -0
  16. package/dist/engine/checkers/static/env-git.js +70 -0
  17. package/dist/engine/checkers/static/rls-migrations.js +324 -0
  18. package/dist/engine/checkers/static/route-inventory.js +31 -0
  19. package/dist/engine/checkers/static/secrets.js +262 -0
  20. package/dist/engine/config.js +54 -0
  21. package/dist/engine/detect.js +110 -0
  22. package/dist/engine/endpoints.js +65 -0
  23. package/dist/engine/i18n.js +189 -0
  24. package/dist/engine/net/http.js +108 -0
  25. package/dist/engine/report.js +219 -0
  26. package/dist/engine/scan.js +53 -0
  27. package/dist/engine/types.js +1 -0
  28. package/dist/engine/util/color.js +17 -0
  29. package/dist/engine/util/mask.js +66 -0
  30. package/dist/engine/util/text.js +50 -0
  31. package/dist/engine/version.js +12 -0
  32. package/dist/engine/walk.js +86 -0
  33. package/dist/orchestrator/flow.js +116 -0
  34. package/package.json +46 -0
@@ -0,0 +1,146 @@
1
+ import { isErr, request, sleep, unreliable } from '../../net/http.js';
2
+ import { looksLikePlaceholder } from '../../util/text.js';
3
+ const COMMON_COLLECTIONS = [
4
+ 'users', 'user', 'profiles', 'accounts', 'messages', 'chats', 'posts',
5
+ 'orders', 'payments', 'products', 'items', 'settings', 'admin', 'config',
6
+ ];
7
+ /** Extract Firebase project identifiers from client config in the source. */
8
+ export function discoverFirebase(all) {
9
+ // Docs/examples must not contribute hosts we would then send requests to.
10
+ const files = all.filter((f) => !/\.(md|txt|mdx|rst)$/i.test(f.rel));
11
+ let projectId;
12
+ let databaseURL;
13
+ let storageBucket;
14
+ for (const f of files) {
15
+ projectId ??= f.content.match(/projectId\s*:\s*["']([^"']+)["']/)?.[1];
16
+ databaseURL ??= f.content.match(/databaseURL\s*:\s*["']([^"']+)["']/)?.[1]
17
+ ?? f.content.match(/https:\/\/[a-z0-9-]+(?:-default-rtdb)?\.firebaseio\.com/)?.[0];
18
+ storageBucket ??= f.content.match(/storageBucket\s*:\s*["']([^"']+)["']/)?.[1];
19
+ if (!projectId) {
20
+ const dom = f.content.match(/([a-z0-9-]+)\.firebaseapp\.com/)?.[1];
21
+ if (dom)
22
+ projectId = dom;
23
+ }
24
+ }
25
+ if (!projectId || looksLikePlaceholder(projectId))
26
+ return null;
27
+ // Only keep hosts that belong to the project we will name in the consent prompt.
28
+ if (databaseURL && !databaseURL.includes(projectId))
29
+ databaseURL = undefined;
30
+ if (storageBucket && !storageBucket.includes(projectId))
31
+ storageBucket = undefined;
32
+ return { projectId, databaseURL, storageBucket };
33
+ }
34
+ /** Only a parsable, non-empty JSON payload proves anonymous read access. */
35
+ function hasJsonData(body) {
36
+ try {
37
+ const v = JSON.parse(body);
38
+ if (v === null || v === undefined)
39
+ return false;
40
+ if (Array.isArray(v))
41
+ return v.length > 0;
42
+ if (typeof v === 'object') {
43
+ const o = v;
44
+ if ('error' in o)
45
+ return false;
46
+ return Object.keys(o).length > 0;
47
+ }
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function hasFirestoreDocs(body) {
55
+ try {
56
+ const v = JSON.parse(body);
57
+ return !v.error && Array.isArray(v.documents) && v.documents.length > 0;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function hasStorageObjects(body) {
64
+ try {
65
+ const v = JSON.parse(body);
66
+ return !v.error && ((Array.isArray(v.items) && v.items.length > 0) || (Array.isArray(v.prefixes) && v.prefixes.length > 0));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Probe Firebase RTDB, Firestore and Storage for anonymous read access. */
73
+ export async function probeFirebase(opts) {
74
+ const { creds } = opts;
75
+ const rl = opts.rateLimitMs ?? 120;
76
+ const log = opts.log ?? (() => { });
77
+ const findings = [];
78
+ let attempts = 0;
79
+ let errors = 0;
80
+ // 1. Realtime Database: the root .json endpoint.
81
+ const rtdbBase = creds.databaseURL?.replace(/\/$/, '') ?? `https://${creds.projectId}-default-rtdb.firebaseio.com`;
82
+ await sleep(rl);
83
+ const rtdb = await request(`${rtdbBase}/.json?shallow=true`);
84
+ attempts++;
85
+ if (unreliable(rtdb) || (!isErr(rtdb) && rtdb.status >= 300 && rtdb.status < 400))
86
+ errors++;
87
+ if (!isErr(rtdb) && rtdb.status === 200 && hasJsonData(rtdb.body)) {
88
+ findings.push({
89
+ id: 'firebase_rtdb_open',
90
+ severity: 'critical',
91
+ title: 'Realtime Database is readable without auth',
92
+ detail: `${rtdbBase}/.json returned data to an unauthenticated request — the database rules are wide open.`,
93
+ fix: 'Set RTDB rules to require auth and ownership, e.g. ".read": "auth != null && auth.uid === $uid".',
94
+ checker: 'firebase-probe',
95
+ level: 2,
96
+ endpoint: `GET ${rtdbBase}/.json`,
97
+ });
98
+ }
99
+ // 2. Firestore: probe common collection names.
100
+ const readable = [];
101
+ for (const col of COMMON_COLLECTIONS) {
102
+ await sleep(rl);
103
+ const res = await request(`https://firestore.googleapis.com/v1/projects/${creds.projectId}/databases/(default)/documents/${col}?pageSize=1`);
104
+ attempts++;
105
+ if (unreliable(res) || (!isErr(res) && res.status >= 300 && res.status < 400))
106
+ errors++;
107
+ if (!isErr(res) && res.status === 200 && hasFirestoreDocs(res.body)) {
108
+ readable.push(col);
109
+ }
110
+ }
111
+ if (readable.length > 0) {
112
+ findings.push({
113
+ id: 'firebase_firestore_open',
114
+ severity: 'critical',
115
+ title: 'Firestore collections readable without auth',
116
+ detail: `Anonymous reads succeeded on: ${readable.join(', ')}. Firestore rules allow public reads.`,
117
+ fix: 'Tighten firestore.rules: match /{doc=**} { allow read: if request.auth != null && ...owner check... }',
118
+ checker: 'firebase-probe',
119
+ level: 2,
120
+ endpoint: `GET firestore/${readable[0]}`,
121
+ });
122
+ }
123
+ // 3. Storage bucket object listing.
124
+ const bucket = creds.storageBucket ?? `${creds.projectId}.appspot.com`;
125
+ await sleep(rl);
126
+ const storage = await request(`https://firebasestorage.googleapis.com/v0/b/${bucket}/o`);
127
+ attempts++;
128
+ if (unreliable(storage) || (!isErr(storage) && storage.status >= 300 && storage.status < 400))
129
+ errors++;
130
+ if (!isErr(storage) && storage.status === 200 && hasStorageObjects(storage.body)) {
131
+ findings.push({
132
+ id: 'firebase_storage_open',
133
+ severity: 'critical',
134
+ title: 'Storage bucket is listable without auth',
135
+ detail: `Objects in ${bucket} can be listed anonymously.`,
136
+ fix: 'Set Storage rules to require auth: match /{path=**} { allow read: if request.auth != null; }',
137
+ checker: 'firebase-probe',
138
+ level: 2,
139
+ endpoint: `GET storage/${bucket}`,
140
+ });
141
+ }
142
+ log(`Firebase: probed RTDB, ${COMMON_COLLECTIONS.length} Firestore collections, storage bucket ${bucket}`);
143
+ const status = errors === 0 ? 'completed' : errors < attempts ? 'partial' : 'failed';
144
+ const note = errors > 0 ? `${errors}/${attempts} requests errored` : undefined;
145
+ return { findings, run: { id: 'firebase-probe', level: 2, status, note } };
146
+ }
@@ -0,0 +1,249 @@
1
+ import { decodeJwtPayload } from '../../util/text.js';
2
+ import { isErr, request, sleep, unreliable } from '../../net/http.js';
3
+ const URL_ASSIGN = /(?:NEXT_PUBLIC_|VITE_|PUBLIC_)?SUPABASE(?:_PUBLIC)?_URL\s*[:=]\s*["'`]?(https?:\/\/[^"'`\s]+)/i;
4
+ const ANON_ASSIGN = /(?:NEXT_PUBLIC_|VITE_|PUBLIC_)?SUPABASE_(?:ANON|PUBLISHABLE)_KEY\s*[:=]\s*["'`]?([A-Za-z0-9._-]{20,})/i;
5
+ export function classifyKey(key) {
6
+ if (key.startsWith('sb_publishable_'))
7
+ return 'publishable';
8
+ if (key.startsWith('sb_secret_'))
9
+ return 'secret';
10
+ const payload = decodeJwtPayload(key);
11
+ const role = payload?.['role'];
12
+ if (role === 'anon')
13
+ return 'jwt-anon';
14
+ if (role === 'authenticated')
15
+ return 'jwt-authenticated';
16
+ if (role === 'service_role')
17
+ return 'jwt-service';
18
+ return 'unknown';
19
+ }
20
+ /** Find a Supabase URL + a *public* key (anon JWT or publishable) in the project. */
21
+ export function discoverSupabase(files) {
22
+ // Skip docs when discovering credentials so we don't mix an example URL with a real key.
23
+ const scannable = files.filter((f) => !f.rel.endsWith('.md') && !f.rel.endsWith('.txt'));
24
+ let url;
25
+ let anonKey;
26
+ let source;
27
+ for (const f of scannable) {
28
+ const u = f.content.match(URL_ASSIGN)?.[1];
29
+ if (u && !url) {
30
+ url = u;
31
+ source = f.rel;
32
+ }
33
+ const k = f.content.match(ANON_ASSIGN)?.[1];
34
+ if (k && !anonKey && ['publishable', 'jwt-anon'].includes(classifyKey(k)))
35
+ anonKey = k;
36
+ if (url && anonKey)
37
+ break;
38
+ }
39
+ if (!url) {
40
+ for (const f of scannable) {
41
+ const m = f.content.match(/https:\/\/[a-z0-9]{16,}\.supabase\.co/);
42
+ if (m) {
43
+ url = m[0];
44
+ source ??= f.rel;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ if (!anonKey) {
50
+ // Only a genuine anon key or publishable key — never authenticated/service.
51
+ outer: for (const f of scannable) {
52
+ for (const m of f.content.matchAll(/\bsb_publishable_[A-Za-z0-9_-]{10,}\b/g)) {
53
+ anonKey = m[0];
54
+ break outer;
55
+ }
56
+ for (const m of f.content.matchAll(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g)) {
57
+ if (classifyKey(m[0]) === 'jwt-anon') {
58
+ anonKey = m[0];
59
+ break outer;
60
+ }
61
+ }
62
+ }
63
+ }
64
+ if (!url || !anonKey)
65
+ return null;
66
+ return {
67
+ url: url.replace(/\/+$/, ''),
68
+ anonKey,
69
+ keyKind: classifyKey(anonKey) === 'publishable' ? 'publishable' : 'jwt-anon',
70
+ source,
71
+ };
72
+ }
73
+ // Word-ish matching on the table name: "postcards" must not match "card",
74
+ // "authors" must not match "auth", but "api_keys" and "ssn_records" must hit.
75
+ const SENSITIVE_WORDS = [
76
+ 'user', 'users', 'account', 'accounts', 'payment', 'payments', 'order', 'orders',
77
+ 'subscription', 'subscriptions', 'auth', 'session', 'sessions', 'email', 'emails',
78
+ 'customer', 'customers', 'profile', 'profiles', 'token', 'tokens', 'secret', 'secrets',
79
+ 'credential', 'credentials', 'key', 'keys', 'apikey', 'apikeys', 'invoice', 'invoices',
80
+ 'billing', 'address', 'addresses', 'phone', 'phones', 'card', 'cards', 'password',
81
+ 'passwords', 'member', 'members', 'contact', 'contacts', 'message', 'messages', 'chat',
82
+ 'chats', 'kyc', 'passport', 'ssn', 'pii', 'salary', 'salaries', 'payroll', 'health',
83
+ 'medical', 'patient', 'patients', 'private', 'identity', 'identities', 'wallet', 'transaction', 'transactions',
84
+ ];
85
+ const SENSITIVE_SET = new Set(SENSITIVE_WORDS);
86
+ function severityForTable(table) {
87
+ const words = table.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
88
+ return words.some((w) => SENSITIVE_SET.has(w) || SENSITIVE_SET.has(w.replace(/s$/, ''))) ? 'critical' : 'warning';
89
+ }
90
+ function authHeaders(key) {
91
+ return { apikey: key, Authorization: `Bearer ${key}` };
92
+ }
93
+ function curlEvidence(url, table) {
94
+ return `curl '${url}/rest/v1/${table}?select=*&limit=3' -H 'apikey: <ANON_KEY>' -H 'Authorization: Bearer <ANON_KEY>'`;
95
+ }
96
+ function parseCount(headers) {
97
+ const cr = headers.get('content-range');
98
+ if (!cr)
99
+ return null;
100
+ const total = cr.split('/').pop();
101
+ if (!total || total === '*')
102
+ return null;
103
+ const n = parseInt(total, 10);
104
+ return Number.isNaN(n) ? null : n;
105
+ }
106
+ async function enumerate(creds) {
107
+ const res = await request(`${creds.url}/rest/v1/`, { headers: authHeaders(creds.anonKey) });
108
+ if (isErr(res))
109
+ return { error: res.error };
110
+ if (res.status === 401 || res.status === 403)
111
+ return { error: `anon key rejected (HTTP ${res.status})` };
112
+ if (res.status >= 400)
113
+ return { error: `PostgREST returned HTTP ${res.status}` };
114
+ let spec;
115
+ try {
116
+ spec = JSON.parse(res.body);
117
+ }
118
+ catch {
119
+ return { error: 'PostgREST did not return an OpenAPI document' };
120
+ }
121
+ const schemas = { ...(spec.definitions ?? {}), ...(spec.components?.schemas ?? {}) };
122
+ let tables = Object.keys(schemas);
123
+ const paths = Object.keys(spec.paths ?? {});
124
+ if (tables.length === 0) {
125
+ tables = paths.filter((p) => /^\/[^/{}]+$/.test(p) && p !== '/rpc' && !p.startsWith('/rpc/')).map((p) => p.slice(1));
126
+ }
127
+ const rpc = paths.filter((p) => p.startsWith('/rpc/')).map((p) => p.slice('/rpc/'.length)).filter(Boolean);
128
+ return { tables, rpc };
129
+ }
130
+ /**
131
+ * Actively probe a Supabase project with its public key — the same access any
132
+ * visitor's browser has. READ-ONLY: it never writes. Returns the findings and a
133
+ * status so a failed/partial probe is never reported as a clean result.
134
+ */
135
+ export async function probeSupabase(opts) {
136
+ const { creds } = opts;
137
+ const rl = opts.rateLimitMs ?? 120;
138
+ const log = opts.log ?? (() => { });
139
+ const findings = [];
140
+ // Only a genuine public key proves anything about anonymous access. Reject
141
+ // service/secret keys (bypass RLS) AND authenticated/unknown keys (not anon).
142
+ const kind = classifyKey(creds.anonKey);
143
+ if (kind !== 'jwt-anon' && kind !== 'publishable') {
144
+ return {
145
+ findings: [{
146
+ id: 'supabase_key_not_public',
147
+ severity: 'warning',
148
+ title: 'Supabase probe skipped — key is not a public anon key',
149
+ detail: `The provided key is "${kind}". Only a public anon (or publishable) key proves anything about anonymous access; probing with anything else is meaningless or unsafe.`,
150
+ fix: 'Re-run with the public anon (or publishable) key. Keep service/secret keys server-side only.',
151
+ checker: 'supabase-probe',
152
+ level: 2,
153
+ }],
154
+ run: { id: 'supabase-probe', level: 2, status: 'skipped', note: `non-anon key (${kind})` },
155
+ };
156
+ }
157
+ const enumerated = await enumerate(creds);
158
+ if ('error' in enumerated) {
159
+ return {
160
+ findings: [],
161
+ run: { id: 'supabase-probe', level: 2, status: 'failed', note: enumerated.error },
162
+ };
163
+ }
164
+ const { tables, rpc } = enumerated;
165
+ log(`Supabase: ${tables.length} table(s), ${rpc.length} rpc function(s) exposed to PostgREST`);
166
+ let errored = 0;
167
+ for (const table of tables) {
168
+ await sleep(rl);
169
+ // HEAD + count=exact returns only the row count in a header — no data pulled.
170
+ const res = await request(`${creds.url}/rest/v1/${encodeURIComponent(table)}?select=*`, {
171
+ method: 'HEAD',
172
+ headers: { ...authHeaders(creds.anonKey), Prefer: 'count=exact', Range: '0-0', 'Range-Unit': 'items' },
173
+ });
174
+ // 5xx/429/timeout AND 3xx (a login redirect) mean we learned nothing here.
175
+ if (isErr(res) || res.status === 429 || res.status >= 500 || (res.status >= 300 && res.status < 400)) {
176
+ errored++;
177
+ continue;
178
+ }
179
+ if (res.status === 200 || res.status === 206) {
180
+ const count = parseCount(res.headers);
181
+ if (count === null) {
182
+ errored++;
183
+ continue;
184
+ } // no usable count → inconclusive, not proof
185
+ if (count > 0) {
186
+ const sev = severityForTable(table);
187
+ findings.push({
188
+ id: 'supabase_anon_read',
189
+ severity: sev,
190
+ title: `Table "${table}" is readable by anyone`,
191
+ detail: `The public key can read ${count} row(s) from "${table}".` +
192
+ (sev === 'critical'
193
+ ? ' The name suggests private/PII/financial data — if so, this is a serious leak.'
194
+ : ' If this table is public content (e.g. products/articles) this may be intended — confirm.'),
195
+ fix: `Enable RLS and make sure no permissive policy grants anon access: ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY; then DROP any "USING (true)" policy and add an owner/tenant policy (a new policy is OR-ed with existing ones, so a permissive policy left in place keeps access open).`,
196
+ checker: 'supabase-probe',
197
+ level: 2,
198
+ endpoint: `GET /rest/v1/${table}`,
199
+ evidence: curlEvidence(creds.url, table),
200
+ });
201
+ }
202
+ }
203
+ }
204
+ // Storage buckets listable by anon.
205
+ await sleep(rl);
206
+ const buckets = await request(`${creds.url}/storage/v1/bucket`, { headers: authHeaders(creds.anonKey) });
207
+ let storageErrored = unreliable(buckets);
208
+ if (!isErr(buckets) && buckets.status === 200) {
209
+ try {
210
+ const parsed = JSON.parse(buckets.body);
211
+ const list = (Array.isArray(parsed) ? parsed : parsed?.buckets);
212
+ if (!Array.isArray(list))
213
+ throw new Error('unrecognized bucket listing');
214
+ if (list.length > 0) {
215
+ const objs = list.map((b) => (typeof b === 'string' ? { name: b } : b));
216
+ const publicOnes = objs.filter((b) => b.public).map((b) => b.name ?? b.id ?? '(unnamed)').join(', ');
217
+ findings.push({
218
+ id: 'supabase_bucket_listing',
219
+ severity: publicOnes ? 'critical' : 'warning',
220
+ title: 'Storage buckets are listable by anyone',
221
+ detail: `The public key can list ${list.length} storage bucket(s)${publicOnes ? `; public: ${publicOnes}` : ''}.`,
222
+ fix: 'Restrict bucket listing and mark buckets private unless public access is intentional; add storage RLS policies.',
223
+ checker: 'supabase-probe',
224
+ level: 2,
225
+ endpoint: 'GET /storage/v1/bucket',
226
+ });
227
+ }
228
+ }
229
+ catch {
230
+ storageErrored = true;
231
+ } // a 200 we cannot parse is a lost sub-check
232
+ }
233
+ if (rpc.length > 0) {
234
+ findings.push({
235
+ id: 'supabase_rpc_exposed',
236
+ severity: 'advisory',
237
+ title: `${rpc.length} RPC function(s) exposed`,
238
+ detail: `PostgREST exposes RPC: ${rpc.slice(0, 12).join(', ')}${rpc.length > 12 ? ' …' : ''}. Review that each checks the caller's identity.`,
239
+ fix: 'Ensure SECURITY DEFINER functions verify auth.uid() internally and are not callable by anon when they should not be.',
240
+ checker: 'supabase-probe',
241
+ level: 2,
242
+ });
243
+ }
244
+ const attempted = tables.length + 1; // tables + storage
245
+ const totalErr = errored + (storageErrored ? 1 : 0);
246
+ const status = totalErr === 0 ? 'completed' : totalErr < attempted ? 'partial' : 'failed';
247
+ const note = totalErr > 0 ? `${totalErr}/${attempted} probe requests errored (5xx/429/timeout)` : undefined;
248
+ return { findings, run: { id: 'supabase-probe', level: 2, status, note } };
249
+ }
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ function run(cmd, args, cwd, timeoutMs) {
3
+ return new Promise((resolve) => {
4
+ execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
5
+ // audit tools exit non-zero when vulnerabilities are found; keep stdout.
6
+ const e = err;
7
+ const failedToSpawn = !!e && (e.code === 'ENOENT' || e.killed === true);
8
+ const code = e && typeof e.code === 'number' ? e.code : e ? 1 : 0;
9
+ resolve({ stdout: stdout || '', code, failedToSpawn });
10
+ });
11
+ });
12
+ }
13
+ /**
14
+ * Level 1: dependency vulnerability audit via the project's package manager.
15
+ * Returns a status so an audit that could not run (offline, missing tool,
16
+ * registry error) is never reported as "no vulnerabilities".
17
+ */
18
+ export async function auditDeps(root, packageManagers, timeoutMs = 60000) {
19
+ const pm = packageManagers.includes('pnpm')
20
+ ? 'pnpm'
21
+ : packageManagers.includes('yarn')
22
+ ? 'yarn'
23
+ : packageManagers.includes('bun')
24
+ ? 'bun'
25
+ : 'npm';
26
+ if (pm === 'bun') {
27
+ return {
28
+ findings: [],
29
+ run: { id: 'deps', level: 1, status: 'unsupported', note: 'bun audit is not yet supported — run `bun audit` manually' },
30
+ };
31
+ }
32
+ const failed = (note) => ({
33
+ findings: [],
34
+ run: { id: 'deps', level: 1, status: 'failed', note },
35
+ });
36
+ const res = await run(pm, ['audit', '--json'], root, timeoutMs);
37
+ if (res.failedToSpawn)
38
+ return failed(`${pm} not found or timed out`);
39
+ if (!res.stdout.trim())
40
+ return failed(`${pm} audit produced no output (offline or no lockfile?)`);
41
+ let counts;
42
+ let vulnMap = {};
43
+ if (pm === 'yarn') {
44
+ let summary;
45
+ for (const line of res.stdout.split('\n')) {
46
+ const s = line.trim();
47
+ if (!s.startsWith('{'))
48
+ continue;
49
+ try {
50
+ const obj = JSON.parse(s);
51
+ if (obj.type === 'auditSummary' && obj.data?.vulnerabilities)
52
+ summary = obj.data.vulnerabilities;
53
+ }
54
+ catch { /* skip */ }
55
+ }
56
+ if (!summary)
57
+ return failed('could not parse yarn audit output');
58
+ counts = summary;
59
+ }
60
+ else {
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(res.stdout);
64
+ }
65
+ catch {
66
+ const line = res.stdout.trim().split('\n').filter(Boolean).pop() ?? '';
67
+ try {
68
+ parsed = JSON.parse(line);
69
+ }
70
+ catch {
71
+ return failed('could not parse audit output');
72
+ }
73
+ }
74
+ // A valid JSON error envelope (e.g. registry unavailable) is NOT "clean".
75
+ if (parsed.error !== undefined || !parsed.metadata?.vulnerabilities) {
76
+ return failed('audit returned an error or an unrecognized shape');
77
+ }
78
+ counts = parsed.metadata.vulnerabilities;
79
+ vulnMap = parsed.vulnerabilities ?? {};
80
+ }
81
+ const critical = counts.critical ?? 0;
82
+ const high = counts.high ?? 0;
83
+ const moderate = counts.moderate ?? 0;
84
+ const low = counts.low ?? 0;
85
+ const total = counts.total ?? critical + high + moderate + low;
86
+ const findings = [];
87
+ if (total > 0) {
88
+ const severity = critical + high > 0 ? 'critical' : moderate > 0 ? 'warning' : 'info';
89
+ findings.push({
90
+ id: 'deps_vulnerabilities',
91
+ severity,
92
+ title: `${total} vulnerable dependenc${total === 1 ? 'y' : 'ies'}`,
93
+ detail: `${pm} audit: ${critical} critical, ${high} high, ${moderate} moderate, ${low} low.`,
94
+ fix: `Run \`${pm} audit${pm === 'npm' ? ' fix' : ''}\` and upgrade the flagged packages; check breaking changes.`,
95
+ checker: 'deps',
96
+ level: 1,
97
+ });
98
+ const named = Object.values(vulnMap)
99
+ .filter((v) => v.severity === 'critical' || v.severity === 'high')
100
+ .map((v) => v.name)
101
+ .filter(Boolean)
102
+ .slice(0, 8);
103
+ if (named.length) {
104
+ findings.push({
105
+ id: 'deps_top_packages',
106
+ severity: 'info',
107
+ title: 'High/critical packages to upgrade',
108
+ detail: named.join(', '),
109
+ fix: 'Upgrade these first; they carry the most severe advisories.',
110
+ checker: 'deps',
111
+ level: 1,
112
+ });
113
+ }
114
+ }
115
+ // "completed" with zero findings means genuinely no known vulns — the status,
116
+ // not an info finding, records that the check ran cleanly.
117
+ return { findings, run: { id: 'deps', level: 1, status: 'completed' } };
118
+ }
@@ -0,0 +1,15 @@
1
+ import { secretsChecker } from './static/secrets.js';
2
+ import { clientExposureChecker } from './static/client-exposure.js';
3
+ import { configRisksChecker } from './static/config-risks.js';
4
+ import { rlsMigrationsChecker } from './static/rls-migrations.js';
5
+ import { envGitChecker } from './static/env-git.js';
6
+ import { routeInventoryChecker } from './static/route-inventory.js';
7
+ /** Level 0 checkers: read-only static analysis, no install, any stack. */
8
+ export const staticCheckers = [
9
+ secretsChecker,
10
+ clientExposureChecker,
11
+ configRisksChecker,
12
+ rlsMigrationsChecker,
13
+ envGitChecker,
14
+ routeInventoryChecker,
15
+ ];
@@ -0,0 +1,72 @@
1
+ import { concretePath } from '../../endpoints.js';
2
+ import { isErr, request, sleep } from '../../net/http.js';
3
+ /** Real, non-empty JSON payload — `[]`, `{}` and `{"error":...}` are not data. */
4
+ function looksLikeData(body) {
5
+ const t = body.trim();
6
+ if (t.length < 2 || !(t.startsWith('{') || t.startsWith('[')))
7
+ return false;
8
+ try {
9
+ const v = JSON.parse(t);
10
+ if (Array.isArray(v))
11
+ return v.length > 0;
12
+ if (v && typeof v === 'object') {
13
+ const o = v;
14
+ if ('error' in o || 'errors' in o)
15
+ return false;
16
+ return Object.keys(o).length > 0;
17
+ }
18
+ return false;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /**
25
+ * Hit each GET-able endpoint with no authentication. A 200 with JSON data is a
26
+ * candidate "no access control" hole — reported as a warning to confirm, since
27
+ * some endpoints are legitimately public.
28
+ */
29
+ export async function probeEndpointsUnauth(appUrl, endpoints, rateLimitMs = 100) {
30
+ const base = appUrl.replace(/\/$/, '');
31
+ const findings = [];
32
+ const candidates = endpoints.filter((e) => e.method === 'GET' || e.method === 'ANY');
33
+ const MAX = 60;
34
+ const targets = candidates.slice(0, MAX);
35
+ const dropped = candidates.length - targets.length;
36
+ if (targets.length === 0) {
37
+ return { findings, run: { id: 'endpoint-probe', level: 2, status: 'skipped', note: 'no GET endpoints discovered' } };
38
+ }
39
+ let errors = 0;
40
+ for (const e of targets) {
41
+ await sleep(rateLimitMs);
42
+ const path = concretePath(e.path).replace(/^\/?/, '/');
43
+ const res = await request(base + path, { headers: { accept: 'application/json' } });
44
+ if (isErr(res) || res.status === 429 || res.status >= 500 || (res.status >= 300 && res.status < 400)) {
45
+ errors++;
46
+ continue;
47
+ }
48
+ if (res.status !== 200)
49
+ continue;
50
+ if (!looksLikeData(res.body))
51
+ continue;
52
+ findings.push({
53
+ id: 'endpoint_no_auth',
54
+ severity: 'warning',
55
+ title: `Endpoint returns data without authentication`,
56
+ detail: `GET ${path} responded 200 with JSON to an unauthenticated request. Confirm this endpoint is meant to be public.`,
57
+ fix: 'Require a session/token check on this route (middleware or an explicit guard) if the data is not meant to be public.',
58
+ checker: 'endpoint-probe',
59
+ level: 2,
60
+ endpoint: `GET ${path}`,
61
+ evidence: `curl '${base}${path}'`,
62
+ });
63
+ }
64
+ const status = errors >= targets.length ? 'failed' : errors > 0 || dropped > 0 ? 'partial' : 'completed';
65
+ const notes = [];
66
+ if (errors > 0)
67
+ notes.push(`${errors}/${targets.length} endpoint requests errored`);
68
+ if (dropped > 0)
69
+ notes.push(`only ${MAX}/${candidates.length} endpoints probed (cap)`);
70
+ const note = notes.length ? notes.join('; ') : undefined;
71
+ return { findings, run: { id: 'endpoint-probe', level: 2, status, note } };
72
+ }