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
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { decodeJwtPayload } from '../../util/text.js';
|
|
2
|
-
import { isErr, request, sleep
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
return { error: `PostgREST
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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)
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 =
|
|
269
|
+
const totalErr = lost.length;
|
|
246
270
|
const status = totalErr === 0 ? 'completed' : totalErr < attempted ? 'partial' : 'failed';
|
|
247
|
-
const note = totalErr > 0
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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'
|
|
52
|
-
|
|
111
|
+
if (obj.type === 'auditSummary')
|
|
112
|
+
counts = validCounts(obj.data?.vulnerabilities);
|
|
53
113
|
}
|
|
54
114
|
catch { /* skip */ }
|
|
55
115
|
}
|
|
56
|
-
if (!
|
|
57
|
-
return failed('
|
|
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('
|
|
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
|
|
76
|
-
return failed('audit returned an error
|
|
134
|
+
if (typeof parsed !== 'object' || parsed === null || parsed.error !== undefined) {
|
|
135
|
+
return failed('audit returned an error envelope');
|
|
77
136
|
}
|
|
78
|
-
counts = parsed.metadata
|
|
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
|
|
82
|
-
const
|
|
83
|
-
const
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
58
|
+
const status = verified === 0 ? 'failed' : unverified.length > 0 || dropped > 0 ? 'partial' : 'completed';
|
|
65
59
|
const notes = [];
|
|
66
|
-
if (
|
|
67
|
-
|
|
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,
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 } };
|