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.
@@ -7,6 +7,90 @@ import { discoverFirebase, probeFirebase } from '../engine/checkers/backend/fire
7
7
  import { checkLiveSite } from '../engine/checkers/live/http-checks.js';
8
8
  import { probeEndpointsUnauth } from '../engine/checkers/live/endpoint-probe.js';
9
9
  import { idorDifferential } from '../engine/checkers/live/idor.js';
10
+ /** Files that feed discovery, endpoint enumeration and the plan: ignorePaths applied. */
11
+ export function visibleFiles(files, config) {
12
+ return files.filter((f) => !config.ignorePaths.some((sub) => f.rel.includes(sub)));
13
+ }
14
+ /** The identity a consent answer is bound to: kind + normalized target. */
15
+ export function consentKey(kind, target) {
16
+ return `${kind} ${target}`;
17
+ }
18
+ /** Trailing slashes and host case must not make "the same" host look different. */
19
+ function normalizeTarget(url) {
20
+ const trimmed = url.trim().replace(/\/+$/, '');
21
+ try {
22
+ return new URL(trimmed).toString().replace(/\/$/, '');
23
+ }
24
+ catch {
25
+ return trimmed;
26
+ }
27
+ }
28
+ const DETAIL = {
29
+ supabase: 'read tables, buckets and RPC using the public key (read-only)',
30
+ firebase: 'anonymous reads of RTDB, Firestore and Storage',
31
+ live: 'passive checks (headers, exposed files) + unauthenticated endpoint probe',
32
+ idor: 'replay object-scoped endpoints with two accounts (IDOR test)',
33
+ };
34
+ /**
35
+ * The live-site targets that follow from an app URL. Exported so the wizard,
36
+ * which learns the URL only after the backend questions, can extend the plan
37
+ * without a second discovery pass.
38
+ */
39
+ export function liveTargets(appUrl, idorTokens, source = '--url') {
40
+ if (!appUrl)
41
+ return [];
42
+ const target = normalizeTarget(appUrl);
43
+ const out = [{ kind: 'live', target, explicit: true, source, appUrl: target }];
44
+ if (idorTokens)
45
+ out.push({ kind: 'idor', target, explicit: true, source: '--idor-tokens', appUrl: target, tokens: idorTokens });
46
+ return out;
47
+ }
48
+ /**
49
+ * Build the immutable target plan: ONE discovery pass over the files that
50
+ * survive `ignorePaths`, plus whatever the caller asked for explicitly. Both the
51
+ * consent prompt and the probes work from this same list.
52
+ */
53
+ export function planTargets(files, config, opts = {}) {
54
+ const visible = visibleFiles(files, config);
55
+ const targets = [];
56
+ if (opts.supabaseUrl && opts.supabaseKey) {
57
+ const keyKind = classifyKey(opts.supabaseKey) === 'publishable' ? 'publishable' : 'jwt-anon';
58
+ const url = normalizeTarget(opts.supabaseUrl);
59
+ targets.push({ kind: 'supabase', target: url, explicit: true, source: '--supabase-url', creds: { url, anonKey: opts.supabaseKey, keyKind } });
60
+ }
61
+ else {
62
+ const sb = discoverSupabase(visible);
63
+ if (sb)
64
+ targets.push({ kind: 'supabase', target: sb.url, explicit: false, source: `discovered:${sb.source ?? 'code'}`, creds: sb });
65
+ }
66
+ const fb = discoverFirebase(visible);
67
+ if (fb)
68
+ targets.push({ kind: 'firebase', target: fb.projectId, explicit: false, source: 'discovered:code', creds: fb });
69
+ targets.push(...liveTargets(opts.appUrl, opts.idorTokens, opts.appUrlSource));
70
+ return { targets };
71
+ }
72
+ /** Which CheckRun ids a target produces when it runs — used to record the not-run case. */
73
+ function runIdsFor(target) {
74
+ switch (target.kind) {
75
+ case 'supabase': return ['supabase-probe'];
76
+ case 'firebase': return ['firebase-probe'];
77
+ case 'live': return ['live-site', 'endpoint-probe'];
78
+ case 'idor': return ['idor'];
79
+ }
80
+ }
81
+ /**
82
+ * A target that was requested and did not run is missing coverage, never a
83
+ * clean result: `unsupported` makes the gate incomplete. A target that was only
84
+ * auto-discovered and declined is a voluntary skip — visible, gate unaffected.
85
+ */
86
+ function notRun(target) {
87
+ const ownership = target.kind === 'live' || target.kind === 'idor';
88
+ const via = target.source === 'wizard' ? 'in the wizard' : `via ${target.source}`;
89
+ const note = target.explicit
90
+ ? `requested ${via} but ${ownership ? 'ownership was not confirmed' : 'consent was not given'}`
91
+ : 'declined by user';
92
+ return runIdsFor(target).map((id) => ({ id, level: 2, status: target.explicit ? 'unsupported' : 'skipped', note }));
93
+ }
10
94
  /**
11
95
  * Tiered orchestrator shared by the CLI wizard and the skill. Every check that
12
96
  * runs records a status (completed/partial/failed/skipped/unsupported) so a
@@ -14,13 +98,15 @@ import { idorDifferential } from '../engine/checkers/live/idor.js';
14
98
  */
15
99
  export async function runFlow(opts) {
16
100
  const log = opts.log ?? (() => { });
17
- const result = opts.precomputedStatic ?? (await scanStatic(opts.root, { configPath: opts.configPath }));
101
+ const result = opts.precomputedStatic ?? (await scanStatic(opts.root, { configPath: opts.configPath, excludeAbs: opts.excludeAbs }));
18
102
  const findings = [...result.findings];
19
103
  const runs = [...result.runs];
20
- // Files used for backend discovery / endpoint enumeration honor ignorePaths,
21
- // so an ignored path (e.g. tests/, fixtures/) does not feed the live probes.
104
+ // Files used for endpoint enumeration (and, without a caller-supplied plan,
105
+ // backend discovery) honor ignorePaths, so an ignored path (e.g. tests/,
106
+ // fixtures/) does not feed the live probes.
22
107
  const config = loadConfig(opts.root, opts.configPath);
23
- const visible = result.files.filter((f) => !config.ignorePaths.some((sub) => f.rel.includes(sub)));
108
+ const visible = visibleFiles(result.files, config);
109
+ const plan = opts.plan ?? planTargets(result.files, config, opts);
24
110
  // Level 1 — dependency audit.
25
111
  if (opts.runDeps) {
26
112
  log('Level 1: dependency audit…');
@@ -28,86 +114,55 @@ export async function runFlow(opts) {
28
114
  findings.push(...deps.findings);
29
115
  runs.push(deps.run);
30
116
  }
31
- // Level 2 — Supabase active probe (read-only).
32
- const sbCreds = opts.supabaseUrl && opts.supabaseKey
33
- ? { url: opts.supabaseUrl, anonKey: opts.supabaseKey, keyKind: classifyKey(opts.supabaseKey) === 'publishable' ? 'publishable' : 'jwt-anon' }
34
- : discoverSupabase(visible);
35
- if (sbCreds) {
36
- const ok = await opts.consent({
37
- kind: 'supabase',
38
- target: sbCreds.url,
39
- detail: 'read tables, buckets and RPC using the public key (read-only)',
40
- });
41
- if (ok === true) {
42
- log(`Level 2: probing Supabase ${sbCreds.url}…`);
43
- const r = await probeSupabase({ creds: sbCreds, log });
44
- findings.push(...r.findings);
45
- runs.push(r.run);
46
- }
47
- else {
48
- runs.push({ id: 'supabase-probe', level: 2, status: 'skipped', note: 'declined' });
49
- }
50
- }
51
- // Level 2 — Firebase active probe.
52
- const fbCreds = discoverFirebase(visible);
53
- if (fbCreds) {
54
- const ok = await opts.consent({
55
- kind: 'firebase',
56
- target: fbCreds.projectId,
57
- detail: 'anonymous reads of RTDB, Firestore and Storage',
58
- });
59
- if (ok === true) {
60
- log(`Level 2: probing Firebase ${fbCreds.projectId}…`);
61
- const r = await probeFirebase({ creds: fbCreds, log });
62
- findings.push(...r.findings);
63
- runs.push(r.run);
117
+ // Level 2 — execute exactly the plan, one consent per concrete target.
118
+ // IDOR depends on the live-site consent: without ownership of the host, the
119
+ // two-account replay must not run either.
120
+ let liveApproved = false;
121
+ let endpoints;
122
+ for (const target of plan.targets) {
123
+ const req = { kind: target.kind, target: target.target, detail: DETAIL[target.kind], explicit: target.explicit };
124
+ const approved = target.kind === 'idor' ? liveApproved && (await opts.consent(req)) === true : (await opts.consent(req)) === true;
125
+ if (!approved) {
126
+ runs.push(...notRun(target));
127
+ continue;
64
128
  }
65
- else {
66
- runs.push({ id: 'firebase-probe', level: 2, status: 'skipped', note: 'declined' });
67
- }
68
- }
69
- // Level 2 — live site + endpoint probe + IDOR.
70
- if (opts.appUrl) {
71
- const ok = await opts.consent({
72
- kind: 'live',
73
- target: opts.appUrl,
74
- detail: 'passive checks (headers, exposed files) + unauthenticated endpoint probe',
75
- });
76
- if (ok === true) {
77
- log(`Level 2: live checks on ${opts.appUrl}…`);
78
- const site = await checkLiveSite(opts.appUrl);
79
- findings.push(...site.findings);
80
- runs.push(site.run);
81
- const endpoints = collectEndpoints(visible);
82
- const ep = await probeEndpointsUnauth(opts.appUrl, endpoints);
83
- findings.push(...ep.findings);
84
- runs.push(ep.run);
85
- if (opts.idorTokens) {
86
- const idorOk = await opts.consent({
87
- kind: 'idor',
88
- target: opts.appUrl,
89
- detail: 'replay object-scoped endpoints with two accounts (IDOR test)',
90
- });
91
- if (idorOk === true) {
92
- const r = await idorDifferential(opts.appUrl, endpoints, opts.idorTokens[0], opts.idorTokens[1]);
93
- findings.push(...r.findings);
94
- runs.push(r.run);
95
- }
96
- else {
97
- runs.push({ id: 'idor', level: 2, status: 'skipped', note: 'declined' });
98
- }
129
+ switch (target.kind) {
130
+ case 'supabase': {
131
+ log(`Level 2: probing Supabase ${target.target}…`);
132
+ const r = await probeSupabase({ creds: target.creds, log });
133
+ findings.push(...r.findings);
134
+ runs.push(r.run);
135
+ break;
136
+ }
137
+ case 'firebase': {
138
+ log(`Level 2: probing Firebase ${target.target}…`);
139
+ const r = await probeFirebase({ creds: target.creds, log });
140
+ findings.push(...r.findings);
141
+ runs.push(r.run);
142
+ break;
143
+ }
144
+ case 'live': {
145
+ liveApproved = true;
146
+ log(`Level 2: live checks on ${target.appUrl}…`);
147
+ const site = await checkLiveSite(target.appUrl);
148
+ findings.push(...site.findings);
149
+ runs.push(site.run);
150
+ endpoints ??= collectEndpoints(visible);
151
+ const ep = await probeEndpointsUnauth(target.appUrl, endpoints);
152
+ findings.push(...ep.findings);
153
+ runs.push(ep.run);
154
+ break;
155
+ }
156
+ case 'idor': {
157
+ endpoints ??= collectEndpoints(visible);
158
+ const r = await idorDifferential(target.appUrl, endpoints, target.tokens[0], target.tokens[1]);
159
+ findings.push(...r.findings);
160
+ // Tokens are always an explicit request. If the check could not run
161
+ // (identical tokens, no id-scoped endpoints…) that is a request not
162
+ // honoured — `unsupported`, so it cannot hide inside a clean PASS.
163
+ runs.push(r.run.status === 'skipped' ? { ...r.run, status: 'unsupported' } : r.run);
164
+ break;
99
165
  }
100
- }
101
- else {
102
- // A URL only reaches here because the caller explicitly asked for it, so
103
- // this is a check that was REQUESTED and did not run. That is missing
104
- // coverage, not a clean result: `unsupported` makes the gate incomplete,
105
- // where `skipped` would have let a never-run live check exit 0 as PASS.
106
- const note = 'requested but not authorized — ownership was not confirmed';
107
- runs.push({ id: 'live-site', level: 2, status: 'unsupported', note });
108
- runs.push({ id: 'endpoint-probe', level: 2, status: 'unsupported', note });
109
- if (opts.idorTokens)
110
- runs.push({ id: 'idor', level: 2, status: 'unsupported', note });
111
166
  }
112
167
  }
113
168
  // Apply config-based suppressions to the full finding set (Level 1/2 too).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyvibegate",
3
- "version": "0.4.4",
3
+ "version": "0.6.2",
4
4
  "description": "Interactive security scanner for vibe-coded apps (focus: Next.js + Supabase). Checks common risks and shows evidence where it can.",
5
5
  "type": "module",
6
6
  "bin": {