easyvibegate 0.4.4 → 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.
@@ -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,62 @@ 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
+ // 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/;
29
+ const BARE_PUBLISHABLE = /\bsb_publishable_[A-Za-z0-9_-]{10,}\b/;
30
+ const BARE_JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
31
+ function isPublicKey(key) {
32
+ const kind = classifyKey(key);
33
+ return kind === 'jwt-anon' || kind === 'publishable';
34
+ }
35
+ /**
36
+ * The URL and *public* key ONE file declares — an explicit assignment first, a
37
+ * bare literal as fallback. Never an authenticated/service key.
38
+ */
39
+ function credsInFile(content) {
40
+ const url = content.match(URL_ASSIGN)?.[1] ?? content.match(BARE_URL)?.[0];
41
+ let key = content.match(ANON_ASSIGN)?.[1];
42
+ if (key && !isPublicKey(key))
43
+ key = undefined;
44
+ key ??= content.match(BARE_PUBLISHABLE)?.[0]
45
+ ?? [...content.matchAll(BARE_JWT)].map((m) => m[0]).find((k) => classifyKey(k) === 'jwt-anon');
46
+ return { url, key };
47
+ }
48
+ function envRank(rel) {
49
+ const i = ENV_ORDER.indexOf(rel.split('/').pop() ?? rel);
50
+ return i === -1 ? ENV_ORDER.length : i;
51
+ }
52
+ /**
53
+ * Find a Supabase URL + a *public* key (anon JWT or publishable) in the project.
54
+ * URL and key are paired by provenance: a file holding both wins (env files in
55
+ * load order first). Only when no single file has both do we fall back to a
56
+ * URL from one file and a key from another — flagged `ambiguous`, because the
57
+ * two may belong to different environments and must never be presented as one
58
+ * discovered config.
59
+ */
21
60
  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 {
61
+ const scannable = files
62
+ .filter((f) => !NOT_CONFIG.test(f.rel))
63
+ .map((f) => ({ rel: f.rel, ...credsInFile(f.content) }))
64
+ .sort((a, b) => envRank(a.rel) - envRank(b.rel)); // stable: ties keep scan order
65
+ const make = (url, anonKey, source, ambiguous) => ({
67
66
  url: url.replace(/\/+$/, ''),
68
67
  anonKey,
69
68
  keyKind: classifyKey(anonKey) === 'publishable' ? 'publishable' : 'jwt-anon',
70
69
  source,
71
- };
70
+ ...(ambiguous ? { ambiguous } : {}),
71
+ });
72
+ const paired = scannable.find((f) => f.url && f.key);
73
+ if (paired?.url && paired.key)
74
+ return make(paired.url, paired.key, paired.rel);
75
+ const urlFile = scannable.find((f) => f.url);
76
+ const keyFile = scannable.find((f) => f.key);
77
+ if (!urlFile?.url || !keyFile?.key)
78
+ return null;
79
+ return make(urlFile.url, keyFile.key, `${urlFile.rel} (URL) + ${keyFile.rel} (key)`, true);
72
80
  }
73
81
  // Word-ish matching on the table name: "postcards" must not match "card",
74
82
  // "authors" must not match "auth", but "api_keys" and "ssn_records" must hit.
@@ -103,21 +111,33 @@ function parseCount(headers) {
103
111
  const n = parseInt(total, 10);
104
112
  return Number.isNaN(n) ? null : n;
105
113
  }
114
+ function isPlainObject(v) {
115
+ return !!v && typeof v === 'object' && !Array.isArray(v);
116
+ }
117
+ /**
118
+ * PostgREST answers /rest/v1/ with an OpenAPI 2/3 document. Any other JSON
119
+ * (`{"error":…}` from a gateway, a maintenance page's payload) is NOT an empty
120
+ * schema — treating it as one would turn an outage into "zero tables exposed".
121
+ */
122
+ function asOpenApi(v) {
123
+ if (!isPlainObject(v))
124
+ return null;
125
+ const schemas = isPlainObject(v['components']) ? v['components']['schemas'] : undefined;
126
+ if (!isPlainObject(v['definitions']) && !isPlainObject(schemas) && !isPlainObject(v['paths']))
127
+ return null;
128
+ return v;
129
+ }
106
130
  async function enumerate(creds) {
107
131
  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
- }
132
+ const verdict = classifyBody(res, 'json');
133
+ if (verdict.kind === 'denied')
134
+ return { error: `anon key rejected (${verdict.reason})` };
135
+ // Redirect, 5xx, truncated body, HTML, error envelope, empty JSON: the schema was not enumerated.
136
+ if (verdict.kind !== 'data')
137
+ return { error: `could not enumerate the PostgREST schema (${verdict.reason})` };
138
+ const spec = asOpenApi(verdict.json);
139
+ if (!spec)
140
+ return { error: 'PostgREST did not return an OpenAPI document — schema enumeration failed' };
121
141
  const schemas = { ...(spec.definitions ?? {}), ...(spec.components?.schemas ?? {}) };
122
142
  let tables = Object.keys(schemas);
123
143
  const paths = Object.keys(spec.paths ?? {});
@@ -163,7 +183,7 @@ export async function probeSupabase(opts) {
163
183
  }
164
184
  const { tables, rpc } = enumerated;
165
185
  log(`Supabase: ${tables.length} table(s), ${rpc.length} rpc function(s) exposed to PostgREST`);
166
- let errored = 0;
186
+ const lost = []; // sub-checks that produced no verdict, for the note
167
187
  for (const table of tables) {
168
188
  await sleep(rl);
169
189
  // HEAD + count=exact returns only the row count in a header — no data pulled.
@@ -172,14 +192,18 @@ export async function probeSupabase(opts) {
172
192
  headers: { ...authHeaders(creds.anonKey), Prefer: 'count=exact', Range: '0-0', 'Range-Unit': 'items' },
173
193
  });
174
194
  // 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++;
195
+ if (isErr(res)) {
196
+ lost.push(`${table} (${res.error})`);
197
+ continue;
198
+ }
199
+ if (res.status === 429 || res.status >= 500 || (res.status >= 300 && res.status < 400)) {
200
+ lost.push(`${table} (HTTP ${res.status})`);
177
201
  continue;
178
202
  }
179
203
  if (res.status === 200 || res.status === 206) {
180
204
  const count = parseCount(res.headers);
181
205
  if (count === null) {
182
- errored++;
206
+ lost.push(`${table} (no content-range)`);
183
207
  continue;
184
208
  } // no usable count → inconclusive, not proof
185
209
  if (count > 0) {
@@ -204,31 +228,31 @@ export async function probeSupabase(opts) {
204
228
  // Storage buckets listable by anon.
205
229
  await sleep(rl);
206
230
  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
- }
231
+ // One classifier for every probe: a redirect, a truncated or non-JSON 200, an
232
+ // error envelope — none of them says "no buckets". 401/403/404 and `[]` do.
233
+ const bv = classifyBody(buckets, 'json');
234
+ if (bv.kind === 'unknown')
235
+ lost.push(`storage (${bv.reason})`);
236
+ if (bv.kind === 'data') {
237
+ const parsed = bv.json;
238
+ const list = (Array.isArray(parsed) ? parsed : parsed?.buckets);
239
+ if (!Array.isArray(list)) {
240
+ lost.push('storage (unrecognized bucket listing)'); // a 200 we cannot read is a lost sub-check
241
+ }
242
+ else if (list.length > 0) {
243
+ const objs = list.map((b) => (typeof b === 'string' ? { name: b } : b));
244
+ const publicOnes = objs.filter((b) => b.public).map((b) => b.name ?? b.id ?? '(unnamed)').join(', ');
245
+ findings.push({
246
+ id: 'supabase_bucket_listing',
247
+ severity: publicOnes ? 'critical' : 'warning',
248
+ title: 'Storage buckets are listable by anyone',
249
+ detail: `The public key can list ${list.length} storage bucket(s)${publicOnes ? `; public: ${publicOnes}` : ''}.`,
250
+ fix: 'Restrict bucket listing and mark buckets private unless public access is intentional; add storage RLS policies.',
251
+ checker: 'supabase-probe',
252
+ level: 2,
253
+ endpoint: 'GET /storage/v1/bucket',
254
+ });
228
255
  }
229
- catch {
230
- storageErrored = true;
231
- } // a 200 we cannot parse is a lost sub-check
232
256
  }
233
257
  if (rpc.length > 0) {
234
258
  findings.push({
@@ -242,8 +266,10 @@ export async function probeSupabase(opts) {
242
266
  });
243
267
  }
244
268
  const attempted = tables.length + 1; // tables + storage
245
- const totalErr = errored + (storageErrored ? 1 : 0);
269
+ const totalErr = lost.length;
246
270
  const status = totalErr === 0 ? 'completed' : totalErr < attempted ? 'partial' : 'failed';
247
- const note = totalErr > 0 ? `${totalErr}/${attempted} probe requests errored (5xx/429/timeout)` : undefined;
271
+ const note = totalErr > 0
272
+ ? `${totalErr}/${attempted} probe(s) not verified: ${lost.slice(0, 5).join(', ')}${totalErr > 5 ? ', …' : ''}`
273
+ : undefined;
248
274
  return { findings, run: { id: 'supabase-probe', level: 2, status, note } };
249
275
  }
@@ -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 } };