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.
- package/README.en.md +5 -9
- package/README.md +30 -12
- package/dist/cli/index.js +40 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +79 -55
- package/dist/engine/checkers/backend/supabase.js +117 -91
- package/dist/engine/checkers/deep/deps.js +87 -29
- package/dist/engine/checkers/live/endpoint-probe.js +29 -33
- package/dist/engine/checkers/live/http-checks.js +23 -15
- package/dist/engine/checkers/live/idor.js +65 -49
- package/dist/engine/checkers/static/client-exposure.js +58 -3
- package/dist/engine/checkers/static/config-risks.js +70 -10
- package/dist/engine/checkers/static/rls-migrations.js +706 -192
- package/dist/engine/checkers/static/secrets.js +259 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +24 -12
- package/dist/engine/endpoints.js +219 -13
- package/dist/engine/i18n.js +6 -2
- package/dist/engine/net/http.js +68 -0
- package/dist/engine/report.js +11 -1
- package/dist/engine/scan.js +16 -7
- package/dist/engine/util/code-lex.js +260 -0
- package/dist/engine/util/git-exposure.js +33 -0
- package/dist/engine/util/mask.js +32 -44
- package/dist/engine/util/sql-lex.js +172 -0
- package/dist/engine/util/text.js +12 -0
- package/dist/engine/walk.js +52 -7
- package/dist/orchestrator/flow.js +137 -82
- package/package.json +1 -1
|
@@ -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
|
|
21
|
-
// so an ignored path (e.g. tests/,
|
|
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
|
|
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 —
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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