easyvibegate 0.4.4

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +144 -0
  4. package/dist/cli/index.js +402 -0
  5. package/dist/cli/wizard.js +196 -0
  6. package/dist/engine/aifix.js +65 -0
  7. package/dist/engine/checkers/backend/firebase.js +146 -0
  8. package/dist/engine/checkers/backend/supabase.js +249 -0
  9. package/dist/engine/checkers/deep/deps.js +118 -0
  10. package/dist/engine/checkers/index.js +15 -0
  11. package/dist/engine/checkers/live/endpoint-probe.js +72 -0
  12. package/dist/engine/checkers/live/http-checks.js +123 -0
  13. package/dist/engine/checkers/live/idor.js +101 -0
  14. package/dist/engine/checkers/static/client-exposure.js +34 -0
  15. package/dist/engine/checkers/static/config-risks.js +89 -0
  16. package/dist/engine/checkers/static/env-git.js +70 -0
  17. package/dist/engine/checkers/static/rls-migrations.js +324 -0
  18. package/dist/engine/checkers/static/route-inventory.js +31 -0
  19. package/dist/engine/checkers/static/secrets.js +262 -0
  20. package/dist/engine/config.js +54 -0
  21. package/dist/engine/detect.js +110 -0
  22. package/dist/engine/endpoints.js +65 -0
  23. package/dist/engine/i18n.js +189 -0
  24. package/dist/engine/net/http.js +108 -0
  25. package/dist/engine/report.js +219 -0
  26. package/dist/engine/scan.js +53 -0
  27. package/dist/engine/types.js +1 -0
  28. package/dist/engine/util/color.js +17 -0
  29. package/dist/engine/util/mask.js +66 -0
  30. package/dist/engine/util/text.js +50 -0
  31. package/dist/engine/version.js +12 -0
  32. package/dist/engine/walk.js +86 -0
  33. package/dist/orchestrator/flow.js +116 -0
  34. package/package.json +46 -0
@@ -0,0 +1,123 @@
1
+ import { isErr, request, requestFollow, unreliable } from '../../net/http.js';
2
+ const EXPOSED = [
3
+ { path: '.env', signature: /^[A-Z0-9_]+=.+/m, title: '.env file served publicly', severity: 'critical' },
4
+ { path: '.env.local', signature: /^[A-Z0-9_]+=.+/m, title: '.env.local served publicly', severity: 'critical' },
5
+ { path: '.env.production', signature: /^[A-Z0-9_]+=.+/m, title: '.env.production served publicly', severity: 'critical' },
6
+ { path: '.git/config', signature: /\[core\]|\[remote/, title: '.git/config served publicly', severity: 'critical' },
7
+ { path: '.git/HEAD', signature: /^ref:\s/m, title: '.git/HEAD served publicly', severity: 'critical' },
8
+ { path: 'backup.sql', signature: /CREATE TABLE|INSERT INTO/i, title: 'SQL backup served publicly', severity: 'critical' },
9
+ { path: 'config.json', signature: /"[^"]*(api[_-]?key|secret|password|token|credential)[^"]*"\s*:/i, title: 'config.json served publicly', severity: 'warning' },
10
+ ];
11
+ const SECURITY_HEADERS = [
12
+ { header: 'content-security-policy', id: 'missing_csp', title: 'Missing Content-Security-Policy' },
13
+ { header: 'strict-transport-security', id: 'missing_hsts', title: 'Missing Strict-Transport-Security' },
14
+ { header: 'x-frame-options', id: 'missing_xfo', title: 'Missing X-Frame-Options' },
15
+ { header: 'x-content-type-options', id: 'missing_xcto', title: 'Missing X-Content-Type-Options' },
16
+ ];
17
+ function looksLikeHtml(body) {
18
+ return /<!doctype html|<html[\s>]/i.test(body.slice(0, 400));
19
+ }
20
+ /** Passive live checks on a deployed URL: exposed files + security headers. */
21
+ export async function checkLiveSite(appUrl) {
22
+ const base = appUrl.replace(/\/$/, '');
23
+ const findings = [];
24
+ // Track every file probe: a timeout/5xx/429 there is a lost sub-check, not "file absent".
25
+ let fileErrors = 0;
26
+ 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++;
30
+ continue;
31
+ }
32
+ if (res.status !== 200)
33
+ continue; // 404 etc. = that file is simply not served
34
+ if (looksLikeHtml(res.body))
35
+ continue; // SPA catch-all, not the real file
36
+ if (!probe.signature.test(res.body))
37
+ continue;
38
+ findings.push({
39
+ id: `exposed_${probe.path.replace(/[^a-z0-9]/gi, '_')}`,
40
+ severity: probe.severity,
41
+ title: probe.title,
42
+ detail: `${base}/${probe.path} is served and returns file content, not an app page.`,
43
+ fix: 'Block dotfiles and backups at the web server/CDN, and remove the file from the deploy output.',
44
+ checker: 'live-site',
45
+ level: 2,
46
+ endpoint: `GET /${probe.path}`,
47
+ });
48
+ }
49
+ // Follow redirects (bounded) so headers are read from the real page, not a 301/302 hop.
50
+ // Only analyze headers on a reliable response — a 5xx/429 must not masquerade as "headers missing".
51
+ const root = await requestFollow(base + '/');
52
+ // Only a genuine 2xx page supports a verdict about its headers.
53
+ if (!isErr(root) && root.status >= 200 && root.status < 300) {
54
+ for (const h of SECURITY_HEADERS) {
55
+ if (!root.headers.get(h.header)) {
56
+ findings.push({
57
+ id: h.id,
58
+ severity: 'warning',
59
+ title: h.title,
60
+ detail: `The response for ${base}/ does not set ${h.header}.`,
61
+ fix: `Add the ${h.header} header at the app or CDN layer.`,
62
+ checker: 'live-site',
63
+ level: 2,
64
+ endpoint: 'GET /',
65
+ });
66
+ }
67
+ }
68
+ const powered = root.headers.get('x-powered-by');
69
+ if (powered) {
70
+ findings.push({
71
+ id: 'server_disclosure',
72
+ severity: 'info',
73
+ title: 'Technology disclosed via X-Powered-By',
74
+ detail: `Response advertises "${powered}", helping an attacker fingerprint the stack.`,
75
+ fix: 'Remove or mask the X-Powered-By header.',
76
+ checker: 'live-site',
77
+ level: 2,
78
+ endpoint: 'GET /',
79
+ });
80
+ }
81
+ // Inspect each Set-Cookie separately — one hardened cookie must not mask another.
82
+ const cookies = root.hopCookies?.length ? root.hopCookies : getSetCookies(root.headers);
83
+ for (const c of cookies) {
84
+ const name = c.split('=', 1)[0]?.trim() || 'cookie';
85
+ const secure = /;\s*secure/i.test(c);
86
+ const httpOnly = /;\s*httponly/i.test(c);
87
+ if (!secure || !httpOnly) {
88
+ const missing = [!secure ? 'Secure' : null, !httpOnly ? 'HttpOnly' : null].filter(Boolean).join(' + ');
89
+ findings.push({
90
+ id: 'cookie_flags',
91
+ severity: 'warning',
92
+ title: `Cookie "${name}" missing ${missing}`,
93
+ detail: `Set-Cookie for "${name}" is missing ${missing}. If it is a session/auth cookie, that weakens it against theft.`,
94
+ fix: 'Set Secure and HttpOnly (and SameSite) on session/auth cookies.',
95
+ checker: 'live-site',
96
+ level: 2,
97
+ endpoint: 'GET /',
98
+ });
99
+ }
100
+ }
101
+ }
102
+ // Aggregate: the root page AND every file probe count. Losing any sub-check = partial;
103
+ // losing all of them = failed.
104
+ const rootBad = unreliable(root) || (!isErr(root) && (root.status < 200 || root.status >= 300));
105
+ const truncated = !isErr(root) && root.truncated === true;
106
+ const status = rootBad && fileErrors === EXPOSED.length ? 'failed' : rootBad || fileErrors > 0 || truncated ? 'partial' : 'completed';
107
+ const notes = [];
108
+ if (rootBad)
109
+ notes.push(isErr(root) ? `could not reach ${base}/: ${root.error}` : `no usable 2xx page (HTTP ${root.status}) at ${base}/`);
110
+ if (fileErrors > 0)
111
+ notes.push(`${fileErrors}/${EXPOSED.length} exposed-file probes errored`);
112
+ if (!isErr(root) && root.truncated)
113
+ notes.push('response body hit the 2 MB cap — content past it was not inspected');
114
+ return { findings, run: { id: 'live-site', level: 2, status, note: notes.length ? notes.join('; ') : undefined } };
115
+ }
116
+ /** Get individual Set-Cookie header values (undici exposes getSetCookie()). */
117
+ function getSetCookies(headers) {
118
+ const withGetter = headers;
119
+ if (typeof withGetter.getSetCookie === 'function')
120
+ return withGetter.getSetCookie();
121
+ const single = headers.get('set-cookie');
122
+ return single ? [single] : [];
123
+ }
@@ -0,0 +1,101 @@
1
+ import { concretePath } from '../../endpoints.js';
2
+ import { isErr, request, sleep, unreliable } from '../../net/http.js';
3
+ function bearer(token) {
4
+ return { Authorization: `Bearer ${token}`, accept: 'application/json' };
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;
25
+ }
26
+ }
27
+ /**
28
+ * Differential IDOR/BOLA probe. For each object-scoped endpoint, requests the
29
+ * same resource as two different users and classifies the PAIR:
30
+ * - 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.
35
+ */
36
+ export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLimitMs = 120) {
37
+ const base = appUrl.replace(/\/$/, '');
38
+ const findings = [];
39
+ // Two different, non-empty identities or the comparison proves nothing.
40
+ if (!tokenA.trim() || !tokenB.trim() || tokenA === tokenB) {
41
+ return { findings, run: { id: 'idor', level: 2, status: 'skipped', note: 'needs two different non-empty account tokens' } };
42
+ }
43
+ const idCandidates = endpoints
44
+ .filter((e) => (e.method === 'GET' || e.method === 'ANY') && /(:[A-Za-z0-9_]+|\[[^\]]+\]|\{[^}]+\})/.test(e.path));
45
+ const MAX = 40;
46
+ const idEndpoints = idCandidates.slice(0, MAX);
47
+ const droppedPairs = idCandidates.length - idEndpoints.length;
48
+ if (idEndpoints.length === 0) {
49
+ return { findings, run: { id: 'idor', level: 2, status: 'skipped', note: 'no object-scoped (id) endpoints found' } };
50
+ }
51
+ let inconclusive = 0; // a side timed out / 5xx / 429 — we learned nothing
52
+ let evaluated = 0; // both sides answered reliably
53
+ let dataSeen = 0; // at least one side returned actual data (the guessed id exists)
54
+ for (const e of idEndpoints) {
55
+ const path = concretePath(e.path).replace(/^\/?/, '/');
56
+ await sleep(rateLimitMs);
57
+ const a = await request(base + path, { headers: bearer(tokenA) });
58
+ await sleep(rateLimitMs);
59
+ const b = await request(base + path, { headers: bearer(tokenB) });
60
+ if (unreliable(a) || unreliable(b) || isErr(a) || isErr(b)) {
61
+ inconclusive++;
62
+ continue;
63
+ }
64
+ 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
+ });
83
+ }
84
+ }
85
+ const total = idEndpoints.length;
86
+ if (evaluated === 0) {
87
+ return { findings, run: { id: 'idor', level: 2, status: 'failed', note: `all ${total} pair(s) were inconclusive (timeout/5xx/429)` } };
88
+ }
89
+ if (inconclusive > 0 || droppedPairs > 0) {
90
+ const n = [];
91
+ if (inconclusive > 0)
92
+ n.push(`${inconclusive}/${total} pair(s) inconclusive (timeout/5xx/429)`);
93
+ if (droppedPairs > 0)
94
+ n.push(`only ${MAX}/${idCandidates.length} object endpoints probed (cap)`);
95
+ return { findings, run: { id: 'idor', level: 2, status: 'partial', note: n.join('; ') } };
96
+ }
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' } };
101
+ }
@@ -0,0 +1,34 @@
1
+ import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
2
+ // Public env prefixes are inlined into the browser bundle by the bundler.
3
+ const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
4
+ // 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');
6
+ export const clientExposureChecker = {
7
+ id: 'client-exposure',
8
+ title: 'Secrets exposed to the browser',
9
+ level: 0,
10
+ run(ctx) {
11
+ const findings = [];
12
+ for (const file of ctx.files) {
13
+ const { content, rel } = file;
14
+ for (const m of content.matchAll(PUBLIC_SECRET)) {
15
+ const value = m[2] ?? '';
16
+ if (looksLikePlaceholder(value))
17
+ continue;
18
+ findings.push({
19
+ id: 'public_env_secret',
20
+ severity: 'critical',
21
+ title: 'Server secret exposed via a public env var',
22
+ detail: `A public-prefixed variable carries a secret-looking value (${redact(value)}). Anything with a public prefix is shipped to the browser.`,
23
+ fix: 'Drop the public prefix, read this value only in server code, and rotate it — it may already be in a deployed bundle.',
24
+ checker: 'client-exposure',
25
+ level: 0,
26
+ file: rel,
27
+ line: lineAt(content, m.index ?? 0),
28
+ evidence: redact(value),
29
+ });
30
+ }
31
+ }
32
+ return findings;
33
+ },
34
+ };
@@ -0,0 +1,89 @@
1
+ import { lineAt } from '../../util/text.js';
2
+ import { looksMinified, maskCode } from '../../util/mask.js';
3
+ const RULES = [
4
+ {
5
+ id: 'cors_star',
6
+ title: 'CORS open to any origin',
7
+ re: /Access-Control-Allow-Origin["']?\s*[:,]\s*["']\*["']|origin\s*:\s*["']\*["']/g,
8
+ severity: 'warning',
9
+ detail: 'The API allows any origin ("*"). If it also allows credentials this is a serious CORS hole; even without credentials it widens exposure.',
10
+ fix: 'Set an explicit allowlist of origins instead of "*", and never combine "*" with credentials.',
11
+ maskStrings: false,
12
+ },
13
+ {
14
+ id: 'debug_on',
15
+ title: 'Debug mode enabled',
16
+ re: /\bDEBUG\s*=\s*True\b|debug\s*=\s*True\b|run\([^)]*debug\s*=\s*True/g,
17
+ severity: 'warning',
18
+ detail: 'Debug mode leaks stack traces and internals to visitors in production.',
19
+ fix: 'Drive debug from an env var and keep it off in production.',
20
+ maskStrings: true,
21
+ },
22
+ {
23
+ id: 'eval_use',
24
+ title: 'Use of eval / new Function',
25
+ re: /\beval\s*\(|\bnew\s+Function\s*\(/g,
26
+ severity: 'warning',
27
+ detail: 'eval on any untrusted input is a code-execution risk.',
28
+ fix: 'Replace eval with explicit parsing/logic; never eval user-supplied data.',
29
+ maskStrings: true,
30
+ },
31
+ {
32
+ id: 'sql_interpolation',
33
+ title: 'SQL built by string interpolation',
34
+ // Requires real query shape: a DML verb + a clause keyword + interpolation,
35
+ // inside one string literal — so prose mentioning "insert" won't match.
36
+ // Quantifiers are length-bounded ({0,200}) to prevent catastrophic
37
+ // backtracking (ReDoS) on very long / minified lines.
38
+ re: /`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^`]{0,200}\$\{|f["']\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^"'\n]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^"'\n]{0,200}\{/gi,
39
+ severity: 'warning',
40
+ detail: 'Interpolating values into SQL invites SQL injection.',
41
+ fix: 'Use parameterized queries / prepared statements instead of string interpolation.',
42
+ maskStrings: false,
43
+ },
44
+ {
45
+ id: 'jwt_alg_none',
46
+ title: 'JWT algorithm set to none',
47
+ re: /(?:alg|algorithm)["']?\s*[:=]\s*["']none["']/gi,
48
+ severity: 'critical',
49
+ detail: 'alg:none disables signature verification — anyone can forge a valid token.',
50
+ fix: 'Require a real signing algorithm (e.g. HS256/RS256) and reject "none".',
51
+ maskStrings: false,
52
+ },
53
+ ];
54
+ export const configRisksChecker = {
55
+ id: 'config-risks',
56
+ title: 'Dangerous configuration',
57
+ level: 0,
58
+ run(ctx) {
59
+ const findings = [];
60
+ for (const file of ctx.files) {
61
+ // Config risks in prose docs are examples, not live config — skip them.
62
+ if (file.rel.endsWith('.md') || file.rel.endsWith('.txt'))
63
+ continue;
64
+ if (looksMinified(file.rel, file.content))
65
+ continue; // generated output, not source
66
+ // Comments never trigger a rule. Strings are a per-rule decision: a rule
67
+ // 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 });
70
+ for (const rule of RULES) {
71
+ const scan = rule.maskStrings ? noStrings : noComments;
72
+ for (const m of scan.matchAll(rule.re)) {
73
+ findings.push({
74
+ id: rule.id,
75
+ severity: rule.severity,
76
+ title: rule.title,
77
+ detail: rule.detail,
78
+ fix: rule.fix,
79
+ checker: 'config-risks',
80
+ level: 0,
81
+ file: file.rel,
82
+ line: lineAt(file.content, m.index ?? 0),
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return findings;
88
+ },
89
+ };
@@ -0,0 +1,70 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function gitOk(root, args) {
3
+ try {
4
+ execFileSync('git', ['-C', root, ...args], { stdio: 'ignore', timeout: 5000 });
5
+ return true;
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ /**
12
+ * Ensures .env files are actually kept out of Git, using Git's own semantics
13
+ * (check-ignore + ls-files) rather than a text heuristic. A tracked .env is the
14
+ * real leak; an un-ignored .env is a risk of becoming one.
15
+ */
16
+ export const envGitChecker = {
17
+ id: 'env-git',
18
+ title: '.env exposure via git',
19
+ level: 0,
20
+ run(ctx) {
21
+ const envFiles = ctx.files.filter((f) => f.rel === '.env' || (/(^|\/)\.env(\.|$)/.test(f.rel) && !f.rel.endsWith('.example') && !f.rel.endsWith('.sample')));
22
+ if (envFiles.length === 0)
23
+ return [];
24
+ const findings = [];
25
+ // Ask git itself: a subdirectory of a repo (monorepo package) is still in git.
26
+ const isGit = gitOk(ctx.root, ['rev-parse', '--git-dir']);
27
+ if (!isGit) {
28
+ findings.push({
29
+ id: 'env_git_unverified',
30
+ severity: 'info',
31
+ title: 'Cannot verify .env is ignored (not a git repo)',
32
+ detail: `Found ${envFiles.map((f) => f.rel).join(', ')}, but this folder is not a git repository, so tracking cannot be checked here.`,
33
+ fix: 'Before pushing, ensure a .gitignore ignores .env files (e.g. `.env*`), and never commit real secrets.',
34
+ checker: 'env-git',
35
+ level: 0,
36
+ });
37
+ return findings;
38
+ }
39
+ for (const env of envFiles) {
40
+ const tracked = gitOk(ctx.root, ['ls-files', '--error-unmatch', '--', env.rel]);
41
+ if (tracked) {
42
+ findings.push({
43
+ id: 'env_committed',
44
+ severity: 'critical',
45
+ title: `${env.rel} is committed to git`,
46
+ detail: `${env.rel} is tracked by git — its secrets are in the repository (and its history), reachable by anyone with repo access.`,
47
+ fix: `Untrack it: git rm --cached ${env.rel}; add it to .gitignore; and ROTATE every secret it contained (a committed secret is already burned; deleting it does not un-leak history).`,
48
+ checker: 'env-git',
49
+ level: 0,
50
+ file: env.rel,
51
+ });
52
+ continue;
53
+ }
54
+ const ignored = gitOk(ctx.root, ['check-ignore', '-q', '--', env.rel]);
55
+ if (!ignored) {
56
+ findings.push({
57
+ id: 'env_not_ignored',
58
+ severity: 'warning',
59
+ title: `${env.rel} is not gitignored`,
60
+ detail: `${env.rel} is not tracked yet, but no .gitignore rule matches it, so it can be committed by accident.`,
61
+ fix: 'Add a matching rule to .gitignore (e.g. `.env*`) so it can never be committed.',
62
+ checker: 'env-git',
63
+ level: 0,
64
+ file: env.rel,
65
+ });
66
+ }
67
+ }
68
+ return findings;
69
+ },
70
+ };