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,37 +1,44 @@
1
1
  import { concretePath } from '../../endpoints.js';
2
- import { isErr, request, sleep, unreliable } from '../../net/http.js';
2
+ import { classifyBody, isErr, request, sleep, unreliable } from '../../net/http.js';
3
3
  function bearer(token) {
4
4
  return { Authorization: `Bearer ${token}`, accept: 'application/json' };
5
5
  }
6
- /** Real, non-empty JSON payload — an empty collection or an error envelope is not data. */
7
- function hasData(body) {
8
- const t = body.trim();
9
- if (t.length < 2 || !(t.startsWith('{') || t.startsWith('[')))
10
- return false;
11
- try {
12
- const v = JSON.parse(t);
13
- if (Array.isArray(v))
14
- return v.length > 0;
15
- if (v && typeof v === 'object') {
16
- const o = v;
17
- if ('error' in o || 'errors' in o)
18
- return false;
19
- return Object.keys(o).length > 0;
20
- }
21
- return false;
22
- }
23
- catch {
24
- return false;
6
+ /**
7
+ * What one (A, B) pair proves. Only a pair where at least one side gave a
8
+ * definite answer AND the other side did not leak is "protected"; anything
9
+ * where the guessed id did not exist, a token was rejected, or a body could
10
+ * not be read proves nothing.
11
+ */
12
+ function classifyPair(a, b, aStatus, bStatus) {
13
+ if (a.kind === 'data' && b.kind === 'data')
14
+ return { kind: 'cross-user', reason: 'both accounts read data' };
15
+ if (a.kind === 'unknown' || b.kind === 'unknown')
16
+ return { kind: 'unknown', reason: a.kind === 'unknown' ? `A: ${a.reason}` : `B: ${b.reason}` };
17
+ if (a.kind === 'absent' || b.kind === 'absent')
18
+ return { kind: 'unknown', reason: `guessed id not found (HTTP ${a.kind === 'absent' ? aStatus : bStatus})` };
19
+ // A 401 means that token itself was rejected, so that account's access was never tested.
20
+ if (aStatus === 401 || bStatus === 401)
21
+ return { kind: 'unknown', reason: `token ${aStatus === 401 ? 'A' : 'B'} rejected (HTTP 401)` };
22
+ // Neither side got data and neither answer was an error/404: no object behind
23
+ // the guessed id (or both denied) — nothing leaked, but ownership was not exercised.
24
+ if (a.kind !== 'data' && b.kind !== 'data') {
25
+ if (a.kind === 'denied' && b.kind === 'denied')
26
+ return { kind: 'protected', reason: 'both accounts denied' };
27
+ return { kind: 'unknown', reason: 'no data for the guessed id from either account' };
25
28
  }
29
+ // Exactly one side read data, the other was denied or got nothing: scoped correctly.
30
+ return { kind: 'protected', reason: 'only one account read data' };
26
31
  }
27
32
  /**
28
33
  * Differential IDOR/BOLA probe. For each object-scoped endpoint, requests the
29
34
  * same resource as two different users and classifies the PAIR:
30
35
  * - either side unreliable (timeout/5xx/429) → inconclusive, not a verdict
31
- * - both 200 with data → candidate cross-user access
32
- * - otherwise → a definitive (non-leaking) answer
33
- * Harvesting real object IDs and judging ambiguous cases is left to the
34
- * operator or the AI-driven skill; the run status says how much was proven.
36
+ * - both 2xx with data → candidate cross-user access
37
+ * - one side data, other denied/empty → protected (a verified pair)
38
+ * - 404 / rejected tokens / unreadable body → unknown (not verified)
39
+ * Any pair that could not be verified makes the run `partial`, so one good
40
+ * pair never hides the others. Harvesting real object IDs and judging
41
+ * ambiguous cases is left to the operator or the AI-driven skill.
35
42
  */
36
43
  export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLimitMs = 120) {
37
44
  const base = appUrl.replace(/\/$/, '');
@@ -50,9 +57,14 @@ export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLi
50
57
  }
51
58
  let inconclusive = 0; // a side timed out / 5xx / 429 — we learned nothing
52
59
  let evaluated = 0; // both sides answered reliably
53
- let dataSeen = 0; // at least one side returned actual data (the guessed id exists)
60
+ let verifiedPairs = 0; // protected or cross-user: a real verdict
61
+ const unknown = []; // "GET /path (reason)"
54
62
  for (const e of idEndpoints) {
55
63
  const path = concretePath(e.path).replace(/^\/?/, '/');
64
+ if (e.unresolved) {
65
+ unknown.push(`GET ${path} (${e.note ?? 'unresolved route prefix'})`);
66
+ continue;
67
+ }
56
68
  await sleep(rateLimitMs);
57
69
  const a = await request(base + path, { headers: bearer(tokenA) });
58
70
  await sleep(rateLimitMs);
@@ -62,40 +74,44 @@ export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLi
62
74
  continue;
63
75
  }
64
76
  evaluated++;
65
- const aOk = a.status === 200 && hasData(a.body);
66
- const bOk = b.status === 200 && hasData(b.body);
67
- if (aOk || bOk)
68
- dataSeen++;
69
- if (aOk && bOk) {
70
- const identical = a.body === b.body;
71
- findings.push({
72
- id: 'idor_cross_user',
73
- severity: 'warning',
74
- title: `Two accounts both read ${path}`,
75
- detail: identical
76
- ? `Both accounts received the identical object at ${path}. If this resource is meant to be private/per-user, that is an IDOR; if it is public/shared, it is fine — confirm which.`
77
- : `Both accounts got a 200 with data at ${path}. Verify each only ever sees their own record.`,
78
- fix: 'Enforce ownership server-side: check the authenticated user owns the requested id before returning it (or apply RLS). To confirm exploitability, request an id you know belongs to account A while authenticated as account B.',
79
- checker: 'idor',
80
- level: 2,
81
- endpoint: `GET ${path}`,
82
- });
77
+ const pair = classifyPair(classifyBody(a, 'json'), classifyBody(b, 'json'), a.status, b.status);
78
+ if (pair.kind === 'unknown') {
79
+ unknown.push(`GET ${path} (${pair.reason})`);
80
+ continue;
83
81
  }
82
+ verifiedPairs++;
83
+ if (pair.kind !== 'cross-user')
84
+ continue;
85
+ const identical = a.body === b.body;
86
+ findings.push({
87
+ id: 'idor_cross_user',
88
+ severity: 'warning',
89
+ title: `Two accounts both read ${path}`,
90
+ detail: identical
91
+ ? `Both accounts received the identical object at ${path}. If this resource is meant to be private/per-user, that is an IDOR; if it is public/shared, it is fine — confirm which.`
92
+ : `Both accounts got a 200 with data at ${path}. Verify each only ever sees their own record.`,
93
+ fix: 'Enforce ownership server-side: check the authenticated user owns the requested id before returning it (or apply RLS). To confirm exploitability, request an id you know belongs to account A while authenticated as account B.',
94
+ checker: 'idor',
95
+ level: 2,
96
+ endpoint: `GET ${path}`,
97
+ });
84
98
  }
85
99
  const total = idEndpoints.length;
86
- if (evaluated === 0) {
100
+ if (evaluated === 0 && unknown.length === 0) {
87
101
  return { findings, run: { id: 'idor', level: 2, status: 'failed', note: `all ${total} pair(s) were inconclusive (timeout/5xx/429)` } };
88
102
  }
89
- if (inconclusive > 0 || droppedPairs > 0) {
103
+ if (inconclusive > 0 || droppedPairs > 0 || unknown.length > 0) {
90
104
  const n = [];
91
105
  if (inconclusive > 0)
92
106
  n.push(`${inconclusive}/${total} pair(s) inconclusive (timeout/5xx/429)`);
107
+ if (unknown.length > 0) {
108
+ n.push(`${unknown.length}/${total} pair(s) not verified: ${unknown.slice(0, 5).join(', ')}${unknown.length > 5 ? ', …' : ''}` +
109
+ ' — provide real object ids to confirm ownership');
110
+ }
93
111
  if (droppedPairs > 0)
94
112
  n.push(`only ${MAX}/${idCandidates.length} object endpoints probed (cap)`);
95
113
  return { findings, run: { id: 'idor', level: 2, status: 'partial', note: n.join('; ') } };
96
114
  }
97
- if (dataSeen === 0) {
98
- return { findings, run: { id: 'idor', level: 2, status: 'partial', note: 'no endpoint returned data for the guessed id — provide real object ids to confirm ownership' } };
99
- }
100
- return { findings, run: { id: 'idor', level: 2, status: 'completed' } };
115
+ // Reaching here means every pair got a real verdict (protected or cross-user).
116
+ return { findings, run: { id: 'idor', level: 2, status: verifiedPairs === total ? 'completed' : 'partial' } };
101
117
  }
@@ -1,20 +1,75 @@
1
1
  import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
2
+ import { classifyKey } from '../backend/supabase.js';
2
3
  // Public env prefixes are inlined into the browser bundle by the bundler.
3
4
  const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
5
+ // Every public-prefixed assignment; what it carries is decided by the VALUE
6
+ // first and the NAME second (below).
7
+ const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)\\s*[:=]\\s*["']?([^"'\\s]{6,})`, 'gi');
4
8
  // A public var whose NAME implies a real secret (not an anon/publishable key).
5
- const PUBLIC_SECRET = new RegExp(`\\b${PUBLIC_PREFIX}[A-Z0-9_]*(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)[A-Z0-9_]*\\s*[:=]\\s*["']?([^"'\\s]{6,})`, 'gi');
9
+ const SECRET_NAME = /(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)/i;
10
+ // Value shapes that are server secrets no matter what the variable is called.
11
+ const SERVER_SECRET_VALUE = /^(?:sk_live_|rk_live_|sk-ant-|sk-(?:proj-)?[A-Za-z0-9_-]{20,}$)/;
12
+ const FIREBASE_WEB_KEY = /^AIza[0-9A-Za-z_-]{35}$/;
13
+ /**
14
+ * A key is public or secret by what it IS, not by what the variable is named.
15
+ * A Supabase publishable key or a Firebase Web API key stored under a public
16
+ * *_API_KEY name was reported as a leaked server secret with "rotate it"
17
+ * advice, although both are designed to ship to the browser. Conversely a
18
+ * service_role JWT behind a public *_KEY name (no secret word) went unreported.
19
+ */
20
+ function classifyValue(value) {
21
+ const sb = classifyKey(value);
22
+ if (sb === 'publishable' || sb === 'jwt-anon')
23
+ return 'client-key';
24
+ if (sb === 'secret' || sb === 'jwt-service' || sb === 'jwt-authenticated')
25
+ return 'server-secret';
26
+ if (FIREBASE_WEB_KEY.test(value))
27
+ return 'client-key';
28
+ if (SERVER_SECRET_VALUE.test(value))
29
+ return 'server-secret';
30
+ return 'unknown';
31
+ }
6
32
  export const clientExposureChecker = {
7
33
  id: 'client-exposure',
8
34
  title: 'Secrets exposed to the browser',
9
35
  level: 0,
10
36
  run(ctx) {
11
37
  const findings = [];
38
+ // The same public key is typically repeated across .env, .env.local and
39
+ // config; one advisory per file+value is enough.
40
+ const advised = new Set();
12
41
  for (const file of ctx.files) {
13
42
  const { content, rel } = file;
14
- for (const m of content.matchAll(PUBLIC_SECRET)) {
43
+ for (const m of content.matchAll(PUBLIC_ASSIGN)) {
44
+ const name = m[1] ?? '';
15
45
  const value = m[2] ?? '';
16
46
  if (looksLikePlaceholder(value))
17
47
  continue;
48
+ const kind = classifyValue(value);
49
+ const line = lineAt(content, m.index ?? 0);
50
+ if (kind === 'client-key') {
51
+ const key = `${rel}:${value}`;
52
+ if (advised.has(key))
53
+ continue;
54
+ advised.add(key);
55
+ findings.push({
56
+ id: 'public_key_client',
57
+ severity: 'advisory',
58
+ title: 'Public client key behind a public env var',
59
+ detail: `${name} carries a key designed for the browser (${redact(value)}) — an anon/publishable Supabase key or a Firebase Web API key. It is not a leak by itself: its safety depends entirely on Row Level Security / Firebase security rules / API key restrictions.`,
60
+ fix: 'Keep it public, and make the server side enforce access: enable RLS with policies on every table (Supabase), lock down security rules (Firebase), and restrict the key by API and HTTP referrer (Google Cloud console).',
61
+ checker: 'client-exposure',
62
+ level: 0,
63
+ file: rel,
64
+ line,
65
+ evidence: redact(value),
66
+ });
67
+ continue;
68
+ }
69
+ // A proven server secret is critical regardless of the name; an
70
+ // unknown value is critical only when the NAME says it is a secret.
71
+ if (kind !== 'server-secret' && !SECRET_NAME.test(name))
72
+ continue;
18
73
  findings.push({
19
74
  id: 'public_env_secret',
20
75
  severity: 'critical',
@@ -24,7 +79,7 @@ export const clientExposureChecker = {
24
79
  checker: 'client-exposure',
25
80
  level: 0,
26
81
  file: rel,
27
- line: lineAt(content, m.index ?? 0),
82
+ line,
28
83
  evidence: redact(value),
29
84
  });
30
85
  }
@@ -1,5 +1,6 @@
1
1
  import { lineAt } from '../../util/text.js';
2
- import { looksMinified, maskCode } from '../../util/mask.js';
2
+ import { looksBundledPath, maskTokens } from '../../util/mask.js';
3
+ import { langForFile, lexCode } from '../../util/code-lex.js';
3
4
  const RULES = [
4
5
  {
5
6
  id: 'cors_star',
@@ -22,7 +23,8 @@ const RULES = [
22
23
  {
23
24
  id: 'eval_use',
24
25
  title: 'Use of eval / new Function',
25
- re: /\beval\s*\(|\bnew\s+Function\s*\(/g,
26
+ // `(?<![\w.])` — `model.eval()` (PyTorch) and `obj.eval(` are methods, not the global.
27
+ re: /(?<![\w.])eval\s*\(|\bnew\s+Function\s*\(/g,
26
28
  severity: 'warning',
27
29
  detail: 'eval on any untrusted input is a code-execution risk.',
28
30
  fix: 'Replace eval with explicit parsing/logic; never eval user-supplied data.',
@@ -51,30 +53,84 @@ const RULES = [
51
53
  maskStrings: false,
52
54
  },
53
55
  ];
56
+ /**
57
+ * Template tags that parameterize `${…}` instead of splicing it into the query
58
+ * text (Postgres.js / slonik / drizzle `sql`, Prisma `$queryRaw` / `Prisma.sql`).
59
+ * Matched against the code just before the backtick. `sql.unsafe` and
60
+ * `$queryRawUnsafe` end differently and so stay reported, as do `.query(`…`)`
61
+ * and untagged templates.
62
+ */
63
+ const SAFE_SQL_TAG = /(?:^|[^\w$.])(?:sql|SQL|[\w$]+\.sql|(?:[\w$]+\.)?\$(?:queryRaw|executeRaw))\s*$/;
64
+ function isParameterizedTemplate(scan, backtickAt) {
65
+ if (scan[backtickAt] !== '`')
66
+ return false;
67
+ return SAFE_SQL_TAG.test(scan.slice(Math.max(0, backtickAt - 64), backtickAt));
68
+ }
69
+ /**
70
+ * Where an interpolation sits decides what it can do. `WHERE id = ${id}` splices
71
+ * a VALUE — injection. `FROM ${table}`, `SET ${col} = ?`, `ORDER BY ${dir}` splice
72
+ * STRUCTURE with the values still parameterized — risky only if the names come
73
+ * from request data, so it is reported as info, not as an injection. About half
74
+ * of the real-world hits were structural.
75
+ */
76
+ const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*$/i;
77
+ const PLACEHOLDER_EXPR = /^\s*(?:\w+\.)?(placeholders?|params?|marks|questions|qs|values|binds?)\b/i;
78
+ function onlyStructuralInterpolation(scan, at) {
79
+ // The literal: from the opening delimiter to its close (bounded).
80
+ const open = scan[at] === '`' ? '`' : scan[at + 1] ?? '"';
81
+ const start = scan[at] === '`' ? at + 1 : at + 2;
82
+ const close = scan.indexOf(open, start);
83
+ const body = scan.slice(start, close === -1 ? Math.min(scan.length, start + 2000) : close);
84
+ const holes = [...body.matchAll(/\$\{([^}]*)\}|(?<!\$)\{([^}]*)\}/g)];
85
+ if (holes.length === 0)
86
+ return false;
87
+ for (const h of holes) {
88
+ const expr = h[1] ?? h[2] ?? '';
89
+ if (PLACEHOLDER_EXPR.test(expr))
90
+ continue;
91
+ const before = body.slice(Math.max(0, (h.index ?? 0) - 60), h.index ?? 0);
92
+ if (VALUE_POSITION.test(before))
93
+ return false;
94
+ }
95
+ return true;
96
+ }
54
97
  export const configRisksChecker = {
55
98
  id: 'config-risks',
56
99
  title: 'Dangerous configuration',
57
100
  level: 0,
58
101
  run(ctx) {
59
102
  const findings = [];
103
+ const skipped = [];
60
104
  for (const file of ctx.files) {
61
105
  // Config risks in prose docs are examples, not live config — skip them.
62
106
  if (file.rel.endsWith('.md') || file.rel.endsWith('.txt'))
63
107
  continue;
64
- if (looksMinified(file.rel, file.content))
65
- continue; // generated output, not source
108
+ // Generated output is skipped by PATH only and reported as missing
109
+ // coverage below. A "looks minified" content heuristic used to skip a
110
+ // file silently, so one long data line disabled every rule for it. The
111
+ // regexes are length-bounded, so long lines are safe to scan instead.
112
+ if (looksBundledPath(file.rel)) {
113
+ skipped.push(file.rel);
114
+ continue;
115
+ }
66
116
  // Comments never trigger a rule. Strings are a per-rule decision: a rule
67
117
  // matching a literal value must still see it (see Rule.maskStrings).
68
- const noComments = maskCode(file.content);
69
- const noStrings = maskCode(file.content, { strings: true });
118
+ const tokens = lexCode(file.content, langForFile(file.rel));
119
+ const noComments = maskTokens(file.content, tokens);
120
+ const noStrings = maskTokens(file.content, tokens, { strings: true });
70
121
  for (const rule of RULES) {
71
122
  const scan = rule.maskStrings ? noStrings : noComments;
72
123
  for (const m of scan.matchAll(rule.re)) {
124
+ if (rule.id === 'sql_interpolation' && isParameterizedTemplate(scan, m.index ?? 0))
125
+ continue;
126
+ const structural = rule.id === 'sql_interpolation' && onlyStructuralInterpolation(scan, m.index ?? 0);
73
127
  findings.push({
74
128
  id: rule.id,
75
- severity: rule.severity,
76
- title: rule.title,
77
- detail: rule.detail,
129
+ severity: structural ? 'info' : rule.severity,
130
+ title: structural ? 'SQL built with interpolated identifiers' : rule.title,
131
+ detail: structural
132
+ ? 'Table/column names or operators are spliced into the query while the values use placeholders. That is safe only if those names come from a fixed allowlist in code — never from request data (`Object.keys(body)` is the classic hole).'
133
+ : rule.detail,
78
134
  fix: rule.fix,
79
135
  checker: 'config-risks',
80
136
  level: 0,
@@ -84,6 +140,10 @@ export const configRisksChecker = {
84
140
  }
85
141
  }
86
142
  }
87
- return findings;
143
+ // Skipped is not clean: name what was not reviewed so the gate stays incomplete.
144
+ if (skipped.length === 0)
145
+ return { findings };
146
+ const shown = skipped.slice(0, 5).join(', ') + (skipped.length > 5 ? `, … (+${skipped.length - 5})` : '');
147
+ return { findings, partial: `${skipped.length} generated/minified file(s) not reviewed: ${shown}` };
88
148
  },
89
149
  };