easyvibegate 0.4.4 → 0.6.0

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.
@@ -1,7 +1,11 @@
1
1
  import { decodeJwtPayload } from '../../util/text.js';
2
- import { isErr, request, sleep, unreliable } from '../../net/http.js';
2
+ import { classifyBody, isErr, request, sleep } from '../../net/http.js';
3
3
  const URL_ASSIGN = /(?:NEXT_PUBLIC_|VITE_|PUBLIC_)?SUPABASE(?:_PUBLIC)?_URL\s*[:=]\s*["'`]?(https?:\/\/[^"'`\s]+)/i;
4
4
  const ANON_ASSIGN = /(?:NEXT_PUBLIC_|VITE_|PUBLIC_)?SUPABASE_(?:ANON|PUBLISHABLE)_KEY\s*[:=]\s*["'`]?([A-Za-z0-9._-]{20,})/i;
5
+ // Docs and templates hold example values, not the running config.
6
+ const NOT_CONFIG = /\.(md|mdx|txt|rst)$|\.(example|sample|template|dist)$/i;
7
+ // Next.js load order for a production build — the most specific file wins.
8
+ const ENV_ORDER = ['.env.production.local', '.env.local', '.env.production', '.env'];
5
9
  export function classifyKey(key) {
6
10
  if (key.startsWith('sb_publishable_'))
7
11
  return 'publishable';
@@ -17,58 +21,58 @@ export function classifyKey(key) {
17
21
  return 'jwt-service';
18
22
  return 'unknown';
19
23
  }
20
- /** Find a Supabase URL + a *public* key (anon JWT or publishable) in the project. */
24
+ const BARE_URL = /https:\/\/[a-z0-9]{16,}\.supabase\.co/;
25
+ const BARE_PUBLISHABLE = /\bsb_publishable_[A-Za-z0-9_-]{10,}\b/;
26
+ const BARE_JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
27
+ function isPublicKey(key) {
28
+ const kind = classifyKey(key);
29
+ return kind === 'jwt-anon' || kind === 'publishable';
30
+ }
31
+ /**
32
+ * The URL and *public* key ONE file declares — an explicit assignment first, a
33
+ * bare literal as fallback. Never an authenticated/service key.
34
+ */
35
+ function credsInFile(content) {
36
+ const url = content.match(URL_ASSIGN)?.[1] ?? content.match(BARE_URL)?.[0];
37
+ let key = content.match(ANON_ASSIGN)?.[1];
38
+ if (key && !isPublicKey(key))
39
+ key = undefined;
40
+ key ??= content.match(BARE_PUBLISHABLE)?.[0]
41
+ ?? [...content.matchAll(BARE_JWT)].map((m) => m[0]).find((k) => classifyKey(k) === 'jwt-anon');
42
+ return { url, key };
43
+ }
44
+ function envRank(rel) {
45
+ const i = ENV_ORDER.indexOf(rel.split('/').pop() ?? rel);
46
+ return i === -1 ? ENV_ORDER.length : i;
47
+ }
48
+ /**
49
+ * Find a Supabase URL + a *public* key (anon JWT or publishable) in the project.
50
+ * URL and key are paired by provenance: a file holding both wins (env files in
51
+ * load order first). Only when no single file has both do we fall back to a
52
+ * URL from one file and a key from another — flagged `ambiguous`, because the
53
+ * two may belong to different environments and must never be presented as one
54
+ * discovered config.
55
+ */
21
56
  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 {
57
+ const scannable = files
58
+ .filter((f) => !NOT_CONFIG.test(f.rel))
59
+ .map((f) => ({ rel: f.rel, ...credsInFile(f.content) }))
60
+ .sort((a, b) => envRank(a.rel) - envRank(b.rel)); // stable: ties keep scan order
61
+ const make = (url, anonKey, source, ambiguous) => ({
67
62
  url: url.replace(/\/+$/, ''),
68
63
  anonKey,
69
64
  keyKind: classifyKey(anonKey) === 'publishable' ? 'publishable' : 'jwt-anon',
70
65
  source,
71
- };
66
+ ...(ambiguous ? { ambiguous } : {}),
67
+ });
68
+ const paired = scannable.find((f) => f.url && f.key);
69
+ if (paired?.url && paired.key)
70
+ return make(paired.url, paired.key, paired.rel);
71
+ const urlFile = scannable.find((f) => f.url);
72
+ const keyFile = scannable.find((f) => f.key);
73
+ if (!urlFile?.url || !keyFile?.key)
74
+ return null;
75
+ return make(urlFile.url, keyFile.key, `${urlFile.rel} (URL) + ${keyFile.rel} (key)`, true);
72
76
  }
73
77
  // Word-ish matching on the table name: "postcards" must not match "card",
74
78
  // "authors" must not match "auth", but "api_keys" and "ssn_records" must hit.
@@ -103,21 +107,33 @@ function parseCount(headers) {
103
107
  const n = parseInt(total, 10);
104
108
  return Number.isNaN(n) ? null : n;
105
109
  }
110
+ function isPlainObject(v) {
111
+ return !!v && typeof v === 'object' && !Array.isArray(v);
112
+ }
113
+ /**
114
+ * PostgREST answers /rest/v1/ with an OpenAPI 2/3 document. Any other JSON
115
+ * (`{"error":…}` from a gateway, a maintenance page's payload) is NOT an empty
116
+ * schema — treating it as one would turn an outage into "zero tables exposed".
117
+ */
118
+ function asOpenApi(v) {
119
+ if (!isPlainObject(v))
120
+ return null;
121
+ const schemas = isPlainObject(v['components']) ? v['components']['schemas'] : undefined;
122
+ if (!isPlainObject(v['definitions']) && !isPlainObject(schemas) && !isPlainObject(v['paths']))
123
+ return null;
124
+ return v;
125
+ }
106
126
  async function enumerate(creds) {
107
127
  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
- }
128
+ const verdict = classifyBody(res, 'json');
129
+ if (verdict.kind === 'denied')
130
+ return { error: `anon key rejected (${verdict.reason})` };
131
+ // Redirect, 5xx, truncated body, HTML, error envelope, empty JSON: the schema was not enumerated.
132
+ if (verdict.kind !== 'data')
133
+ return { error: `could not enumerate the PostgREST schema (${verdict.reason})` };
134
+ const spec = asOpenApi(verdict.json);
135
+ if (!spec)
136
+ return { error: 'PostgREST did not return an OpenAPI document — schema enumeration failed' };
121
137
  const schemas = { ...(spec.definitions ?? {}), ...(spec.components?.schemas ?? {}) };
122
138
  let tables = Object.keys(schemas);
123
139
  const paths = Object.keys(spec.paths ?? {});
@@ -163,7 +179,7 @@ export async function probeSupabase(opts) {
163
179
  }
164
180
  const { tables, rpc } = enumerated;
165
181
  log(`Supabase: ${tables.length} table(s), ${rpc.length} rpc function(s) exposed to PostgREST`);
166
- let errored = 0;
182
+ const lost = []; // sub-checks that produced no verdict, for the note
167
183
  for (const table of tables) {
168
184
  await sleep(rl);
169
185
  // HEAD + count=exact returns only the row count in a header — no data pulled.
@@ -172,14 +188,18 @@ export async function probeSupabase(opts) {
172
188
  headers: { ...authHeaders(creds.anonKey), Prefer: 'count=exact', Range: '0-0', 'Range-Unit': 'items' },
173
189
  });
174
190
  // 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++;
191
+ if (isErr(res)) {
192
+ lost.push(`${table} (${res.error})`);
193
+ continue;
194
+ }
195
+ if (res.status === 429 || res.status >= 500 || (res.status >= 300 && res.status < 400)) {
196
+ lost.push(`${table} (HTTP ${res.status})`);
177
197
  continue;
178
198
  }
179
199
  if (res.status === 200 || res.status === 206) {
180
200
  const count = parseCount(res.headers);
181
201
  if (count === null) {
182
- errored++;
202
+ lost.push(`${table} (no content-range)`);
183
203
  continue;
184
204
  } // no usable count → inconclusive, not proof
185
205
  if (count > 0) {
@@ -204,31 +224,31 @@ export async function probeSupabase(opts) {
204
224
  // Storage buckets listable by anon.
205
225
  await sleep(rl);
206
226
  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
- }
227
+ // One classifier for every probe: a redirect, a truncated or non-JSON 200, an
228
+ // error envelope — none of them says "no buckets". 401/403/404 and `[]` do.
229
+ const bv = classifyBody(buckets, 'json');
230
+ if (bv.kind === 'unknown')
231
+ lost.push(`storage (${bv.reason})`);
232
+ if (bv.kind === 'data') {
233
+ const parsed = bv.json;
234
+ const list = (Array.isArray(parsed) ? parsed : parsed?.buckets);
235
+ if (!Array.isArray(list)) {
236
+ lost.push('storage (unrecognized bucket listing)'); // a 200 we cannot read is a lost sub-check
237
+ }
238
+ else if (list.length > 0) {
239
+ const objs = list.map((b) => (typeof b === 'string' ? { name: b } : b));
240
+ const publicOnes = objs.filter((b) => b.public).map((b) => b.name ?? b.id ?? '(unnamed)').join(', ');
241
+ findings.push({
242
+ id: 'supabase_bucket_listing',
243
+ severity: publicOnes ? 'critical' : 'warning',
244
+ title: 'Storage buckets are listable by anyone',
245
+ detail: `The public key can list ${list.length} storage bucket(s)${publicOnes ? `; public: ${publicOnes}` : ''}.`,
246
+ fix: 'Restrict bucket listing and mark buckets private unless public access is intentional; add storage RLS policies.',
247
+ checker: 'supabase-probe',
248
+ level: 2,
249
+ endpoint: 'GET /storage/v1/bucket',
250
+ });
228
251
  }
229
- catch {
230
- storageErrored = true;
231
- } // a 200 we cannot parse is a lost sub-check
232
252
  }
233
253
  if (rpc.length > 0) {
234
254
  findings.push({
@@ -242,8 +262,10 @@ export async function probeSupabase(opts) {
242
262
  });
243
263
  }
244
264
  const attempted = tables.length + 1; // tables + storage
245
- const totalErr = errored + (storageErrored ? 1 : 0);
265
+ const totalErr = lost.length;
246
266
  const status = totalErr === 0 ? 'completed' : totalErr < attempted ? 'partial' : 'failed';
247
- const note = totalErr > 0 ? `${totalErr}/${attempted} probe requests errored (5xx/429/timeout)` : undefined;
267
+ const note = totalErr > 0
268
+ ? `${totalErr}/${attempted} probe(s) not verified: ${lost.slice(0, 5).join(', ')}${totalErr > 5 ? ', …' : ''}`
269
+ : undefined;
248
270
  return { findings, run: { id: 'supabase-probe', level: 2, status, note } };
249
271
  }
@@ -1,61 +1,120 @@
1
1
  import { execFile } from 'node:child_process';
2
+ const MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
2
3
  function run(cmd, args, cwd, timeoutMs) {
3
4
  return new Promise((resolve) => {
4
5
  execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
5
6
  // audit tools exit non-zero when vulnerabilities are found; keep stdout.
6
7
  const e = err;
7
8
  const failedToSpawn = !!e && (e.code === 'ENOENT' || e.killed === true);
8
- const code = e && typeof e.code === 'number' ? e.code : e ? 1 : 0;
9
+ const code = e ? (typeof e.code === 'number' ? e.code : null) : 0;
9
10
  resolve({ stdout: stdout || '', code, failedToSpawn });
10
11
  });
11
12
  });
12
13
  }
14
+ /**
15
+ * Exit codes that mean "the audit ran and found something", per tool docs:
16
+ * npm/pnpm exit 1 when vulnerabilities exist; yarn v1 exits with a bitmask of
17
+ * the severities found (1 info … 16 critical, max 31). Anything else is a tool
18
+ * or registry error — a fake pnpm exiting 7 with an empty report once counted
19
+ * as a clean `completed` run.
20
+ */
21
+ function isVulnsFoundExit(pm, code) {
22
+ if (pm === 'yarn')
23
+ return code >= 1 && code <= 31;
24
+ return code === 1;
25
+ }
26
+ const SEVERITY_FIELDS = ['info', 'low', 'moderate', 'high', 'critical'];
27
+ /**
28
+ * Accept only a report whose severity counters are all present and numeric,
29
+ * with `total` (when present) equal to their sum. `{}` or a missing field is
30
+ * an unparseable report, never "0 vulnerabilities".
31
+ */
32
+ function validCounts(v) {
33
+ if (typeof v !== 'object' || v === null)
34
+ return null;
35
+ const o = v;
36
+ const out = {};
37
+ for (const k of SEVERITY_FIELDS) {
38
+ const n = o[k];
39
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 0)
40
+ return null;
41
+ out[k] = n;
42
+ }
43
+ const sum = SEVERITY_FIELDS.reduce((a, k) => a + (out[k] ?? 0), 0);
44
+ if (o['total'] !== undefined) {
45
+ if (typeof o['total'] !== 'number' || o['total'] !== sum)
46
+ return null;
47
+ out['total'] = o['total'];
48
+ }
49
+ return out;
50
+ }
13
51
  /**
14
52
  * Level 1: dependency vulnerability audit via the project's package manager.
15
53
  * Returns a status so an audit that could not run (offline, missing tool,
16
- * registry error) is never reported as "no vulnerabilities".
54
+ * registry error, garbage output) is never reported as "no vulnerabilities".
55
+ *
56
+ * `packageManagers` comes from detect(): the declared `packageManager` field
57
+ * first, then lockfile-detected managers in preference order — so [0] decides.
17
58
  */
18
59
  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';
60
+ const known = packageManagers.filter((m) => MANAGERS.has(m));
61
+ const pm = known[0] ?? 'npm';
62
+ const others = [...new Set(known.slice(1))];
26
63
  if (pm === 'bun') {
27
64
  return {
28
65
  findings: [],
29
66
  run: { id: 'deps', level: 1, status: 'unsupported', note: 'bun audit is not yet supported — run `bun audit` manually' },
30
67
  };
31
68
  }
69
+ // Several managers detected (a stale lockfile beside `packageManager`, or
70
+ // two lockfiles): the audit only covers the one we ran. Say so, visibly but
71
+ // not fatally — the audit itself did complete.
72
+ const conflict = others.length
73
+ ? `audited with ${pm}; ${others.join(', ')} lockfile(s)/declaration also present — remove the stale one so the audit covers what is actually installed`
74
+ : undefined;
75
+ const conflictFindings = conflict
76
+ ? [{
77
+ id: 'deps_manager_conflict',
78
+ severity: 'advisory',
79
+ title: 'Several package managers detected',
80
+ detail: `Dependency audit ran with ${pm}, but ${others.join(', ')} is also detected (lockfile or packageManager field).`,
81
+ fix: `Keep one package manager: delete the stale lockfile(s) or fix the \`packageManager\` field, so \`${pm} audit\` reflects the real dependency tree.`,
82
+ checker: 'deps',
83
+ level: 1,
84
+ }]
85
+ : [];
86
+ const withNote = (r) => (conflict ? { ...r, note: r.note ? `${r.note}; ${conflict}` : conflict } : r);
32
87
  const failed = (note) => ({
33
- findings: [],
34
- run: { id: 'deps', level: 1, status: 'failed', note },
88
+ findings: conflictFindings,
89
+ run: withNote({ id: 'deps', level: 1, status: 'failed', note }),
35
90
  });
36
91
  const res = await run(pm, ['audit', '--json'], root, timeoutMs);
37
92
  if (res.failedToSpawn)
38
93
  return failed(`${pm} not found or timed out`);
94
+ if (res.code === null)
95
+ return failed(`${pm} audit was terminated by a signal`);
96
+ // A non-zero exit that is not the documented "vulnerabilities found" code is
97
+ // a tool error, whatever stdout says.
98
+ if (res.code !== 0 && !isVulnsFoundExit(pm, res.code))
99
+ return failed(`${pm} audit exited with code ${res.code}`);
39
100
  if (!res.stdout.trim())
40
101
  return failed(`${pm} audit produced no output (offline or no lockfile?)`);
41
- let counts;
102
+ let counts = null;
42
103
  let vulnMap = {};
43
104
  if (pm === 'yarn') {
44
- let summary;
45
105
  for (const line of res.stdout.split('\n')) {
46
106
  const s = line.trim();
47
107
  if (!s.startsWith('{'))
48
108
  continue;
49
109
  try {
50
110
  const obj = JSON.parse(s);
51
- if (obj.type === 'auditSummary' && obj.data?.vulnerabilities)
52
- summary = obj.data.vulnerabilities;
111
+ if (obj.type === 'auditSummary')
112
+ counts = validCounts(obj.data?.vulnerabilities);
53
113
  }
54
114
  catch { /* skip */ }
55
115
  }
56
- if (!summary)
57
- return failed('could not parse yarn audit output');
58
- counts = summary;
116
+ if (!counts)
117
+ return failed('unparseable audit output (no valid yarn auditSummary)');
59
118
  }
60
119
  else {
61
120
  let parsed;
@@ -68,22 +127,21 @@ export async function auditDeps(root, packageManagers, timeoutMs = 60000) {
68
127
  parsed = JSON.parse(line);
69
128
  }
70
129
  catch {
71
- return failed('could not parse audit output');
130
+ return failed('unparseable audit output (not JSON)');
72
131
  }
73
132
  }
74
133
  // 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');
134
+ if (typeof parsed !== 'object' || parsed === null || parsed.error !== undefined) {
135
+ return failed('audit returned an error envelope');
77
136
  }
78
- counts = parsed.metadata.vulnerabilities;
137
+ counts = validCounts(parsed.metadata?.vulnerabilities);
138
+ if (!counts)
139
+ return failed('unparseable audit output (metadata.vulnerabilities missing or malformed)');
79
140
  vulnMap = parsed.vulnerabilities ?? {};
80
141
  }
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 = [];
142
+ const { critical, high, moderate, low } = counts;
143
+ const total = counts.total ?? critical + high + moderate + low + counts.info;
144
+ const findings = [...conflictFindings];
87
145
  if (total > 0) {
88
146
  const severity = critical + high > 0 ? 'critical' : moderate > 0 ? 'warning' : 'info';
89
147
  findings.push({
@@ -114,5 +172,5 @@ export async function auditDeps(root, packageManagers, timeoutMs = 60000) {
114
172
  }
115
173
  // "completed" with zero findings means genuinely no known vulns — the status,
116
174
  // not an info finding, records that the check ran cleanly.
117
- return { findings, run: { id: 'deps', level: 1, status: 'completed' } };
175
+ return { findings, run: withNote({ id: 'deps', level: 1, status: 'completed' }) };
118
176
  }
@@ -1,30 +1,15 @@
1
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
- }
2
+ import { classifyBody, request, sleep } from '../../net/http.js';
24
3
  /**
25
- * Hit each GET-able endpoint with no authentication. A 200 with JSON data is a
4
+ * Hit each GET-able endpoint with no authentication. A 2xx with JSON data is a
26
5
  * candidate "no access control" hole — reported as a warning to confirm, since
27
6
  * some endpoints are legitimately public.
7
+ *
8
+ * Every response is classified (see classifyBody): only data / empty / denied
9
+ * count as a checked endpoint. A 404 (the inventory guessed the id or the
10
+ * prefix), an HTML page where JSON was expected, a redirect, a truncated or
11
+ * unparseable body all mean the endpoint was NOT verified — the run is
12
+ * `partial` and the note says which ones.
28
13
  */
29
14
  export async function probeEndpointsUnauth(appUrl, endpoints, rateLimitMs = 100) {
30
15
  const base = appUrl.replace(/\/$/, '');
@@ -36,18 +21,27 @@ export async function probeEndpointsUnauth(appUrl, endpoints, rateLimitMs = 100)
36
21
  if (targets.length === 0) {
37
22
  return { findings, run: { id: 'endpoint-probe', level: 2, status: 'skipped', note: 'no GET endpoints discovered' } };
38
23
  }
39
- let errors = 0;
24
+ let verified = 0;
25
+ const unverified = []; // "GET /path (reason)"
40
26
  for (const e of targets) {
41
- await sleep(rateLimitMs);
42
27
  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++;
28
+ // A route whose mount prefix is unknown would be probed at a wrong URL, and
29
+ // a 404 there says nothing about the real one.
30
+ if (e.unresolved) {
31
+ unverified.push(`GET ${path} (${e.note ?? 'unresolved route prefix'})`);
46
32
  continue;
47
33
  }
48
- if (res.status !== 200)
34
+ await sleep(rateLimitMs);
35
+ const res = await request(base + path, { headers: { accept: 'application/json' } });
36
+ const verdict = classifyBody(res, 'json');
37
+ // `absent` is lumped with unknown on purpose: the id/prefix was guessed, so a
38
+ // 404 does not prove the real resource is protected.
39
+ if (verdict.kind === 'unknown' || verdict.kind === 'absent') {
40
+ unverified.push(`GET ${path} (${verdict.reason})`);
49
41
  continue;
50
- if (!looksLikeData(res.body))
42
+ }
43
+ verified++;
44
+ if (verdict.kind !== 'data')
51
45
  continue;
52
46
  findings.push({
53
47
  id: 'endpoint_no_auth',
@@ -61,10 +55,12 @@ export async function probeEndpointsUnauth(appUrl, endpoints, rateLimitMs = 100)
61
55
  evidence: `curl '${base}${path}'`,
62
56
  });
63
57
  }
64
- const status = errors >= targets.length ? 'failed' : errors > 0 || dropped > 0 ? 'partial' : 'completed';
58
+ const status = verified === 0 ? 'failed' : unverified.length > 0 || dropped > 0 ? 'partial' : 'completed';
65
59
  const notes = [];
66
- if (errors > 0)
67
- notes.push(`${errors}/${targets.length} endpoint requests errored`);
60
+ if (unverified.length > 0) {
61
+ const shown = unverified.slice(0, 5).join(', ');
62
+ notes.push(`${unverified.length}/${targets.length} endpoint(s) not verified: ${shown}${unverified.length > 5 ? ', …' : ''}`);
63
+ }
68
64
  if (dropped > 0)
69
65
  notes.push(`only ${MAX}/${candidates.length} endpoints probed (cap)`);
70
66
  const note = notes.length ? notes.join('; ') : undefined;
@@ -1,4 +1,4 @@
1
- import { isErr, request, requestFollow, unreliable } from '../../net/http.js';
1
+ import { classifyBody, isErr, looksLikeHtml, requestFollow, unreliable } from '../../net/http.js';
2
2
  const EXPOSED = [
3
3
  { path: '.env', signature: /^[A-Z0-9_]+=.+/m, title: '.env file served publicly', severity: 'critical' },
4
4
  { path: '.env.local', signature: /^[A-Z0-9_]+=.+/m, title: '.env.local served publicly', severity: 'critical' },
@@ -14,27 +14,34 @@ const SECURITY_HEADERS = [
14
14
  { header: 'x-frame-options', id: 'missing_xfo', title: 'Missing X-Frame-Options' },
15
15
  { header: 'x-content-type-options', id: 'missing_xcto', title: 'Missing X-Content-Type-Options' },
16
16
  ];
17
- function looksLikeHtml(body) {
18
- return /<!doctype html|<html[\s>]/i.test(body.slice(0, 400));
19
- }
20
17
  /** Passive live checks on a deployed URL: exposed files + security headers. */
21
18
  export async function checkLiveSite(appUrl) {
22
19
  const base = appUrl.replace(/\/$/, '');
23
20
  const findings = [];
24
- // Track every file probe: a timeout/5xx/429 there is a lost sub-check, not "file absent".
25
- let fileErrors = 0;
21
+ // Track every file probe: a lost sub-check (timeout/5xx/429, a redirect we could
22
+ // not follow, a truncated body) is NOT "file absent" — it goes in the note.
23
+ const fileUnverified = [];
26
24
  for (const probe of EXPOSED) {
27
- const res = await request(`${base}/${probe.path}`);
28
- if (isErr(res) || res.status === 429 || res.status >= 500) {
29
- fileErrors++;
25
+ // A GET of a static file is safe to follow: the file may sit behind a same-origin
26
+ // hop (e.g. `/.env` → `/files/.env`). A hop off the origin is refused and lands
27
+ // below as unknown, so a redirect is never taken as evidence of absence.
28
+ const res = await requestFollow(`${base}/${probe.path}`);
29
+ const verdict = classifyBody(res, 'any');
30
+ // 4xx (absent/denied) and an empty 2xx are real answers: no file content was handed out.
31
+ if (verdict.kind === 'absent' || verdict.kind === 'denied' || verdict.kind === 'empty')
30
32
  continue;
31
- }
32
- if (res.status !== 200)
33
- continue; // 404 etc. = that file is simply not served
34
- if (looksLikeHtml(res.body))
33
+ const twoXx = !isErr(res) && res.status >= 200 && res.status < 300;
34
+ if (twoXx && looksLikeHtml(res.body))
35
35
  continue; // SPA catch-all, not the real file
36
- if (!probe.signature.test(res.body))
36
+ // A 2xx body carrying the signature is positive evidence even when truncated —
37
+ // the cap does not make a leaked file un-leaked.
38
+ const served = twoXx && probe.signature.test(res.body);
39
+ if (!served) {
40
+ // Truncated without a signature, a redirect off the origin, a 5xx…: not verified.
41
+ if (verdict.kind === 'unknown')
42
+ fileUnverified.push(`/${probe.path} (${verdict.reason})`);
37
43
  continue;
44
+ }
38
45
  findings.push({
39
46
  id: `exposed_${probe.path.replace(/[^a-z0-9]/gi, '_')}`,
40
47
  severity: probe.severity,
@@ -103,12 +110,13 @@ export async function checkLiveSite(appUrl) {
103
110
  // losing all of them = failed.
104
111
  const rootBad = unreliable(root) || (!isErr(root) && (root.status < 200 || root.status >= 300));
105
112
  const truncated = !isErr(root) && root.truncated === true;
113
+ const fileErrors = fileUnverified.length;
106
114
  const status = rootBad && fileErrors === EXPOSED.length ? 'failed' : rootBad || fileErrors > 0 || truncated ? 'partial' : 'completed';
107
115
  const notes = [];
108
116
  if (rootBad)
109
117
  notes.push(isErr(root) ? `could not reach ${base}/: ${root.error}` : `no usable 2xx page (HTTP ${root.status}) at ${base}/`);
110
118
  if (fileErrors > 0)
111
- notes.push(`${fileErrors}/${EXPOSED.length} exposed-file probes errored`);
119
+ notes.push(`${fileErrors}/${EXPOSED.length} exposed-file probes not verified: ${fileUnverified.slice(0, 4).join(', ')}${fileErrors > 4 ? ', …' : ''}`);
112
120
  if (!isErr(root) && root.truncated)
113
121
  notes.push('response body hit the 2 MB cap — content past it was not inspected');
114
122
  return { findings, run: { id: 'live-site', level: 2, status, note: notes.length ? notes.join('; ') : undefined } };