shippable-dev 0.1.0

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.
@@ -0,0 +1,2119 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../engine/src/cli.ts
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ // ../engine/src/types.ts
9
+ var SEVERITY_RANK = {
10
+ critical: 5,
11
+ high: 4,
12
+ medium: 3,
13
+ low: 2,
14
+ info: 1
15
+ };
16
+ function worstSeverity(findings) {
17
+ let worst = null;
18
+ for (const f of findings) {
19
+ if (worst === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[worst]) {
20
+ worst = f.severity;
21
+ }
22
+ }
23
+ return worst;
24
+ }
25
+
26
+ // ../engine/src/fetchClient.ts
27
+ var MAX_SCRIPTS = 20;
28
+ var MAX_CHUNKS = 30;
29
+ var MAX_CHUNK_BYTES_TOTAL = 15e6;
30
+ function extractScriptSrcs(html, pageUrl) {
31
+ const out = [];
32
+ const re = /<script[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
33
+ let m;
34
+ while ((m = re.exec(html)) !== null) {
35
+ const raw = m[1];
36
+ if (!raw) continue;
37
+ try {
38
+ out.push(new URL(raw, pageUrl).toString());
39
+ } catch {
40
+ }
41
+ }
42
+ return [...new Set(out)];
43
+ }
44
+ function extractChunkRefs(text, baseUrl) {
45
+ const out = /* @__PURE__ */ new Set();
46
+ const add = (raw) => {
47
+ if (!raw) return;
48
+ try {
49
+ out.add(new URL(raw, baseUrl).toString());
50
+ } catch {
51
+ }
52
+ };
53
+ const addRootRelative = (raw) => {
54
+ if (!raw) return;
55
+ add((raw.startsWith("/") ? raw : "/" + raw).replace(/^\/+/, "/"));
56
+ };
57
+ const importRe = /\bimport\(\s*["'`]([^"'`]+\.js)["'`]\s*\)/g;
58
+ let m;
59
+ while ((m = importRe.exec(text)) !== null) add(m[1]);
60
+ const assetLiteralRe = /["'](\.?\/?(?:assets|static\/chunks)\/[^"'\s]+\.js)["']/g;
61
+ while ((m = assetLiteralRe.exec(text)) !== null) addRootRelative(m[1]);
62
+ for (const tag of text.match(/<link\b[^>]*>/gi) ?? []) {
63
+ if (!/rel=["']modulepreload["']/i.test(tag)) continue;
64
+ const href = /href=["']([^"']+)["']/i.exec(tag)?.[1];
65
+ if (href && /\.js($|\?)/.test(href)) add(href);
66
+ }
67
+ return [...out];
68
+ }
69
+ function sameOrigin(a, b) {
70
+ try {
71
+ return new URL(a).origin === new URL(b).origin;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ function isJsUrl(url) {
77
+ try {
78
+ return new URL(url).pathname.endsWith(".js");
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+ async function fetchApp(ctx, pageUrl) {
84
+ const notes = [];
85
+ const page = await ctx.http(pageUrl, { method: "GET" });
86
+ let combined = page.text;
87
+ const scriptsFetched = [];
88
+ const scripts = [];
89
+ const srcs = extractScriptSrcs(page.text, pageUrl).filter((s) => sameOrigin(s, pageUrl)).slice(0, MAX_SCRIPTS);
90
+ const discovered = new Set(srcs);
91
+ for (const src of srcs) {
92
+ try {
93
+ const res = await ctx.http(src, { method: "GET" });
94
+ if (res.status >= 200 && res.status < 300) {
95
+ combined += "\n/*__" + src + "__*/\n" + res.text;
96
+ scriptsFetched.push(src);
97
+ scripts.push({ url: src, text: res.text });
98
+ } else {
99
+ notes.push(`Skipped ${src} (status ${res.status}).`);
100
+ }
101
+ } catch {
102
+ notes.push(`Could not fetch ${src}.`);
103
+ }
104
+ }
105
+ const queue = [];
106
+ const enqueue = (refs) => {
107
+ for (const ref of refs) {
108
+ if (discovered.has(ref) || !sameOrigin(ref, pageUrl) || !isJsUrl(ref)) continue;
109
+ discovered.add(ref);
110
+ queue.push(ref);
111
+ }
112
+ };
113
+ enqueue(extractChunkRefs(page.text, pageUrl));
114
+ for (const s of scripts) enqueue(extractChunkRefs(s.text, s.url));
115
+ let chunksFetched = 0;
116
+ let chunkBytesTotal = 0;
117
+ while (queue.length > 0 && chunksFetched < MAX_CHUNKS && chunkBytesTotal < MAX_CHUNK_BYTES_TOTAL) {
118
+ const url = queue.shift();
119
+ try {
120
+ const res = await ctx.http(url, { method: "GET" });
121
+ if (res.status < 200 || res.status >= 300) continue;
122
+ if (res.text.length > ctx.maxBytesPerAsset) continue;
123
+ chunksFetched++;
124
+ chunkBytesTotal += res.text.length;
125
+ combined += "\n/*__" + url + "__*/\n" + res.text;
126
+ scriptsFetched.push(url);
127
+ scripts.push({ url, text: res.text });
128
+ enqueue(extractChunkRefs(res.text, url));
129
+ } catch {
130
+ }
131
+ }
132
+ if (chunksFetched > 0) {
133
+ notes.push(`Scanned ${chunksFetched} code chunk${chunksFetched === 1 ? "" : "s"}.`);
134
+ }
135
+ return {
136
+ finalStatus: page.status,
137
+ headers: page.headers,
138
+ setCookie: page.setCookie ?? [],
139
+ combinedSource: combined,
140
+ scriptsFetched,
141
+ scripts,
142
+ notes
143
+ };
144
+ }
145
+
146
+ // ../engine/src/checks/secrets.ts
147
+ function redact(value) {
148
+ if (value.length <= 12) return value.slice(0, 3) + "\u2026";
149
+ return value.slice(0, 6) + "\u2026" + value.slice(-4);
150
+ }
151
+ function decodeJwt(token) {
152
+ const parts = token.split(".");
153
+ if (parts.length !== 3) return null;
154
+ const payload = parts[1];
155
+ if (!payload) return null;
156
+ try {
157
+ const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
158
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
159
+ const json = Buffer.from(b64 + pad, "base64").toString("utf8");
160
+ const obj = JSON.parse(json);
161
+ return typeof obj === "object" && obj !== null ? obj : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+ var JWT_RE = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
167
+ var SUPABASE_URL_RE = /https:\/\/([a-z0-9]{16,40})\.supabase\.co/g;
168
+ var STRIPE_SECRET_RE = /\b([rs]k_(?:live|test)_[A-Za-z0-9]{16,})/g;
169
+ var STRIPE_PUB_RE = /\bpk_(?:live|test)_[A-Za-z0-9]{16,}/g;
170
+ var OPENAI_RE = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g;
171
+ var ANTHROPIC_RE = /\bsk-ant-[A-Za-z0-9_-]{20,}/g;
172
+ var AWS_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
173
+ var GOOGLE_API_RE = /\bAIza[A-Za-z0-9_-]{30,}\b/g;
174
+ var GITHUB_RE = /\b(?:ghp_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/g;
175
+ var RESEND_RE = /\bre_[A-Za-z0-9]{32,40}\b/g;
176
+ var SENDGRID_RE = /\bSG\.[A-Za-z0-9_-]{16,32}\.[A-Za-z0-9_-]{16,64}\b/g;
177
+ var TWILIO_KEY_RE = /\bSK[0-9a-fA-F]{32}\b/g;
178
+ var MAILGUN_RE = /\bkey-[0-9a-fA-F]{32}\b/g;
179
+ var GROQ_RE = /\bgsk_[A-Za-z0-9]{20,}\b/g;
180
+ var REPLICATE_RE = /\br8_[A-Za-z0-9]{20,}\b/g;
181
+ function pushAll(out, seen, kind, matches) {
182
+ for (const value of matches) {
183
+ const dedupeKey = kind + ":" + value;
184
+ if (seen.has(dedupeKey)) continue;
185
+ seen.add(dedupeKey);
186
+ out.push({ kind, value, redacted: redact(value) });
187
+ }
188
+ }
189
+ function extractSecrets(text) {
190
+ const out = [];
191
+ const seen = /* @__PURE__ */ new Set();
192
+ pushAll(out, seen, "anthropic-key", text.match(ANTHROPIC_RE) ?? []);
193
+ const anthropicValues = new Set(out.filter((h) => h.kind === "anthropic-key").map((h) => h.value));
194
+ const openaiMatches = (text.match(OPENAI_RE) ?? []).filter(
195
+ (m) => ![...anthropicValues].some((a) => a.startsWith(m) || m.startsWith("sk-ant"))
196
+ );
197
+ pushAll(out, seen, "openai-key", openaiMatches);
198
+ pushAll(out, seen, "stripe-secret", text.match(STRIPE_SECRET_RE) ?? []);
199
+ pushAll(out, seen, "stripe-publishable", text.match(STRIPE_PUB_RE) ?? []);
200
+ pushAll(out, seen, "aws-access-key", text.match(AWS_RE) ?? []);
201
+ pushAll(out, seen, "google-api-key", text.match(GOOGLE_API_RE) ?? []);
202
+ pushAll(out, seen, "github-token", text.match(GITHUB_RE) ?? []);
203
+ pushAll(out, seen, "resend-key", text.match(RESEND_RE) ?? []);
204
+ pushAll(out, seen, "sendgrid-key", text.match(SENDGRID_RE) ?? []);
205
+ pushAll(out, seen, "twilio-key", text.match(TWILIO_KEY_RE) ?? []);
206
+ pushAll(out, seen, "mailgun-key", text.match(MAILGUN_RE) ?? []);
207
+ pushAll(out, seen, "groq-key", text.match(GROQ_RE) ?? []);
208
+ pushAll(out, seen, "replicate-key", text.match(REPLICATE_RE) ?? []);
209
+ if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text) && !seen.has("gcp-service-account:present")) {
210
+ seen.add("gcp-service-account:present");
211
+ out.push({
212
+ kind: "gcp-service-account",
213
+ value: "[not captured]",
214
+ redacted: "service-account JSON with private_key"
215
+ });
216
+ }
217
+ for (const m of new Set(text.match(SUPABASE_URL_RE) ?? [])) {
218
+ if (!seen.has("supabase-url:" + m)) {
219
+ seen.add("supabase-url:" + m);
220
+ out.push({ kind: "supabase-url", value: m, redacted: m });
221
+ }
222
+ }
223
+ for (const token of new Set(text.match(JWT_RE) ?? [])) {
224
+ const payload = decodeJwt(token);
225
+ const role = payload?.["role"];
226
+ let kind = "generic-jwt";
227
+ if (role === "service_role") kind = "supabase-service-key";
228
+ else if (role === "anon") kind = "supabase-anon-key";
229
+ if (!seen.has(kind + ":" + token)) {
230
+ seen.add(kind + ":" + token);
231
+ out.push({ kind, value: token, redacted: redact(token) });
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+ function getSupabaseConnection(hits) {
237
+ const url = hits.find((h) => h.kind === "supabase-url")?.value;
238
+ const anonKey2 = hits.find((h) => h.kind === "supabase-anon-key")?.value;
239
+ if (url && anonKey2) return { url, anonKey: anonKey2 };
240
+ return null;
241
+ }
242
+ var CRITICAL_LEAK_META = {
243
+ "supabase-service-key": {
244
+ title: "Supabase service_role key exposed in the browser",
245
+ why: "The service_role key bypasses ALL Row-Level Security. Anyone reading your page source has full read/write admin access to your entire database.",
246
+ rotate: "Rotate the service_role key in Supabase \u2192 Settings \u2192 API immediately, and remove it from all client-side code \u2014 it must only ever live in server/edge functions."
247
+ },
248
+ "stripe-secret": {
249
+ title: "Stripe secret key exposed in the browser",
250
+ why: "A Stripe secret key lets anyone create charges, refunds, and read customer/payment data on your account.",
251
+ rotate: "Roll the key now in the Stripe Dashboard \u2192 Developers \u2192 API keys, and move it to a server-side environment variable."
252
+ },
253
+ "openai-key": {
254
+ title: "OpenAI API key exposed in the browser",
255
+ why: "Anyone can run up unlimited billed usage on your OpenAI account with this key.",
256
+ rotate: "Revoke the key at platform.openai.com/api-keys and call OpenAI only from a server/edge function."
257
+ },
258
+ "anthropic-key": {
259
+ title: "Anthropic API key exposed in the browser",
260
+ why: "Anyone can run up unlimited billed usage on your Anthropic account with this key.",
261
+ rotate: "Revoke the key in the Anthropic Console and call the API only from a server/edge function."
262
+ },
263
+ "aws-access-key": {
264
+ title: "AWS access key exposed in the browser",
265
+ why: "Depending on its IAM permissions this can grant access to your cloud resources and data.",
266
+ rotate: "Deactivate the key in AWS IAM immediately and audit CloudTrail for misuse."
267
+ },
268
+ "github-token": {
269
+ title: "GitHub token exposed in the browser",
270
+ why: "Can grant read/write access to your repositories.",
271
+ rotate: "Revoke the token in GitHub \u2192 Settings \u2192 Developer settings immediately."
272
+ },
273
+ "resend-key": {
274
+ title: "Resend API key exposed in the browser",
275
+ why: "Anyone can use this key to send email as your Resend account \u2014 running up usage or sending spam/phishing that appears to come from you.",
276
+ rotate: "Revoke the key in the Resend dashboard \u2192 API Keys and issue a new one."
277
+ },
278
+ "sendgrid-key": {
279
+ title: "SendGrid API key exposed in the browser",
280
+ why: "Anyone can send email through your SendGrid account with this key, and depending on its scopes may also read your contacts and stats.",
281
+ rotate: "Revoke the key in SendGrid \u2192 Settings \u2192 API Keys and create a new one."
282
+ },
283
+ "twilio-key": {
284
+ title: "Twilio API key exposed in the browser",
285
+ why: "This key can send SMS/voice messages and access your Twilio account, running up charges on your bill.",
286
+ rotate: "Delete the key in Twilio Console \u2192 Account \u2192 API keys & tokens and create a new one."
287
+ },
288
+ "mailgun-key": {
289
+ title: "Mailgun API key exposed in the browser",
290
+ why: "Anyone can send email through your Mailgun account with this key.",
291
+ rotate: "Rotate the key in the Mailgun dashboard \u2192 API Keys."
292
+ },
293
+ "groq-key": {
294
+ title: "Groq API key exposed in the browser",
295
+ why: "Anyone can run up unlimited billed usage on your Groq account with this key.",
296
+ rotate: "Revoke the key at console.groq.com/keys and call Groq only from a server/edge function."
297
+ },
298
+ "replicate-key": {
299
+ title: "Replicate API key exposed in the browser",
300
+ why: "Anyone can run models on your Replicate account with this key, running up your bill.",
301
+ rotate: "Revoke the key at replicate.com/account/api-tokens and create a new one."
302
+ },
303
+ "gcp-service-account": {
304
+ title: "Google Cloud service-account key exposed in the browser",
305
+ why: "A service-account JSON key can grant broad, standing access to your Google Cloud project's resources and data \u2014 far more than a normal API key.",
306
+ rotate: "Delete or disable this service-account key in Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts immediately, and check Cloud Audit Logs for misuse."
307
+ }
308
+ };
309
+ function secretFindings(hits, location) {
310
+ const findings = [];
311
+ for (const hit of hits) {
312
+ const meta = CRITICAL_LEAK_META[hit.kind];
313
+ if (meta) {
314
+ findings.push({
315
+ id: `secret:${hit.kind}:${hit.redacted}`,
316
+ category: "secret",
317
+ severity: "critical",
318
+ confidence: "proven",
319
+ title: meta.title,
320
+ description: meta.why,
321
+ location,
322
+ proof: {
323
+ summary: `Found ${hit.kind} (${hit.redacted}) in publicly served code.`,
324
+ evidence: hit.redacted
325
+ },
326
+ fix: {
327
+ steps: [meta.rotate],
328
+ aiPrompt: `Security fix: a secret key (${meta.title.replace(/ exposed in the browser$/, "")}) is shipped in my app's frontend code, where anyone can read it. Move every use of this key into a server-side function or API route that reads it from an environment variable, remove it from all client-side code, and make sure it is never bundled into the browser again. I will rotate the key myself.`
329
+ },
330
+ reverify: { check: "secret-scan", target: hit.kind }
331
+ });
332
+ }
333
+ if (hit.kind === "google-api-key") {
334
+ findings.push({
335
+ id: `secret:google-api-key:${hit.redacted}`,
336
+ category: "secret",
337
+ severity: "low",
338
+ confidence: "info",
339
+ title: "Google API key present in client code",
340
+ description: "A Google API key is in your bundle. This is normal for Firebase and Maps keys, but only if the key is restricted. An unrestricted key can be abused to run up billed usage.",
341
+ location,
342
+ proof: { summary: `Found google-api-key (${hit.redacted}) in publicly served code.` },
343
+ fix: {
344
+ steps: [
345
+ "In Google Cloud Console \u2192 Credentials, add HTTP-referrer and API restrictions to this key so only your domain and intended APIs can use it."
346
+ ],
347
+ aiPrompt: "My app ships a Google API key in its frontend. Tell me exactly which HTTP-referrer and API restrictions to set on this key in Google Cloud Console so only my domain and the APIs my app actually uses can call it, and check the code uses no other Google APIs that would break."
348
+ },
349
+ reverify: { check: "secret-scan", target: "google-api-key" }
350
+ });
351
+ }
352
+ }
353
+ return findings;
354
+ }
355
+
356
+ // ../engine/src/checks/harvest.ts
357
+ var NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,62}$/;
358
+ function isValidName(name) {
359
+ return NAME_RE.test(name);
360
+ }
361
+ function dedupeNames(names) {
362
+ return [...new Set(names)];
363
+ }
364
+ function extractQuoted(re, text) {
365
+ const out = /* @__PURE__ */ new Set();
366
+ let m;
367
+ while ((m = re.exec(text)) !== null) {
368
+ const name = m[2];
369
+ if (name && isValidName(name)) out.add(name);
370
+ }
371
+ return out;
372
+ }
373
+ var SUPABASE_FROM_RE = /\.from\(\s*(["'`])([^"'`]{0,80})\1\s*\)/g;
374
+ var SUPABASE_RPC_RE = /\.rpc\(\s*(["'`])([^"'`]{0,80})\1/g;
375
+ var FIRESTORE_COLLECTION_CALL_RE = /\bcollection\(\s*[A-Za-z_$][\w$]*\s*,\s*(["'`])([^"'`]{0,80})\1/g;
376
+ var FIRESTORE_DOC_CALL_RE = /\bdoc\(\s*[A-Za-z_$][\w$]*\s*,\s*(["'`])([^"'`]{0,80})\1/g;
377
+ var FIRESTORE_NAMESPACED_RE = /\.collection\(\s*(["'`])([^"'`]{0,80})\1/g;
378
+ function harvestSupabaseTableNames(text) {
379
+ return dedupeNames([...extractQuoted(SUPABASE_FROM_RE, text)]);
380
+ }
381
+ function harvestSupabaseRpcNames(text) {
382
+ return dedupeNames([...extractQuoted(SUPABASE_RPC_RE, text)]);
383
+ }
384
+ function harvestFirestoreCollectionNames(text) {
385
+ const out = /* @__PURE__ */ new Set();
386
+ for (const n of extractQuoted(FIRESTORE_COLLECTION_CALL_RE, text)) out.add(n);
387
+ for (const n of extractQuoted(FIRESTORE_DOC_CALL_RE, text)) out.add(n);
388
+ for (const n of extractQuoted(FIRESTORE_NAMESPACED_RE, text)) out.add(n);
389
+ return dedupeNames([...out]);
390
+ }
391
+
392
+ // ../engine/src/checks/supabase.ts
393
+ var MAX_TABLE_PROBES = 25;
394
+ function authHeaders(conn) {
395
+ return {
396
+ apikey: conn.anonKey,
397
+ Authorization: `Bearer ${conn.anonKey}`
398
+ };
399
+ }
400
+ function restBase(conn) {
401
+ return conn.url.replace(/\/+$/, "") + "/rest/v1";
402
+ }
403
+ function parseTables(openApiText) {
404
+ let doc;
405
+ try {
406
+ doc = JSON.parse(openApiText);
407
+ } catch {
408
+ return [];
409
+ }
410
+ if (typeof doc !== "object" || doc === null) return [];
411
+ const tables = /* @__PURE__ */ new Set();
412
+ const defs = doc["definitions"];
413
+ if (defs && typeof defs === "object") {
414
+ for (const name of Object.keys(defs)) {
415
+ if (!name.startsWith("(") && !name.startsWith("rpc")) tables.add(name);
416
+ }
417
+ }
418
+ const paths = doc["paths"];
419
+ if (paths && typeof paths === "object") {
420
+ for (const p of Object.keys(paths)) {
421
+ if (p === "/") continue;
422
+ if (p.startsWith("/rpc/")) continue;
423
+ const name = p.replace(/^\//, "");
424
+ if (name && !name.includes("/")) tables.add(name);
425
+ }
426
+ }
427
+ return [...tables];
428
+ }
429
+ function parseRpcFunctions(openApiText) {
430
+ let doc;
431
+ try {
432
+ doc = JSON.parse(openApiText);
433
+ } catch {
434
+ return [];
435
+ }
436
+ if (typeof doc !== "object" || doc === null) return [];
437
+ const paths = doc["paths"];
438
+ const fns = /* @__PURE__ */ new Set();
439
+ if (paths && typeof paths === "object") {
440
+ for (const p of Object.keys(paths)) {
441
+ if (p.startsWith("/rpc/")) {
442
+ const name = p.slice("/rpc/".length);
443
+ if (name) fns.add(name);
444
+ }
445
+ }
446
+ }
447
+ return [...fns];
448
+ }
449
+ async function fetchOpenApiDocText(ctx, conn) {
450
+ const res = await ctx.http(restBase(conn) + "/", {
451
+ method: "GET",
452
+ headers: authHeaders(conn)
453
+ });
454
+ if (res.status >= 400) return null;
455
+ return res.text;
456
+ }
457
+ var FALLBACK_TABLE_WORDLIST = [
458
+ // English
459
+ "users",
460
+ "user",
461
+ "profiles",
462
+ "profile",
463
+ "accounts",
464
+ "posts",
465
+ "projects",
466
+ "orders",
467
+ "payments",
468
+ "subscriptions",
469
+ "invoices",
470
+ "customers",
471
+ "waitlist",
472
+ "contacts",
473
+ "leads",
474
+ "messages",
475
+ "chats",
476
+ "images",
477
+ "files",
478
+ "uploads",
479
+ "comments",
480
+ "quotes",
481
+ "bookings",
482
+ "reviews",
483
+ "notifications",
484
+ "sessions",
485
+ "teams",
486
+ "organizations",
487
+ "members",
488
+ "settings",
489
+ "logs",
490
+ "events",
491
+ "todos",
492
+ "products",
493
+ "carts",
494
+ "transactions",
495
+ "api_keys",
496
+ "tokens",
497
+ "secrets",
498
+ // Portuguese (BR)
499
+ "usuarios",
500
+ "usuario",
501
+ "clientes",
502
+ "cliente",
503
+ "pedidos",
504
+ "pagamentos",
505
+ "projetos",
506
+ "projeto",
507
+ "produtos",
508
+ "contatos",
509
+ "mensagens",
510
+ "orcamentos",
511
+ "assinaturas",
512
+ "faturas",
513
+ "renders",
514
+ "imagens",
515
+ "arquivos"
516
+ ];
517
+ async function tableReachable(ctx, conn, table) {
518
+ try {
519
+ const res = await ctx.http(
520
+ `${restBase(conn)}/${encodeURIComponent(table)}?select=*&limit=1`,
521
+ { method: "GET", headers: authHeaders(conn) }
522
+ );
523
+ return res.status >= 200 && res.status < 300;
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+ async function discoverTables(ctx, conn, harvestedTables = []) {
529
+ const text = await fetchOpenApiDocText(ctx, conn);
530
+ const introspected = text ? parseTables(text) : [];
531
+ if (introspected.length > 0) {
532
+ const tables = dedupeNames([...introspected, ...harvestedTables]).slice(0, MAX_TABLE_PROBES);
533
+ return { tables, viaFallback: false, rpcFunctions: text ? parseRpcFunctions(text) : [] };
534
+ }
535
+ const candidates = dedupeNames([...harvestedTables, ...FALLBACK_TABLE_WORDLIST]).slice(
536
+ 0,
537
+ MAX_TABLE_PROBES
538
+ );
539
+ const reachable = [];
540
+ for (const t of candidates) {
541
+ if (await tableReachable(ctx, conn, t)) reachable.push(t);
542
+ }
543
+ return { tables: reachable, viaFallback: true, rpcFunctions: [] };
544
+ }
545
+ function parseContentRangeTotal(headers) {
546
+ const cr = headers["content-range"] ?? headers["Content-Range"];
547
+ if (!cr) return null;
548
+ const m = cr.match(/\/(\d+)$/);
549
+ return m && m[1] ? Number(m[1]) : null;
550
+ }
551
+ function firstRowKeys(text) {
552
+ try {
553
+ const arr = JSON.parse(text);
554
+ if (Array.isArray(arr) && arr.length > 0 && typeof arr[0] === "object" && arr[0]) {
555
+ return Object.keys(arr[0]).slice(0, 12);
556
+ }
557
+ } catch {
558
+ }
559
+ return [];
560
+ }
561
+ function rowCount(text) {
562
+ try {
563
+ const arr = JSON.parse(text);
564
+ return Array.isArray(arr) ? arr.length : 0;
565
+ } catch {
566
+ return 0;
567
+ }
568
+ }
569
+ async function probeTableRead(ctx, conn, table) {
570
+ const url = `${restBase(conn)}/${encodeURIComponent(table)}?select=*&limit=3`;
571
+ let res;
572
+ try {
573
+ res = await ctx.http(url, {
574
+ method: "GET",
575
+ headers: { ...authHeaders(conn), Prefer: "count=estimated" }
576
+ });
577
+ } catch {
578
+ return {
579
+ table,
580
+ readable: false,
581
+ rowsReturned: 0,
582
+ totalEstimate: null,
583
+ sampleKeys: [],
584
+ blocked: true,
585
+ status: 0
586
+ };
587
+ }
588
+ const blocked = res.status === 401 || res.status === 403;
589
+ const rows = res.status >= 200 && res.status < 300 ? rowCount(res.text) : 0;
590
+ return {
591
+ table,
592
+ readable: rows > 0,
593
+ rowsReturned: rows,
594
+ totalEstimate: parseContentRangeTotal(res.headers),
595
+ sampleKeys: rows > 0 ? firstRowKeys(res.text) : [],
596
+ blocked,
597
+ status: res.status
598
+ };
599
+ }
600
+ var SENSITIVE_COLUMN_PATTERNS = [
601
+ /email/i,
602
+ /phone/i,
603
+ /pass(word)?/i,
604
+ /hash/i,
605
+ /token/i,
606
+ /secret/i,
607
+ /api[_-]?key/i,
608
+ /ssn/i,
609
+ /tax/i,
610
+ /address/i,
611
+ /\bdob\b/i,
612
+ /birth/i,
613
+ /stripe/i,
614
+ /card/i,
615
+ /payment/i,
616
+ /iban/i
617
+ ];
618
+ function sensitiveColumns(keys) {
619
+ return keys.filter((k) => SENSITIVE_COLUMN_PATTERNS.some((re) => re.test(k)));
620
+ }
621
+ function rlsSql(table) {
622
+ return [
623
+ `-- 1. Turn on Row-Level Security for the exposed table:`,
624
+ `ALTER TABLE public.${table} ENABLE ROW LEVEL SECURITY;`,
625
+ ``,
626
+ `-- 2. Add a policy that grants ONLY the access you intend. Example:`,
627
+ `-- (assumes the table has a user_id column identifying the owner)`,
628
+ `CREATE POLICY "${table}_owner_can_read"`,
629
+ ` ON public.${table} FOR SELECT`,
630
+ ` USING (auth.uid() = user_id);`,
631
+ ``,
632
+ `-- If this table is meant to be fully public, that is fine \u2014 but confirm`,
633
+ `-- it contains no private data before leaving it open.`
634
+ ].join("\n");
635
+ }
636
+ function rlsAiPrompt(table) {
637
+ return `Security fix: the Supabase table "${table}" is currently readable by anyone on the internet using the public anon key, because Row-Level Security is not restricting it. Enable RLS on public.${table} and add policies so each user can only read and write their own rows (match auth.uid() to the owner column). Do not allow anonymous or cross-user access to this table.`;
638
+ }
639
+ function rpcFindings(conn, rpcFunctions) {
640
+ if (rpcFunctions.length === 0) return [];
641
+ return [
642
+ {
643
+ id: "rls:rpc-functions",
644
+ category: "rls",
645
+ severity: "low",
646
+ confidence: "info",
647
+ title: `${rpcFunctions.length} database function(s) callable by anyone with the public key`,
648
+ description: "Your Supabase project exposes these Postgres functions (RPC endpoints) to anyone holding the public anon key \u2014 which includes every visitor to your site, since that key ships in your page source. We have not called any of them (that would not be read-only), but each one should check that the caller is allowed to do whatever it does.",
649
+ location: `${restBase(conn)}/rpc/`,
650
+ proof: { summary: `Function(s) exposed via PostgREST: ${rpcFunctions.join(", ")}.` },
651
+ fix: {
652
+ steps: [
653
+ "For each function, confirm it checks the caller's identity/permissions (e.g. auth.uid()) before doing anything sensitive, or revoke EXECUTE from the anon/authenticated roles if it shouldn't be public."
654
+ ],
655
+ aiPrompt: `My Supabase project exposes these database functions to anonymous users via PostgREST RPC: ${rpcFunctions.join(", ")}. For each one, check that the function body enforces proper authorization (e.g. an auth.uid() check) before doing anything sensitive, and lock down EXECUTE permissions from anon/public for any that shouldn't be callable by anyone.`
656
+ },
657
+ reverify: { check: "supabase-rpc-list" }
658
+ }
659
+ ];
660
+ }
661
+ async function probeRls(ctx, conn, harvestedTables = [], harvestedRpc = []) {
662
+ const findings = [];
663
+ const { tables, viaFallback, rpcFunctions } = await discoverTables(ctx, conn, harvestedTables);
664
+ const harvestedSet = new Set(harvestedTables);
665
+ ctx.log(
666
+ `Supabase: ${tables.length} table(s) reachable by the anon role` + (viaFallback ? " (via fallback wordlist \u2014 introspection was blocked)." : ".")
667
+ );
668
+ const reachableEmpty = [];
669
+ for (const table of tables) {
670
+ const r = await probeTableRead(ctx, conn, table);
671
+ if (!r.readable && r.status >= 200 && r.status < 300) {
672
+ reachableEmpty.push(table);
673
+ }
674
+ if (r.readable) {
675
+ const total = r.totalEstimate !== null ? ` (table holds ~${r.totalEstimate} rows total)` : "";
676
+ const sensitive = sensitiveColumns(r.sampleKeys);
677
+ const sensitivePhrase = sensitive.length > 0 ? ` It exposes ${sensitive.join(", ")}.` : "";
678
+ const fromCodePhrase = harvestedSet.has(table) ? " \u2014 a table name found in your app's code" : "";
679
+ const rowsPhrase = r.totalEstimate !== null ? `about ${r.totalEstimate.toLocaleString("en-US")} rows` : "rows";
680
+ const includingPhrase = sensitive.length > 0 ? `, including ${sensitive.join(", ")}` : "";
681
+ const impact = `Anyone can download ${rowsPhrase} from \`${table}\`${includingPhrase}.`;
682
+ findings.push({
683
+ id: `rls:${table}`,
684
+ category: "rls",
685
+ severity: "critical",
686
+ confidence: "proven",
687
+ title: `Table "${table}" is readable by anyone`,
688
+ description: `${impact} Using only the public anon key from your app's page source, an anonymous user can read rows from "${table}"${fromCodePhrase}, regardless of whether Row-Level Security is enabled on the table \u2014 a table can have RLS turned on and still carry a policy that allows public reads.${sensitivePhrase}`,
689
+ location: `${restBase(conn)}/${table}`,
690
+ proof: {
691
+ summary: `Read ${r.rowsReturned} row(s) from "${table}" anonymously${total}.` + (sensitive.length > 0 ? ` Exposes: ${sensitive.join(", ")}.` : ""),
692
+ evidence: r.sampleKeys.length > 0 ? `Columns exposed: ${r.sampleKeys.join(", ")}` : void 0
693
+ },
694
+ fix: { sql: rlsSql(table), aiPrompt: rlsAiPrompt(table) },
695
+ reverify: { check: "supabase-rls-read", target: table }
696
+ });
697
+ }
698
+ }
699
+ if (reachableEmpty.length > 0) {
700
+ findings.push({
701
+ id: "rls:reachable-empty",
702
+ category: "rls",
703
+ severity: "low",
704
+ confidence: "info",
705
+ title: `${reachableEmpty.length} table(s) reachable by anonymous users (no rows returned)`,
706
+ description: "The anon role can query these tables but they returned no rows \u2014 either Row-Level Security is correctly blocking anonymous reads, or the tables are empty. This is NOT proof of exposure, but confirm RLS is enabled and intentional on each: " + reachableEmpty.join(", ") + ". If any of these later hold data without RLS, they would become readable.",
707
+ location: restBase(conn),
708
+ proof: {
709
+ summary: `Anon SELECT returned 200 with 0 rows for: ${reachableEmpty.join(", ")}.`
710
+ },
711
+ fix: {
712
+ steps: [
713
+ "For each table, confirm in Supabase \u2192 Authentication \u2192 Policies that RLS is ENABLED and no policy grants anonymous read access to private data."
714
+ ],
715
+ aiPrompt: `Check these Supabase tables in my app: ${reachableEmpty.join(", ")}. For each one, make sure Row-Level Security is enabled and that no policy lets anonymous (anon) users read private data. Add owner-based policies (auth.uid()) where they're missing, and tell me which tables are intentionally public.`
716
+ },
717
+ reverify: { check: "supabase-rls-read" }
718
+ });
719
+ }
720
+ findings.push(...rpcFindings(conn, dedupeNames([...rpcFunctions, ...harvestedRpc])));
721
+ return findings;
722
+ }
723
+ async function probeAuthConfig(ctx, conn) {
724
+ const findings = [];
725
+ let res;
726
+ try {
727
+ res = await ctx.http(conn.url.replace(/\/+$/, "") + "/auth/v1/settings", {
728
+ method: "GET",
729
+ headers: authHeaders(conn)
730
+ });
731
+ } catch {
732
+ return findings;
733
+ }
734
+ if (res.status >= 400) return findings;
735
+ let cfg;
736
+ try {
737
+ cfg = JSON.parse(res.text);
738
+ } catch {
739
+ return findings;
740
+ }
741
+ if (cfg["mailer_autoconfirm"] === true || cfg["autoconfirm"] === true) {
742
+ findings.push({
743
+ id: "auth:autoconfirm",
744
+ category: "auth",
745
+ severity: "medium",
746
+ confidence: "proven",
747
+ title: "Email confirmation is disabled",
748
+ description: "New accounts are auto-confirmed without verifying the email address. Anyone can sign up as any email they don't own, which enables spoofing and spam.",
749
+ location: conn.url + "/auth/v1/settings",
750
+ proof: { summary: "auth settings report mailer_autoconfirm = true." },
751
+ fix: {
752
+ steps: [
753
+ "In Supabase \u2192 Authentication \u2192 Providers \u2192 Email, turn ON 'Confirm email'."
754
+ ],
755
+ aiPrompt: "Enable email confirmation for signups in my Supabase project so accounts must verify their email before they are active."
756
+ },
757
+ reverify: { check: "supabase-auth-config" }
758
+ });
759
+ }
760
+ return findings;
761
+ }
762
+ async function probeStorage(ctx, conn) {
763
+ const findings = [];
764
+ let res;
765
+ try {
766
+ res = await ctx.http(conn.url.replace(/\/+$/, "") + "/storage/v1/bucket", {
767
+ method: "GET",
768
+ headers: authHeaders(conn)
769
+ });
770
+ } catch {
771
+ return findings;
772
+ }
773
+ if (res.status >= 400) return findings;
774
+ let buckets;
775
+ try {
776
+ buckets = JSON.parse(res.text);
777
+ } catch {
778
+ return findings;
779
+ }
780
+ if (!Array.isArray(buckets)) return findings;
781
+ for (const b of buckets) {
782
+ if (b && typeof b === "object" && b["public"] === true) {
783
+ const name = String(b["name"] ?? "unknown");
784
+ findings.push({
785
+ id: `storage:${name}`,
786
+ category: "storage",
787
+ severity: "high",
788
+ confidence: "proven",
789
+ title: `Storage bucket "${name}" is public`,
790
+ description: `Anyone can access every file in the "${name}" storage bucket. The bucket is marked public, so its files are readable by anyone who knows or guesses an object path.`,
791
+ location: conn.url + "/storage/v1/bucket",
792
+ proof: { summary: `Bucket "${name}" listed anonymously with public=true.` },
793
+ fix: {
794
+ steps: [
795
+ `In Supabase \u2192 Storage, set the "${name}" bucket to Private unless it is truly meant to serve public assets, and add access policies.`
796
+ ],
797
+ aiPrompt: `Make the Supabase storage bucket "${name}" private and add storage policies so only authorized users can read their own files.`
798
+ },
799
+ reverify: { check: "supabase-storage", target: name }
800
+ });
801
+ }
802
+ }
803
+ return findings;
804
+ }
805
+
806
+ // ../engine/src/checks/headers.ts
807
+ function get(headers, name) {
808
+ const lower = name.toLowerCase();
809
+ for (const [k, v] of Object.entries(headers)) {
810
+ if (k.toLowerCase() === lower) return v;
811
+ }
812
+ return void 0;
813
+ }
814
+ function headerFindings(input) {
815
+ const findings = [];
816
+ const h = input.headers;
817
+ const acao = get(h, "access-control-allow-origin");
818
+ const acac = get(h, "access-control-allow-credentials");
819
+ if (acao === "*" && acac?.toLowerCase() === "true") {
820
+ findings.push({
821
+ id: "headers:cors-wildcard-credentials",
822
+ category: "headers",
823
+ severity: "high",
824
+ confidence: "proven",
825
+ title: "CORS allows any origin with credentials",
826
+ description: "The server returns Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. Browsers block this exact combo, but if the origin is instead reflected per-request it lets any website make authenticated requests as your logged-in users.",
827
+ location: input.url,
828
+ proof: { summary: "Response sent ACAO: * with ACAC: true." },
829
+ fix: {
830
+ steps: [
831
+ "Never combine a wildcard/reflected origin with credentials. Allow-list the specific origins that may send credentialed requests."
832
+ ],
833
+ aiPrompt: "Security fix: my server returns Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. Change the CORS config so credentialed requests are only allowed from an explicit list of my own origins, and never reflect or wildcard the origin when credentials are enabled."
834
+ },
835
+ reverify: { check: "headers" }
836
+ });
837
+ }
838
+ if (!get(h, "content-security-policy")) {
839
+ findings.push({
840
+ id: "headers:no-csp",
841
+ category: "headers",
842
+ severity: "low",
843
+ confidence: "info",
844
+ title: "No Content-Security-Policy header",
845
+ description: "A Content-Security-Policy meaningfully reduces the impact of cross-site scripting (XSS) by controlling what scripts can run. None was set.",
846
+ location: input.url,
847
+ proof: { summary: "No content-security-policy header in the response." },
848
+ fix: {
849
+ steps: [
850
+ "Add a Content-Security-Policy header. Start in report-only mode, then tighten script-src to your own origin and required CDNs."
851
+ ],
852
+ aiPrompt: "Add a Content-Security-Policy header to every response of my app. First list every external domain the app loads scripts, styles, images, fonts and API calls from, then set a policy that allows only my own origin plus those domains. Start with Content-Security-Policy-Report-Only so nothing breaks, and tell me how to switch it to enforcing once no violations show up."
853
+ },
854
+ reverify: { check: "headers" }
855
+ });
856
+ }
857
+ if (!get(h, "strict-transport-security")) {
858
+ findings.push({
859
+ id: "headers:no-hsts",
860
+ category: "headers",
861
+ severity: "low",
862
+ confidence: "info",
863
+ title: "No HSTS header",
864
+ description: "Strict-Transport-Security forces browsers to use HTTPS, preventing downgrade and cookie-interception attacks. It was not set.",
865
+ location: input.url,
866
+ proof: { summary: "No strict-transport-security header in the response." },
867
+ fix: {
868
+ steps: [
869
+ "Add Strict-Transport-Security: max-age=63072000; includeSubDomains once you are confident everything is served over HTTPS."
870
+ ],
871
+ aiPrompt: "Add the header Strict-Transport-Security: max-age=63072000; includeSubDomains to every response of my app, but only after confirming every page, API and asset (including subdomains) is served over HTTPS."
872
+ },
873
+ reverify: { check: "headers" }
874
+ });
875
+ }
876
+ const xfo = get(h, "x-frame-options");
877
+ const csp = get(h, "content-security-policy");
878
+ const hasFrameAncestors = !!csp && /frame-ancestors/i.test(csp);
879
+ if (!xfo && !hasFrameAncestors) {
880
+ findings.push({
881
+ id: "headers:clickjacking",
882
+ category: "headers",
883
+ severity: "low",
884
+ confidence: "info",
885
+ title: "No clickjacking protection",
886
+ description: "Neither X-Frame-Options nor a Content-Security-Policy frame-ancestors directive is set, so your site can be embedded in an invisible iframe on another site and tricked into accepting clicks meant for something else (clickjacking).",
887
+ location: input.url,
888
+ proof: { summary: "No x-frame-options header and no frame-ancestors in the CSP." },
889
+ fix: {
890
+ steps: [
891
+ "Add X-Frame-Options: DENY (or SAMEORIGIN), or a CSP with frame-ancestors 'none' (or 'self')."
892
+ ],
893
+ aiPrompt: "Add clickjacking protection to every response of my app: either the header X-Frame-Options: DENY, or a Content-Security-Policy with frame-ancestors 'none' (use 'self' instead if the app legitimately needs to be embedded on its own domain)."
894
+ },
895
+ reverify: { check: "headers" }
896
+ });
897
+ }
898
+ findings.push(...cookieFindings(input.url, input.setCookie));
899
+ return findings;
900
+ }
901
+ function cookieFindings(url, setCookie) {
902
+ if (!setCookie || setCookie.length === 0) return [];
903
+ const isHttps = url.startsWith("https:");
904
+ const flagged = [];
905
+ for (const raw of setCookie) {
906
+ const name = raw.split("=")[0]?.trim();
907
+ if (!name) continue;
908
+ const missing = [];
909
+ if (isHttps && !/;\s*secure\b/i.test(raw)) missing.push("Secure");
910
+ if (!/;\s*httponly\b/i.test(raw)) missing.push("HttpOnly");
911
+ if (!/;\s*samesite\s*=/i.test(raw)) missing.push("SameSite");
912
+ if (missing.length > 0) flagged.push(`${name} (missing ${missing.join(", ")})`);
913
+ }
914
+ if (flagged.length === 0) return [];
915
+ return [
916
+ {
917
+ id: "headers:cookie-flags",
918
+ category: "headers",
919
+ severity: "low",
920
+ confidence: "proven",
921
+ title: "Cookies missing security flags",
922
+ description: "Your app sets cookies without all the recommended security attributes. Without Secure, HttpOnly, and SameSite, a cookie is more exposed to interception on the network, theft via a cross-site scripting bug, or being sent along with a forged cross-site request.",
923
+ location: url,
924
+ proof: { summary: `Cookie(s) with missing flags: ${flagged.join("; ")}.` },
925
+ fix: {
926
+ steps: [
927
+ "Set Secure, HttpOnly, and SameSite=Lax (or Strict) on every cookie your server sets."
928
+ ],
929
+ aiPrompt: "Add the Secure, HttpOnly, and SameSite=Lax attributes to every cookie my server sets (session/auth cookies especially), so they can't be read by JavaScript, aren't sent over plain HTTP, and aren't sent along with unrelated cross-site requests."
930
+ },
931
+ reverify: { check: "headers-cookies" }
932
+ }
933
+ ];
934
+ }
935
+ async function checkHttpDowngrade(ctx, targetUrl) {
936
+ let target;
937
+ try {
938
+ target = new URL(targetUrl);
939
+ } catch {
940
+ return null;
941
+ }
942
+ if (target.protocol !== "https:") return null;
943
+ const httpUrl = `http://${target.host}/`;
944
+ let res;
945
+ try {
946
+ res = await ctx.http(httpUrl, { method: "GET", redirect: "manual" });
947
+ } catch {
948
+ return null;
949
+ }
950
+ const location = get(res.headers, "location");
951
+ const redirectsToHttps = res.status >= 300 && res.status < 400 && !!location && location.toLowerCase().startsWith("https:");
952
+ if (redirectsToHttps) return null;
953
+ return {
954
+ id: "headers:http-not-upgraded",
955
+ category: "headers",
956
+ severity: "low",
957
+ confidence: "proven",
958
+ title: "HTTP does not redirect to HTTPS",
959
+ description: "Requesting the plain http:// address of your site does not redirect to https://. Anyone who types the address without 'https', or follows an old http:// link, stays on an unencrypted connection where traffic \u2014 including session cookies \u2014 can be intercepted.",
960
+ location: httpUrl,
961
+ proof: {
962
+ summary: `GET ${httpUrl} returned status ${res.status}` + (location ? ` with Location: ${location}` : ", no redirect to https.")
963
+ },
964
+ fix: {
965
+ steps: [
966
+ "Configure your host/CDN/reverse proxy to redirect every http:// request to the https:// equivalent (301), then add an HSTS header."
967
+ ],
968
+ aiPrompt: "Make sure every http:// request to my domain is redirected (301) to the https:// version at the hosting/CDN/reverse-proxy level, before any app code runs, and add a Strict-Transport-Security header once that's confirmed working."
969
+ },
970
+ reverify: { check: "headers-http-downgrade" }
971
+ };
972
+ }
973
+
974
+ // ../engine/src/checks/sourcemaps.ts
975
+ var MAX_BUNDLES_TO_CHECK = 5;
976
+ function sameOrigin2(a, b) {
977
+ try {
978
+ return new URL(a).origin === new URL(b).origin;
979
+ } catch {
980
+ return false;
981
+ }
982
+ }
983
+ function extractSourceMappingUrl(jsText) {
984
+ const re = /\/[/*][@#]\s*sourceMappingURL=([^\s*]+)/g;
985
+ let last = null;
986
+ let m;
987
+ while ((m = re.exec(jsText)) !== null) {
988
+ const raw = m[1];
989
+ if (raw && !raw.startsWith("data:")) last = raw;
990
+ }
991
+ return last;
992
+ }
993
+ function looksLikeSourceMap(text) {
994
+ if (!text || text.length < 2) return false;
995
+ let doc;
996
+ try {
997
+ doc = JSON.parse(text);
998
+ } catch {
999
+ return false;
1000
+ }
1001
+ if (typeof doc !== "object" || doc === null) return false;
1002
+ const d = doc;
1003
+ return "mappings" in d || Array.isArray(d["sources"]);
1004
+ }
1005
+ async function sourceMapFindings(ctx, scripts) {
1006
+ const findings = [];
1007
+ for (const script of scripts.slice(0, MAX_BUNDLES_TO_CHECK)) {
1008
+ const linked = extractSourceMappingUrl(script.text);
1009
+ let mapUrl;
1010
+ try {
1011
+ mapUrl = linked ? new URL(linked, script.url).toString() : new URL(script.url + ".map").toString();
1012
+ } catch {
1013
+ continue;
1014
+ }
1015
+ if (!sameOrigin2(mapUrl, script.url)) continue;
1016
+ let res;
1017
+ try {
1018
+ res = await ctx.http(mapUrl, { method: "GET" });
1019
+ } catch {
1020
+ continue;
1021
+ }
1022
+ if (res.status < 200 || res.status >= 300) continue;
1023
+ if (res.text.length > ctx.maxBytesPerAsset) continue;
1024
+ if (!looksLikeSourceMap(res.text)) continue;
1025
+ findings.push({
1026
+ id: `sourcemap:${script.url}`,
1027
+ category: "config",
1028
+ severity: "medium",
1029
+ confidence: "proven",
1030
+ title: "Your original source code is public",
1031
+ description: "A source map for one of your JS bundles is publicly served. Source maps let anyone reconstruct your original, unminified source code \u2014 variable names, file structure, comments \u2014 straight from the browser dev tools, not just the compiled output.",
1032
+ location: mapUrl,
1033
+ proof: { summary: `GET ${mapUrl} returned a source map for ${script.url}.` },
1034
+ fix: {
1035
+ steps: [
1036
+ "Disable source maps in production builds (Vite: build.sourcemap: false; Next.js: productionBrowserSourceMaps: false \u2014 the default), or make sure .map files are excluded from the deployed output. If you need maps for your own error tracking, upload them privately to that provider instead."
1037
+ ],
1038
+ aiPrompt: "My production build is serving JavaScript source maps (.map files) publicly, which exposes my original source code. Disable source maps in production (Vite: build.sourcemap: false, Next.js: productionBrowserSourceMaps: false), or make sure .map files are excluded from the deployed output, then redeploy."
1039
+ },
1040
+ reverify: { check: "sourcemap-exposure", target: script.url }
1041
+ });
1042
+ let doc;
1043
+ try {
1044
+ doc = JSON.parse(res.text);
1045
+ } catch {
1046
+ doc = null;
1047
+ }
1048
+ const sourcesContent = doc && typeof doc === "object" ? doc["sourcesContent"] : null;
1049
+ if (Array.isArray(sourcesContent)) {
1050
+ const combined = sourcesContent.filter((s) => typeof s === "string").join("\n");
1051
+ findings.push(...secretFindings(extractSecrets(combined), mapUrl));
1052
+ }
1053
+ break;
1054
+ }
1055
+ return findings;
1056
+ }
1057
+
1058
+ // ../engine/src/checks/configFiles.ts
1059
+ var CANDIDATE_PATHS = ["/.env", "/.env.local", "/.env.production", "/.git/config"];
1060
+ function looksLikeHtml(text) {
1061
+ return /<html[\s>]|<!doctype\s+html/i.test(text.slice(0, 500));
1062
+ }
1063
+ function envVarNames(text) {
1064
+ const names = [];
1065
+ for (const line of text.split(/\r?\n/)) {
1066
+ const m = /^([A-Z][A-Z0-9_]*)=/.exec(line.trim());
1067
+ if (m?.[1]) names.push(m[1]);
1068
+ }
1069
+ return names;
1070
+ }
1071
+ function looksLikeDotenv(text) {
1072
+ if (looksLikeHtml(text)) return false;
1073
+ return envVarNames(text).length > 0;
1074
+ }
1075
+ function looksLikeGitConfig(text) {
1076
+ if (looksLikeHtml(text)) return false;
1077
+ return /\[core\]/.test(text);
1078
+ }
1079
+ function configFinding(path, url, summary, description) {
1080
+ return {
1081
+ id: `config:${path}`,
1082
+ category: "config",
1083
+ severity: "critical",
1084
+ confidence: "proven",
1085
+ title: `${path} is publicly readable`,
1086
+ description,
1087
+ location: url,
1088
+ proof: { summary },
1089
+ fix: {
1090
+ steps: [
1091
+ `Remove ${path} from your deployed output (exclude it in your build config, and/or block dotfiles at the host/CDN level), then rotate anything it exposed.`
1092
+ ],
1093
+ aiPrompt: `My deployed app is serving ${path} publicly. Make sure this file is never included in the production build/deploy output (exclude it in the build config and/or block dotfiles at the host/CDN level), and confirm the path returns 404 after redeploy. I will rotate any exposed secrets myself.`
1094
+ },
1095
+ reverify: { check: "config-exposure", target: path }
1096
+ };
1097
+ }
1098
+ async function configFileFindings(ctx, pageUrl) {
1099
+ const findings = [];
1100
+ let origin;
1101
+ try {
1102
+ origin = new URL(pageUrl).origin;
1103
+ } catch {
1104
+ return findings;
1105
+ }
1106
+ for (const path of CANDIDATE_PATHS) {
1107
+ const url = origin + path;
1108
+ let res;
1109
+ try {
1110
+ res = await ctx.http(url, { method: "GET" });
1111
+ } catch {
1112
+ continue;
1113
+ }
1114
+ if (res.status < 200 || res.status >= 300) continue;
1115
+ if (res.text.length > ctx.maxBytesPerAsset) continue;
1116
+ const isEnv = path.startsWith("/.env");
1117
+ if (isEnv && looksLikeDotenv(res.text)) {
1118
+ const names = envVarNames(res.text);
1119
+ const includingPhrase = names.length > 0 ? `: ${names.join(", ")}` : "";
1120
+ findings.push(
1121
+ configFinding(
1122
+ path,
1123
+ url,
1124
+ `GET ${url} returned a plaintext env file defining: ${names.join(", ") || "(no variable names parsed)"}.`,
1125
+ `Anyone can download ${path} and see ${names.length} secret variable name${names.length === 1 ? "" : "s"}${includingPhrase}. This environment file typically holds database URLs, API keys, and other secrets, all readable by anyone on the internet, no login required.`
1126
+ )
1127
+ );
1128
+ findings.push(...secretFindings(extractSecrets(res.text), url));
1129
+ } else if (!isEnv && looksLikeGitConfig(res.text)) {
1130
+ findings.push(
1131
+ configFinding(
1132
+ path,
1133
+ url,
1134
+ `GET ${url} returned a git config file (contains a [core] section).`,
1135
+ "Your app serves its .git/config file publicly. Combined with other exposed git internals this can let someone reconstruct your repository, including commit history and any secrets ever committed to it."
1136
+ )
1137
+ );
1138
+ findings.push(...secretFindings(extractSecrets(res.text), url));
1139
+ }
1140
+ }
1141
+ return findings;
1142
+ }
1143
+
1144
+ // ../engine/src/checks/firebase.ts
1145
+ var MAX_COLLECTION_PROBES = 20;
1146
+ var PROJECT_ID_RE = /["']?projectId["']?\s*:\s*["']([^"']{1,80})["']/;
1147
+ var DATABASE_URL_RE = /["']?databaseURL["']?\s*:\s*["'](https:\/\/[^"']{1,120})["']/i;
1148
+ var STORAGE_BUCKET_RE = /["']?storageBucket["']?\s*:\s*["']([^"']{1,100})["']/;
1149
+ var API_KEY_RE = /["']?apiKey["']?\s*:\s*["']([^"']{1,100})["']/;
1150
+ var PROJECT_ID_VALID = /^[a-z0-9-]{4,40}$/;
1151
+ var RTDB_HOST_VALID = /^[a-z0-9-]{3,63}\.firebaseio\.com$|^[a-z0-9-]{3,63}\.[a-z0-9-]{1,30}\.firebasedatabase\.app$/;
1152
+ var BUCKET_VALID = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\.appspot\.com$|^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\.firebasestorage\.app$/;
1153
+ var API_KEY_VALID = /^[A-Za-z0-9_-]{10,100}$/;
1154
+ function extractFirebaseConfig(text) {
1155
+ const config = {};
1156
+ const projectId = PROJECT_ID_RE.exec(text)?.[1];
1157
+ if (projectId && PROJECT_ID_VALID.test(projectId)) config.projectId = projectId;
1158
+ const dbUrlRaw = DATABASE_URL_RE.exec(text)?.[1];
1159
+ if (dbUrlRaw) {
1160
+ try {
1161
+ const u = new URL(dbUrlRaw);
1162
+ if (u.protocol === "https:" && RTDB_HOST_VALID.test(u.hostname)) {
1163
+ config.databaseURL = `https://${u.hostname}`;
1164
+ }
1165
+ } catch {
1166
+ }
1167
+ }
1168
+ const bucket = STORAGE_BUCKET_RE.exec(text)?.[1];
1169
+ if (bucket && BUCKET_VALID.test(bucket)) config.storageBucket = bucket;
1170
+ const apiKey = API_KEY_RE.exec(text)?.[1];
1171
+ if (apiKey && API_KEY_VALID.test(apiKey)) config.apiKey = apiKey;
1172
+ const hasAny = config.projectId || config.databaseURL || config.storageBucket || config.apiKey;
1173
+ return hasAny ? config : null;
1174
+ }
1175
+ function rtdbShallowUrl(databaseURL) {
1176
+ return databaseURL.replace(/\/+$/, "") + "/.json?shallow=true";
1177
+ }
1178
+ function rtdbRulesSnippet() {
1179
+ return [
1180
+ `{`,
1181
+ ` "rules": {`,
1182
+ ` ".read": "auth != null",`,
1183
+ ` ".write": "auth != null"`,
1184
+ ` }`,
1185
+ `}`,
1186
+ ``,
1187
+ `// Better: scope each branch to its owner, e.g. for a /users/$uid tree:`,
1188
+ `// "users": { "$uid": { ".read": "auth != null && auth.uid == $uid", ".write": "auth != null && auth.uid == $uid" } }`
1189
+ ].join("\n");
1190
+ }
1191
+ function rtdbAiPrompt() {
1192
+ return "Security fix: my Firebase Realtime Database has no rules restricting reads, so anyone on the internet can read the entire database with no login. In the Firebase Console, go to Realtime Database \u2192 Rules and replace the rules so reads and writes require authentication, then scope each branch to its owner (match auth.uid() to the record's owner field) instead of allowing any signed-in user to read everything.";
1193
+ }
1194
+ async function probeRealtimeDatabase(ctx, databaseURL) {
1195
+ const url = rtdbShallowUrl(databaseURL);
1196
+ let res;
1197
+ try {
1198
+ res = await ctx.http(url, { method: "GET" });
1199
+ } catch {
1200
+ return [];
1201
+ }
1202
+ if (res.status === 401 || res.status === 403) return [];
1203
+ if (res.status < 200 || res.status >= 300) return [];
1204
+ if (res.text.length > ctx.maxBytesPerAsset) return [];
1205
+ let data;
1206
+ try {
1207
+ data = JSON.parse(res.text);
1208
+ } catch {
1209
+ return [];
1210
+ }
1211
+ if (data === null) {
1212
+ return [
1213
+ {
1214
+ id: "firebase-rtdb:reachable-empty",
1215
+ category: "rls",
1216
+ severity: "low",
1217
+ confidence: "info",
1218
+ title: "Realtime Database is reachable but currently empty",
1219
+ description: "Your Firebase Realtime Database has no rules blocking anonymous reads \u2014 we could query its root with no login \u2014 but it currently holds no data. This is not proof of a leak, but confirm rules require authentication before you store anything in it.",
1220
+ location: url,
1221
+ proof: { summary: `GET ${url} returned 200 with an empty (null) database.` },
1222
+ fix: { steps: [rtdbRulesSnippet()], aiPrompt: rtdbAiPrompt() },
1223
+ reverify: { check: "firebase-rtdb-read" }
1224
+ }
1225
+ ];
1226
+ }
1227
+ const keys = typeof data === "object" && !Array.isArray(data) ? Object.keys(data).slice(0, 10) : [];
1228
+ const includingPhrase = keys.length > 0 ? `, including ${keys.join(", ")}` : "";
1229
+ return [
1230
+ {
1231
+ id: "firebase-rtdb:readable",
1232
+ category: "rls",
1233
+ severity: "critical",
1234
+ confidence: "proven",
1235
+ title: "Your Realtime Database is readable by anyone",
1236
+ description: `Anyone can read your entire Firebase Realtime Database${includingPhrase}. This works with no login, because its security rules don't require authentication. We only listed top-level key names to prove this \u2014 we did not read any data.`,
1237
+ location: url,
1238
+ proof: {
1239
+ summary: `GET ${url} returned 200 with a readable database.` + (keys.length > 0 ? ` Top-level keys: ${keys.join(", ")}.` : "")
1240
+ },
1241
+ fix: { steps: [rtdbRulesSnippet()], aiPrompt: rtdbAiPrompt() },
1242
+ reverify: { check: "firebase-rtdb-read" }
1243
+ }
1244
+ ];
1245
+ }
1246
+ var FIRESTORE_COLLECTION_WORDLIST = [
1247
+ "users",
1248
+ "profiles",
1249
+ "orders",
1250
+ "payments",
1251
+ "messages",
1252
+ "posts",
1253
+ "customers",
1254
+ "accounts",
1255
+ "subscriptions",
1256
+ "invoices"
1257
+ ];
1258
+ function firestoreLocation(projectId, collection) {
1259
+ return `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents/${encodeURIComponent(collection)}`;
1260
+ }
1261
+ function firestoreRequestUrl(projectId, collection, apiKey) {
1262
+ const base = firestoreLocation(projectId, collection) + "?pageSize=1";
1263
+ return apiKey ? `${base}&key=${encodeURIComponent(apiKey)}` : base;
1264
+ }
1265
+ function firestoreRulesSnippet(collection) {
1266
+ return [
1267
+ `rules_version = '2';`,
1268
+ `service cloud.firestore {`,
1269
+ ` match /databases/{database}/documents {`,
1270
+ ` match /${collection}/{docId} {`,
1271
+ ` allow read, write: if request.auth != null && request.auth.uid == resource.data.ownerId;`,
1272
+ ` }`,
1273
+ ` }`,
1274
+ `}`
1275
+ ].join("\n");
1276
+ }
1277
+ function firestoreAiPrompt(collection) {
1278
+ return `Security fix: my Firestore collection "${collection}" is readable by anyone with no login, because its security rules don't restrict access. In the Firebase Console, go to Firestore Database \u2192 Rules and add a rule for "${collection}" that only allows a document's owner to read or write it (match request.auth.uid to the document's owner field, e.g. ownerId), and denies everyone else, including other signed-in users.`;
1279
+ }
1280
+ async function probeFirestore(ctx, projectId, apiKey, harvestedCollections = []) {
1281
+ const findings = [];
1282
+ const reachableEmpty = [];
1283
+ const harvestedSet = new Set(harvestedCollections);
1284
+ const candidates = dedupeNames([
1285
+ ...harvestedCollections,
1286
+ ...FIRESTORE_COLLECTION_WORDLIST
1287
+ ]).slice(0, MAX_COLLECTION_PROBES);
1288
+ for (const collection of candidates) {
1289
+ let res;
1290
+ try {
1291
+ res = await ctx.http(firestoreRequestUrl(projectId, collection, apiKey), { method: "GET" });
1292
+ } catch {
1293
+ continue;
1294
+ }
1295
+ if (res.status < 200 || res.status >= 300) continue;
1296
+ if (res.text.length > ctx.maxBytesPerAsset) continue;
1297
+ let body;
1298
+ try {
1299
+ body = JSON.parse(res.text);
1300
+ } catch {
1301
+ continue;
1302
+ }
1303
+ const documents = body && typeof body === "object" ? body["documents"] : void 0;
1304
+ if (!Array.isArray(documents) || documents.length === 0) {
1305
+ reachableEmpty.push(collection);
1306
+ continue;
1307
+ }
1308
+ const first = documents[0];
1309
+ const fields = first && typeof first === "object" ? first["fields"] : void 0;
1310
+ const fieldNames = fields && typeof fields === "object" ? Object.keys(fields) : [];
1311
+ const sensitive = sensitiveColumns(fieldNames);
1312
+ const sensitivePhrase = sensitive.length > 0 ? ` It exposes fields: ${sensitive.join(", ")}.` : "";
1313
+ const fromCodePhrase = harvestedSet.has(collection) ? " \u2014 a collection name found in your app's code" : "";
1314
+ const includingPhrase = sensitive.length > 0 ? `, including ${sensitive.join(", ")}` : "";
1315
+ const impact = `Anyone can read documents from \`${collection}\`${includingPhrase}.`;
1316
+ findings.push({
1317
+ id: `firebase-firestore:${collection}`,
1318
+ category: "rls",
1319
+ severity: "critical",
1320
+ confidence: "proven",
1321
+ title: `Firestore collection "${collection}" is readable by anyone`,
1322
+ description: `${impact} Using only your app's public Firebase config, an anonymous user can read documents from the "${collection}" collection${fromCodePhrase} with no login, because its security rules don't restrict reads.${sensitivePhrase}`,
1323
+ location: firestoreLocation(projectId, collection),
1324
+ proof: {
1325
+ summary: `Read 1 document from "${collection}" anonymously via the Firestore REST API.` + (fieldNames.length > 0 ? ` Fields present: ${fieldNames.join(", ")}.` : ""),
1326
+ evidence: fieldNames.length > 0 ? `Fields exposed: ${fieldNames.join(", ")}` : void 0
1327
+ },
1328
+ fix: { steps: [firestoreRulesSnippet(collection)], aiPrompt: firestoreAiPrompt(collection) },
1329
+ reverify: { check: "firebase-firestore-read", target: collection }
1330
+ });
1331
+ }
1332
+ if (reachableEmpty.length > 0) {
1333
+ findings.push({
1334
+ id: "firebase-firestore:reachable-empty",
1335
+ category: "rls",
1336
+ severity: "low",
1337
+ confidence: "info",
1338
+ title: `${reachableEmpty.length} Firestore collection(s) reachable by anonymous users (no documents returned)`,
1339
+ description: "These Firestore collections can be queried with no login but returned no documents \u2014 either their rules correctly block anonymous reads, or they're empty. This is NOT proof of exposure, but confirm the rules are intentional on each: " + reachableEmpty.join(", ") + ".",
1340
+ location: `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents`,
1341
+ proof: {
1342
+ summary: `Anonymous query returned 200 with 0 documents for: ${reachableEmpty.join(", ")}.`
1343
+ },
1344
+ fix: {
1345
+ steps: [
1346
+ "For each collection, confirm in Firebase Console \u2192 Firestore Database \u2192 Rules that reads require request.auth != null and are scoped to the document owner."
1347
+ ],
1348
+ aiPrompt: `Check these Firestore collections in my app: ${reachableEmpty.join(", ")}. For each, make sure the security rules require authentication and only let a document's owner read or write it, and tell me which are intentionally public.`
1349
+ },
1350
+ reverify: { check: "firebase-firestore-read" }
1351
+ });
1352
+ }
1353
+ return findings;
1354
+ }
1355
+ function storageListUrl(bucket) {
1356
+ return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucket)}/o?maxResults=5`;
1357
+ }
1358
+ function storageRulesSnippet() {
1359
+ return [
1360
+ `rules_version = '2';`,
1361
+ `service firebase.storage {`,
1362
+ ` match /b/{bucket}/o {`,
1363
+ ` match /{allPaths=**} {`,
1364
+ ` allow read, write: if request.auth != null && request.auth.uid == resource.metadata.ownerId;`,
1365
+ ` }`,
1366
+ ` }`,
1367
+ `}`
1368
+ ].join("\n");
1369
+ }
1370
+ function storageAiPrompt() {
1371
+ return "Security fix: my Firebase Storage bucket lets anyone list its files with no login, because its security rules don't restrict access. In the Firebase Console, go to Storage \u2192 Rules and require request.auth != null for read/write, scoped so each user can only access their own files (e.g. match auth.uid to an owner path or metadata field).";
1372
+ }
1373
+ async function probeFirebaseStorage(ctx, bucket) {
1374
+ const url = storageListUrl(bucket);
1375
+ let res;
1376
+ try {
1377
+ res = await ctx.http(url, { method: "GET" });
1378
+ } catch {
1379
+ return [];
1380
+ }
1381
+ if (res.status === 401 || res.status === 403) return [];
1382
+ if (res.status < 200 || res.status >= 300) return [];
1383
+ if (res.text.length > ctx.maxBytesPerAsset) return [];
1384
+ let body;
1385
+ try {
1386
+ body = JSON.parse(res.text);
1387
+ } catch {
1388
+ return [];
1389
+ }
1390
+ const items = body && typeof body === "object" ? body["items"] : void 0;
1391
+ if (!Array.isArray(items) || items.length === 0) return [];
1392
+ const names = items.map((i) => i && typeof i === "object" ? i["name"] : void 0).filter((n) => typeof n === "string").slice(0, 5);
1393
+ const countPhrase = items.length >= 5 ? "at least 5 files" : `${items.length} file${items.length === 1 ? "" : "s"}`;
1394
+ return [
1395
+ {
1396
+ id: "firebase-storage:public-listing",
1397
+ category: "storage",
1398
+ severity: "high",
1399
+ confidence: "proven",
1400
+ title: "Your Firebase Storage files can be listed by anyone",
1401
+ description: `Anyone can list ${countPhrase} in your Firebase Storage bucket. This works with no login, because its security rules don't restrict access. We only listed file names to prove this \u2014 we did not download any file contents.`,
1402
+ location: storageListUrl(bucket).split("?")[0],
1403
+ proof: {
1404
+ summary: `Listed ${items.length} object(s) anonymously.` + (names.length > 0 ? ` Names: ${names.join(", ")}.` : "")
1405
+ },
1406
+ fix: { steps: [storageRulesSnippet()], aiPrompt: storageAiPrompt() },
1407
+ reverify: { check: "firebase-storage-list" }
1408
+ }
1409
+ ];
1410
+ }
1411
+
1412
+ // ../engine/src/checks/libraries.ts
1413
+ var KNOWN_LIBS = {
1414
+ react: "react",
1415
+ "react-dom": "react-dom",
1416
+ jquery: "jquery",
1417
+ lodash: "lodash",
1418
+ "lodash-es": "lodash-es",
1419
+ axios: "axios",
1420
+ moment: "moment",
1421
+ "moment.js": "moment",
1422
+ vue: "vue",
1423
+ "vue.js": "vue",
1424
+ express: "express",
1425
+ "socket.io": "socket.io",
1426
+ d3: "d3",
1427
+ "chart.js": "chart.js",
1428
+ bootstrap: "bootstrap",
1429
+ preact: "preact",
1430
+ svelte: "svelte",
1431
+ next: "next",
1432
+ "next.js": "next",
1433
+ dayjs: "dayjs",
1434
+ underscore: "underscore"
1435
+ };
1436
+ var MAX_PACKAGES = 20;
1437
+ var OSV_URL = "https://api.osv.dev/v1/querybatch";
1438
+ function normalizeName(raw) {
1439
+ return KNOWN_LIBS[raw.trim().toLowerCase()] ?? null;
1440
+ }
1441
+ function collect(source, text, location, out, seen) {
1442
+ const re = new RegExp(source.source, source.flags);
1443
+ let m;
1444
+ while ((m = re.exec(text)) !== null) {
1445
+ const rawName = m[1];
1446
+ const version = m[2];
1447
+ if (!rawName || !version) continue;
1448
+ const name = normalizeName(rawName);
1449
+ if (!name) continue;
1450
+ const key = `${name}@${version}`;
1451
+ if (seen.has(key)) continue;
1452
+ seen.add(key);
1453
+ out.push({ name, version, location });
1454
+ }
1455
+ }
1456
+ var BANG_BANNER_RE = /\/\*!\s*([A-Za-z][\w.\/-]{1,30})\s+(?:JavaScript Library\s+)?v(\d+\.\d+\.\d+)/g;
1457
+ var LICENSE_RE = /@license\s+([A-Za-z][\w.\/-]{1,30})\s+v?(\d+\.\d+\.\d+)/g;
1458
+ function detectLibraries(scripts) {
1459
+ const out = [];
1460
+ const seen = /* @__PURE__ */ new Set();
1461
+ for (const script of scripts) {
1462
+ collect(BANG_BANNER_RE, script.text, script.url, out, seen);
1463
+ collect(LICENSE_RE, script.text, script.url, out, seen);
1464
+ if (out.length >= MAX_PACKAGES) break;
1465
+ }
1466
+ return out.slice(0, MAX_PACKAGES);
1467
+ }
1468
+ function mapSeverity(vuln) {
1469
+ const raw = vuln.database_specific?.severity;
1470
+ if (typeof raw === "string") {
1471
+ const s = raw.toUpperCase();
1472
+ if (s === "CRITICAL") return "critical";
1473
+ if (s === "HIGH") return "high";
1474
+ if (s === "MODERATE" || s === "MEDIUM") return "medium";
1475
+ if (s === "LOW") return "low";
1476
+ }
1477
+ return "medium";
1478
+ }
1479
+ function fixedVersionFor(pkgName, vuln) {
1480
+ for (const affected of vuln.affected ?? []) {
1481
+ if (affected.package?.name !== pkgName) continue;
1482
+ for (const range of affected.ranges ?? []) {
1483
+ for (const event of range.events ?? []) {
1484
+ if (event.fixed) return event.fixed;
1485
+ }
1486
+ }
1487
+ }
1488
+ return null;
1489
+ }
1490
+ function libraryFinding(lib, vulns) {
1491
+ const top = vulns.slice(0, 3);
1492
+ const ids = top.map((v) => v.id).filter((id) => !!id);
1493
+ let severity = "medium";
1494
+ for (const v of vulns) {
1495
+ const s = mapSeverity(v);
1496
+ if (SEVERITY_RANK[s] > SEVERITY_RANK[severity]) severity = s;
1497
+ }
1498
+ const fixed = top.map((v) => fixedVersionFor(lib.name, v)).find((f) => !!f) ?? null;
1499
+ const firstSummary = top.find((v) => typeof v.summary === "string")?.summary;
1500
+ const countPhrase = `${vulns.length} known vulnerabilit${vulns.length === 1 ? "y" : "ies"}`;
1501
+ return {
1502
+ id: `dependency:${lib.name}@${lib.version}`,
1503
+ category: "dependency",
1504
+ severity,
1505
+ confidence: "proven",
1506
+ title: `${lib.name} ${lib.version} has known vulnerabilities`,
1507
+ description: `Anyone can see your app ships ${lib.name} ${lib.version} in its public JS, which has ${countPhrase} on record` + (firstSummary ? `: ${firstSummary}` : "."),
1508
+ location: lib.location,
1509
+ proof: {
1510
+ summary: `Detected ${lib.name} v${lib.version} from a preserved license banner in the shipped JS; OSV lists: ${ids.join(", ") || "(advisory ids unavailable)"}.`
1511
+ },
1512
+ fix: {
1513
+ steps: [
1514
+ fixed ? `Upgrade ${lib.name} to ${fixed} or later.` : `Upgrade ${lib.name} past ${lib.version} \u2014 check the advisories above for the fixed version.`
1515
+ ],
1516
+ aiPrompt: `My app uses ${lib.name} version ${lib.version}, which has known security vulnerabilities (${ids.join(", ") || "see OSV"}). Upgrade it${fixed ? ` to ${fixed} or later` : ""} and check the changelog/release notes for breaking changes before deploying.`
1517
+ },
1518
+ reverify: { check: "dependency-osv", target: `${lib.name}@${lib.version}` }
1519
+ };
1520
+ }
1521
+ async function queryOsv(ctx, libs) {
1522
+ if (libs.length === 0) return { findings: [] };
1523
+ const capped = libs.slice(0, MAX_PACKAGES);
1524
+ const queries = capped.map((l) => ({ package: { name: l.name, ecosystem: "npm" }, version: l.version }));
1525
+ let res;
1526
+ try {
1527
+ res = await ctx.http(OSV_URL, {
1528
+ method: "POST",
1529
+ headers: { "content-type": "application/json" },
1530
+ body: JSON.stringify({ queries })
1531
+ });
1532
+ } catch {
1533
+ return { findings: [], note: "OSV vulnerability lookup failed (network error) \u2014 skipped." };
1534
+ }
1535
+ if (res.status < 200 || res.status >= 300) {
1536
+ return { findings: [], note: `OSV vulnerability lookup returned ${res.status} \u2014 skipped.` };
1537
+ }
1538
+ let body;
1539
+ try {
1540
+ body = JSON.parse(res.text);
1541
+ } catch {
1542
+ return { findings: [], note: "OSV vulnerability lookup returned an unreadable response \u2014 skipped." };
1543
+ }
1544
+ const results = body?.results;
1545
+ if (!Array.isArray(results)) return { findings: [] };
1546
+ const findings = [];
1547
+ results.forEach((entry, i) => {
1548
+ const lib = capped[i];
1549
+ if (!lib) return;
1550
+ const vulns = entry?.vulns;
1551
+ if (!Array.isArray(vulns) || vulns.length === 0) return;
1552
+ findings.push(libraryFinding(lib, vulns));
1553
+ });
1554
+ return { findings };
1555
+ }
1556
+
1557
+ // ../engine/src/checks/endpoints.ts
1558
+ var MAX_ENDPOINTS = 50;
1559
+ var FETCH_RE = /\bfetch\(\s*["'`](\/[A-Za-z0-9_\-./]{1,200})["'`]/g;
1560
+ var METHOD_CALL_RE = /\.(?:get|post|put|patch|delete)\(\s*["'`](\/[A-Za-z0-9_\-./]{1,200})["'`]/g;
1561
+ var URL_KEY_RE = /\burl\s*:\s*["'`](\/[A-Za-z0-9_\-./]{1,200})["'`]/g;
1562
+ var API_PATH_LITERAL_RE = /["'`](\/api\/[A-Za-z0-9_\-./]{0,200})["'`]/g;
1563
+ var FUNCTIONS_INVOKE_RE = /\.functions\.invoke\(\s*["'`]([A-Za-z0-9_-]{1,80})["'`]/g;
1564
+ function isValidPath(p) {
1565
+ return p.startsWith("/") && !p.includes("..") && !/\s/.test(p) && p.length <= 200;
1566
+ }
1567
+ function fresh(re) {
1568
+ return new RegExp(re.source, re.flags);
1569
+ }
1570
+ function harvestEndpoints(text) {
1571
+ const out = /* @__PURE__ */ new Set();
1572
+ for (const re of [FETCH_RE, METHOD_CALL_RE, URL_KEY_RE, API_PATH_LITERAL_RE]) {
1573
+ const r = fresh(re);
1574
+ let m2;
1575
+ while ((m2 = r.exec(text)) !== null) {
1576
+ const p = m2[1];
1577
+ if (p && isValidPath(p)) out.add(p);
1578
+ }
1579
+ }
1580
+ const invokeRe = fresh(FUNCTIONS_INVOKE_RE);
1581
+ let m;
1582
+ while ((m = invokeRe.exec(text)) !== null) {
1583
+ const name = m[1];
1584
+ if (name) out.add(`functions/${name}`);
1585
+ }
1586
+ return [...out].slice(0, MAX_ENDPOINTS);
1587
+ }
1588
+ function endpointFindings(endpoints) {
1589
+ if (endpoints.length === 0) return [];
1590
+ const list = endpoints.slice(0, MAX_ENDPOINTS);
1591
+ const plural = list.length === 1 ? "" : "s";
1592
+ return [
1593
+ {
1594
+ id: "endpoints:list",
1595
+ category: "config",
1596
+ severity: "low",
1597
+ confidence: "info",
1598
+ title: `Your app calls ${list.length} API endpoint${plural}`,
1599
+ description: `Your app's code calls ${list.length} endpoint${plural}. We only listed them here \u2014 we did not call any of them. Every one of these must check who is calling it, since each is reachable by anyone on the internet, not only through your app's own UI. Our AI Red Team (coming soon) will test them for real authorization gaps.`,
1600
+ location: "client code",
1601
+ proof: { summary: `Endpoints referenced in client code: ${list.join(", ")}.` },
1602
+ fix: {
1603
+ steps: [
1604
+ "For each endpoint listed, confirm it checks authentication/authorization on the server side \u2014 never trust a check that only happens in the browser."
1605
+ ],
1606
+ aiPrompt: `My app's frontend calls these API endpoints: ${list.join(", ")}. For each one, review the server-side handler and confirm it verifies the caller is authenticated and authorized for that specific action/data before doing anything, never trusting a check that only happens in frontend code.`
1607
+ },
1608
+ reverify: { check: "endpoints-list" }
1609
+ }
1610
+ ];
1611
+ }
1612
+
1613
+ // ../engine/src/engine.ts
1614
+ function sortFindings(findings) {
1615
+ return [...findings].sort(
1616
+ (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
1617
+ );
1618
+ }
1619
+ function dedupe(findings) {
1620
+ const seen = /* @__PURE__ */ new Set();
1621
+ const out = [];
1622
+ for (const f of findings) {
1623
+ if (seen.has(f.id)) continue;
1624
+ seen.add(f.id);
1625
+ out.push(f);
1626
+ }
1627
+ return out;
1628
+ }
1629
+ async function scan(ctx, target, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
1630
+ const startedAt = now();
1631
+ const findings = [];
1632
+ const notes = [];
1633
+ const app = await fetchApp(ctx, target.url);
1634
+ notes.push(...app.notes);
1635
+ ctx.log(
1636
+ `Fetched page (status ${app.finalStatus}) + ${app.scriptsFetched.length} script(s).`
1637
+ );
1638
+ const hits = extractSecrets(app.combinedSource);
1639
+ findings.push(...secretFindings(hits, target.url));
1640
+ const harvestedTables = harvestSupabaseTableNames(app.combinedSource);
1641
+ const harvestedRpc = harvestSupabaseRpcNames(app.combinedSource);
1642
+ const harvestedCollections = harvestFirestoreCollectionNames(app.combinedSource);
1643
+ const conn = getSupabaseConnection(hits);
1644
+ if (conn) {
1645
+ ctx.log(`Supabase detected at ${conn.url} \u2014 running read-only RLS sweep.`);
1646
+ findings.push(...await probeRls(ctx, conn, harvestedTables, harvestedRpc));
1647
+ findings.push(...await probeStorage(ctx, conn));
1648
+ findings.push(...await probeAuthConfig(ctx, conn));
1649
+ } else {
1650
+ notes.push("No Supabase anon connection found in client code.");
1651
+ }
1652
+ const fbConfig = extractFirebaseConfig(app.combinedSource);
1653
+ if (fbConfig) {
1654
+ ctx.log("Firebase config detected \u2014 running read-only rules sweep.");
1655
+ if (fbConfig.databaseURL) {
1656
+ findings.push(...await probeRealtimeDatabase(ctx, fbConfig.databaseURL));
1657
+ }
1658
+ if (fbConfig.projectId) {
1659
+ findings.push(
1660
+ ...await probeFirestore(ctx, fbConfig.projectId, fbConfig.apiKey, harvestedCollections)
1661
+ );
1662
+ }
1663
+ if (fbConfig.storageBucket) {
1664
+ findings.push(...await probeFirebaseStorage(ctx, fbConfig.storageBucket));
1665
+ }
1666
+ } else {
1667
+ notes.push("No Firebase config found in client code.");
1668
+ }
1669
+ if (target.local) {
1670
+ notes.push("Local scan: skipped headers, HTTPS, source maps and exposed files. Those depend on your host, so scan the deployed URL for them.");
1671
+ } else {
1672
+ findings.push(
1673
+ ...headerFindings({ url: target.url, headers: app.headers, setCookie: app.setCookie })
1674
+ );
1675
+ const downgrade = await checkHttpDowngrade(ctx, target.url);
1676
+ if (downgrade) findings.push(downgrade);
1677
+ findings.push(...await sourceMapFindings(ctx, app.scripts));
1678
+ findings.push(...await configFileFindings(ctx, target.url));
1679
+ }
1680
+ const detectedLibs = detectLibraries(app.scripts);
1681
+ if (detectedLibs.length > 0) {
1682
+ const { findings: libFindings, note } = await queryOsv(ctx, detectedLibs);
1683
+ findings.push(...libFindings);
1684
+ if (note) notes.push(note);
1685
+ }
1686
+ const endpoints = harvestEndpoints(app.combinedSource);
1687
+ findings.push(...endpointFindings(endpoints));
1688
+ const deduped = sortFindings(dedupe(findings));
1689
+ return {
1690
+ target,
1691
+ startedAt,
1692
+ finishedAt: now(),
1693
+ findings: deduped,
1694
+ notes,
1695
+ worst: worstSeverity(deduped),
1696
+ endpoints
1697
+ };
1698
+ }
1699
+
1700
+ // ../engine/src/report.ts
1701
+ var COLOR = {
1702
+ critical: "\x1B[41m\x1B[97m",
1703
+ high: "\x1B[31m",
1704
+ medium: "\x1B[33m",
1705
+ low: "\x1B[36m",
1706
+ info: "\x1B[90m"
1707
+ };
1708
+ var RESET = "\x1B[0m";
1709
+ var BOLD = "\x1B[1m";
1710
+ function countBySeverity(findings) {
1711
+ const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1712
+ for (const f of findings) c[f.severity]++;
1713
+ return c;
1714
+ }
1715
+ function renderTerminal(result, opts = {}) {
1716
+ const color = opts.color ?? true;
1717
+ const paint = (s, sev) => color ? `${COLOR[sev]}${s}${RESET}` : s;
1718
+ const b = (s) => color ? `${BOLD}${s}${RESET}` : s;
1719
+ const lines = [];
1720
+ lines.push("");
1721
+ lines.push(b(` Shippable scan \u2014 ${result.target.url}`));
1722
+ lines.push(` ${result.startedAt}`);
1723
+ lines.push("");
1724
+ if (result.findings.length === 0) {
1725
+ lines.push(" \u2713 No issues found. (A shallow pass finding nothing is not a guarantee.)");
1726
+ } else {
1727
+ const c = countBySeverity(result.findings);
1728
+ const summary = ["critical", "high", "medium", "low", "info"].filter((s) => c[s]).map((s) => paint(` ${c[s]} ${s} `, s)).join(" ");
1729
+ lines.push(` ${summary}`);
1730
+ lines.push("");
1731
+ for (const f of result.findings) {
1732
+ lines.push(` ${paint(` ${f.severity.toUpperCase()} `, f.severity)} ${b(f.title)}`);
1733
+ lines.push(` ${f.description}`);
1734
+ lines.push(` ${b("Proof:")} ${f.proof.summary}`);
1735
+ if (f.proof.evidence) lines.push(` ${f.proof.evidence}`);
1736
+ if (f.fix.sql) {
1737
+ lines.push(` ${b("Fix (SQL):")}`);
1738
+ for (const l of f.fix.sql.split("\n")) lines.push(` ${l}`);
1739
+ }
1740
+ if (f.fix.aiPrompt) {
1741
+ lines.push(` ${b("Fix (paste into your AI tool):")}`);
1742
+ lines.push(` ${f.fix.aiPrompt}`);
1743
+ }
1744
+ if (f.fix.steps) {
1745
+ lines.push(` ${b("Fix:")}`);
1746
+ for (const s of f.fix.steps) lines.push(` \u2022 ${s}`);
1747
+ }
1748
+ lines.push("");
1749
+ }
1750
+ }
1751
+ if (result.notes.length) {
1752
+ lines.push(b(" Notes:"));
1753
+ for (const n of result.notes) lines.push(` - ${n}`);
1754
+ lines.push("");
1755
+ }
1756
+ return lines.join("\n");
1757
+ }
1758
+
1759
+ // ../engine/src/http.ts
1760
+ var MAX_REDIRECTS = 5;
1761
+ function createHttpClient(opts = {}) {
1762
+ const timeoutMs = opts.timeoutMs ?? 15e3;
1763
+ const maxBytes = opts.maxBytes ?? 5e6;
1764
+ const userAgent = opts.userAgent ?? "Shippable-Scanner/0.1 (+https://shippable.example)";
1765
+ return async (url, init) => {
1766
+ const controller = new AbortController();
1767
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1768
+ try {
1769
+ let current = url;
1770
+ let res;
1771
+ for (let hop = 0; ; hop++) {
1772
+ if (opts.isAllowed && !await opts.isAllowed(current)) {
1773
+ throw new Error(`Blocked request to disallowed address: ${new URL(current).host}`);
1774
+ }
1775
+ res = await fetch(current, {
1776
+ method: init?.method ?? "GET",
1777
+ headers: { "user-agent": userAgent, ...init?.headers ?? {} },
1778
+ body: init?.body,
1779
+ redirect: "manual",
1780
+ signal: controller.signal
1781
+ });
1782
+ const location = res.headers.get("location");
1783
+ const isRedirect = res.status >= 300 && res.status < 400 && location;
1784
+ if (!isRedirect || init?.redirect === "manual") break;
1785
+ if (hop >= MAX_REDIRECTS) throw new Error("Too many redirects.");
1786
+ await res.body?.cancel();
1787
+ current = new URL(location, current).toString();
1788
+ }
1789
+ const headers = {};
1790
+ res.headers.forEach((v, k) => {
1791
+ headers[k.toLowerCase()] = v;
1792
+ });
1793
+ const reader = res.body?.getReader();
1794
+ let text = "";
1795
+ if (reader) {
1796
+ const decoder = new TextDecoder();
1797
+ let total = 0;
1798
+ for (; ; ) {
1799
+ const { done, value } = await reader.read();
1800
+ if (done) break;
1801
+ total += value.byteLength;
1802
+ text += decoder.decode(value, { stream: true });
1803
+ if (total >= maxBytes) {
1804
+ await reader.cancel();
1805
+ break;
1806
+ }
1807
+ }
1808
+ text += decoder.decode();
1809
+ } else {
1810
+ text = await res.text();
1811
+ }
1812
+ const setCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
1813
+ return { status: res.status, headers, text, setCookie };
1814
+ } finally {
1815
+ clearTimeout(timer);
1816
+ }
1817
+ };
1818
+ }
1819
+
1820
+ // ../engine/src/demo.ts
1821
+ function b64url(obj) {
1822
+ return Buffer.from(JSON.stringify(obj), "utf8").toString("base64url");
1823
+ }
1824
+ var anonKey = `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url({
1825
+ role: "anon",
1826
+ iss: "supabase",
1827
+ ref: "demoproject0000000000"
1828
+ })}.c2lnbmF0dXJlZm9yZGVtb29ubHk`;
1829
+ var DEMO_URL = "https://demo-vuln-app.lovable.app";
1830
+ var PAGE = `<!doctype html><html><head>
1831
+ <title>My Startup</title>
1832
+ <script src="/assets/index-demo.js"></script>
1833
+ </head><body><div id="root"></div></body></html>`;
1834
+ var BUNDLE = `
1835
+ /*! react-dom v16.0.0 */
1836
+ var SUPABASE_URL = "https://demoproject0000000000.supabase.co";
1837
+ var SUPABASE_ANON_KEY = "${anonKey}";
1838
+ var STRIPE_SECRET = "sk_live_51DEMOabcdefghijklmnopqrst";
1839
+ var supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
1840
+ var firebaseConfig = {projectId:"demo-firebase-app",databaseURL:"https://demo-firebase-app.firebaseio.com",storageBucket:"demo-firebase-app.appspot.com",apiKey:"AIzaSyDEMOFIREBASEKEY0000000000000"};
1841
+ fetch("/api/checkout", { method: "POST" });
1842
+ fetch("/api/admin/export-users");
1843
+ supabase.functions.invoke("send-receipt");
1844
+ // The billing screen is lazy-loaded \u2014 its own chunk is only fetched by the
1845
+ // browser once a user navigates there, so it never appears above.
1846
+ import("./chunk-billing-demo.js");
1847
+ //# sourceMappingURL=index-demo.js.map
1848
+ `;
1849
+ var OSV_RESPONSE = JSON.stringify({
1850
+ results: [
1851
+ {
1852
+ vulns: [
1853
+ {
1854
+ id: "GHSA-demo-0001",
1855
+ summary: "Demo advisory: a cross-site-scripting issue fixed in a later release.",
1856
+ database_specific: { severity: "HIGH" },
1857
+ affected: [
1858
+ {
1859
+ package: { name: "react-dom", ecosystem: "npm" },
1860
+ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }, { fixed: "16.0.1" }] }]
1861
+ }
1862
+ ]
1863
+ }
1864
+ ]
1865
+ }
1866
+ ]
1867
+ });
1868
+ var LAZY_CHUNK = `
1869
+ var s2 = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
1870
+ s2.from("invoices").select("*");
1871
+ var BILLING_PROVIDER_KEY = "sk_live_51DEMOCHUNKabcdefghijklmnopqr";
1872
+ `;
1873
+ var SOURCE_MAP = JSON.stringify({
1874
+ version: 3,
1875
+ sources: ["src/App.tsx"],
1876
+ sourcesContent: ["const key = 'sk_live_51DEMOMAPLEAKabcdefghijklmno';"],
1877
+ mappings: "AAAA"
1878
+ });
1879
+ var DOTENV = "DATABASE_URL=postgres://user:pass@host:5432/db\nSTRIPE_SECRET_KEY=sk_live_51DEMOENVabcdefghijklmnop\n";
1880
+ var OPENAPI = JSON.stringify({
1881
+ definitions: { users: {}, orders: {}, waitlist: {} },
1882
+ paths: { "/": {}, "/users": {}, "/orders": {}, "/waitlist": {}, "/rpc/reset_password": {} }
1883
+ });
1884
+ function demoHttpClient() {
1885
+ const routes = [
1886
+ // More specific needles must come before the generic "lovable.app" catch-all below,
1887
+ // since routes are matched in order by substring.
1888
+ ["/assets/index-demo.js.map", { status: 200, headers: {}, text: SOURCE_MAP }],
1889
+ ["/assets/index-demo.js", { status: 200, headers: {}, text: BUNDLE }],
1890
+ ["/assets/chunk-billing-demo.js", { status: 200, headers: {}, text: LAZY_CHUNK }],
1891
+ ["/.env", { status: 200, headers: {}, text: DOTENV }],
1892
+ [
1893
+ "/rest/v1/invoices",
1894
+ {
1895
+ status: 200,
1896
+ headers: { "content-range": "0-0/312" },
1897
+ text: '[{"id":1,"customer_email":"jane@example.com","amount_cents":4900}]'
1898
+ }
1899
+ ],
1900
+ [
1901
+ "/rest/v1/users",
1902
+ {
1903
+ status: 200,
1904
+ headers: { "content-range": "0-2/1284" },
1905
+ text: '[{"id":1,"email":"jane@example.com","full_name":"Jane Doe","stripe_customer":"cus_x"},{"id":2,"email":"bob@example.com","full_name":"Bob Roe","stripe_customer":"cus_y"}]'
1906
+ }
1907
+ ],
1908
+ [
1909
+ "/rest/v1/orders",
1910
+ {
1911
+ status: 200,
1912
+ headers: { "content-range": "0-2/57" },
1913
+ text: '[{"id":1,"user_email":"jane@example.com","amount_cents":4900,"card_last4":"4242"}]'
1914
+ }
1915
+ ],
1916
+ ["/rest/v1/waitlist", { status: 200, headers: {}, text: "[]" }],
1917
+ // properly locked
1918
+ ["/rest/v1/", { status: 200, headers: {}, text: OPENAPI }],
1919
+ [
1920
+ "/storage/v1/bucket",
1921
+ { status: 200, headers: {}, text: '[{"name":"user-uploads","public":true}]' }
1922
+ ],
1923
+ ["/auth/v1/settings", { status: 200, headers: {}, text: '{"mailer_autoconfirm":true}' }],
1924
+ // Firebase Realtime Database: no rules restricting reads — readable by anyone.
1925
+ ["firebaseio.com/.json", { status: 200, headers: {}, text: '{"users":true,"config":true}' }],
1926
+ // Firestore + Storage rules are locked down in this demo, so only the RTDB
1927
+ // finding fires — keeps the demo output focused on one Firebase issue.
1928
+ ["firestore.googleapis.com", { status: 403, headers: {}, text: '{"error":{"status":"PERMISSION_DENIED"}}' }],
1929
+ ["firebasestorage.googleapis.com", { status: 403, headers: {}, text: '{"error":{"code":403}}' }],
1930
+ ["api.osv.dev", { status: 200, headers: {}, text: OSV_RESPONSE }],
1931
+ [
1932
+ "lovable.app",
1933
+ { status: 200, headers: {}, text: PAGE, setCookie: ["session=demo-token; Path=/"] }
1934
+ ]
1935
+ ];
1936
+ return async (url) => {
1937
+ for (const [needle, r] of routes) if (url.includes(needle)) return r;
1938
+ return { status: 404, headers: {}, text: "" };
1939
+ };
1940
+ }
1941
+
1942
+ // ../engine/src/verify.ts
1943
+ import { randomBytes } from "node:crypto";
1944
+ var META_NAME = "shippable-site-verification";
1945
+ function generateToken() {
1946
+ return "sv_" + randomBytes(24).toString("hex");
1947
+ }
1948
+ function verificationTagFor(token) {
1949
+ return `<meta name="${META_NAME}" content="${token}">`;
1950
+ }
1951
+ function verificationPromptFor(token) {
1952
+ return `Add this exact meta tag inside the <head> of my app's HTML and redeploy:
1953
+
1954
+ ${verificationTagFor(token)}
1955
+
1956
+ Do not change the name or content values.`;
1957
+ }
1958
+ function htmlContainsToken(html, token) {
1959
+ const metaRe = /<meta\b[^>]*>/gi;
1960
+ let m;
1961
+ while ((m = metaRe.exec(html)) !== null) {
1962
+ const tag = m[0];
1963
+ const nameMatch = /name\s*=\s*["']([^"']+)["']/i.exec(tag);
1964
+ const contentMatch = /content\s*=\s*["']([^"']+)["']/i.exec(tag);
1965
+ if (nameMatch?.[1]?.toLowerCase() === META_NAME && contentMatch?.[1] === token) {
1966
+ return true;
1967
+ }
1968
+ }
1969
+ return false;
1970
+ }
1971
+ async function verifyOwnership(ctx, url, token) {
1972
+ let res;
1973
+ try {
1974
+ res = await ctx.http(url, { method: "GET" });
1975
+ } catch {
1976
+ return { verified: false, reason: "Could not reach the site." };
1977
+ }
1978
+ if (res.status >= 400) {
1979
+ return { verified: false, reason: `Site returned status ${res.status}.` };
1980
+ }
1981
+ if (htmlContainsToken(res.text, token)) {
1982
+ return { verified: true, reason: "Verification tag found." };
1983
+ }
1984
+ return {
1985
+ verified: false,
1986
+ reason: "Verification tag not found. Add it to your <head> and redeploy."
1987
+ };
1988
+ }
1989
+
1990
+ // ../engine/src/target.ts
1991
+ function isLocalHost(hostname) {
1992
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
1993
+ return h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "0.0.0.0" || /^127\.\d+\.\d+\.\d+$/.test(h);
1994
+ }
1995
+ function parseTarget(input) {
1996
+ const bare = !/^https?:\/\//i.test(input);
1997
+ try {
1998
+ const u = new URL(bare ? `https://${input}` : input);
1999
+ if (bare && isLocalHost(u.hostname)) u.protocol = "http:";
2000
+ return u;
2001
+ } catch {
2002
+ return null;
2003
+ }
2004
+ }
2005
+ var LOCAL_SCAN_BACKENDS = /(^|\.)(supabase\.co|supabase\.in|firebaseio\.com|firebasedatabase\.app)$|^(firebasestorage\.googleapis\.com|firestore\.googleapis\.com|api\.osv\.dev)$/;
2006
+ function localScanAllows(url) {
2007
+ try {
2008
+ const host = new URL(url).hostname;
2009
+ return isLocalHost(host) || LOCAL_SCAN_BACKENDS.test(host.toLowerCase());
2010
+ } catch {
2011
+ return false;
2012
+ }
2013
+ }
2014
+
2015
+ // ../engine/src/cli.ts
2016
+ function makeCtx(http, verbose) {
2017
+ return {
2018
+ http,
2019
+ readOnly: true,
2020
+ maxBytesPerAsset: 5e6,
2021
+ log: (m) => {
2022
+ if (verbose) console.error(` \xB7 ${m}`);
2023
+ }
2024
+ };
2025
+ }
2026
+ var TOKENS_FILE = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "shippable", "tokens.json");
2027
+ async function tokenFor(host) {
2028
+ let tokens = {};
2029
+ try {
2030
+ tokens = JSON.parse(await readFile(TOKENS_FILE, "utf8"));
2031
+ } catch {
2032
+ }
2033
+ if (tokens[host]) return tokens[host];
2034
+ tokens[host] = generateToken();
2035
+ await mkdir(join(TOKENS_FILE, ".."), { recursive: true });
2036
+ await writeFile(TOKENS_FILE, JSON.stringify(tokens, null, 2) + "\n", { mode: 384 });
2037
+ return tokens[host];
2038
+ }
2039
+ async function main() {
2040
+ const [cmd, ...rest] = process.argv.slice(2);
2041
+ const verbose = rest.includes("--verbose") || rest.includes("-v");
2042
+ switch (cmd) {
2043
+ case "demo": {
2044
+ const result = await scan(makeCtx(demoHttpClient(), verbose), { url: DEMO_URL });
2045
+ console.log(renderTerminal(result));
2046
+ return 0;
2047
+ }
2048
+ case "scan": {
2049
+ const input = rest.find((a) => !a.startsWith("-"));
2050
+ const target = input ? parseTarget(input) : null;
2051
+ if (!target) {
2052
+ console.error("usage: shippable scan <url> e.g. shippable scan localhost:5173");
2053
+ return 2;
2054
+ }
2055
+ const local = isLocalHost(target.hostname);
2056
+ const http = createHttpClient({
2057
+ userAgent: "Shippable-CLI/0.1 (+https://shippable.dev)",
2058
+ isAllowed: local ? async (u) => localScanAllows(u) : void 0
2059
+ });
2060
+ const ctx = makeCtx(http, verbose);
2061
+ if (!local) {
2062
+ const token = await tokenFor(target.host);
2063
+ const v = await verifyOwnership(ctx, target.origin, token);
2064
+ if (!v.verified) {
2065
+ console.log(`
2066
+ First, prove you own ${target.host}. ${v.reason}
2067
+ `);
2068
+ console.log(" Paste this into your AI tool (Lovable, Bolt, Cursor\u2026):\n");
2069
+ for (const line of verificationPromptFor(token).split("\n")) console.log(" " + line);
2070
+ console.log(`
2071
+ Once it's deployed, run this again: shippable scan ${target.origin}
2072
+ `);
2073
+ return 2;
2074
+ }
2075
+ }
2076
+ const result = await scan(ctx, { url: target.toString(), local });
2077
+ console.log(renderTerminal(result));
2078
+ return result.worst === "critical" || result.worst === "high" ? 1 : 0;
2079
+ }
2080
+ default:
2081
+ console.log(
2082
+ "\n Shippable: find the leaks in your AI-built app before someone else does.\n\n shippable scan localhost:5173 scan your dev server before you push\n shippable scan https://myapp.com scan your live app (asks you to verify ownership once)\n shippable demo see a scan of a deliberately leaky test app\n\n Read-only: we look, never change anything. https://shippable.dev\n"
2083
+ );
2084
+ return cmd ? 1 : 0;
2085
+ }
2086
+ }
2087
+ main().then(
2088
+ (code) => process.exit(code),
2089
+ (err) => {
2090
+ const msg = err instanceof Error ? err.message : String(err);
2091
+ const off = /^Blocked request to disallowed address: (.+)$/.exec(msg);
2092
+ console.error(
2093
+ off ? `
2094
+ Local scans stay on your machine, but this one tried to reach ${off[1]}.
2095
+ To scan a live site, run: shippable scan https://${off[1]}
2096
+ ` : `Fatal: ${msg}`
2097
+ );
2098
+ process.exit(1);
2099
+ }
2100
+ );
2101
+ /**
2102
+ * Outdated-library detection + OSV lookup.
2103
+ *
2104
+ * Detection is deliberately conservative: minifiers strip ordinary comments
2105
+ * but preserve "banner" comments (`/*! ... *\/` or anything containing
2106
+ * `@license`), which is exactly why a name+version pulled from one of those
2107
+ * banners is trustworthy — it's the library author's own declared version,
2108
+ * not a guess. We only trust a banner whose library name is in our small
2109
+ * known-library allowlist (KNOWN_LIBS); anything else is skipped rather than
2110
+ * reported, because a wrong version turns into a false vulnerability claim,
2111
+ * which is worse than saying nothing.
2112
+ *
2113
+ * The one OSV request is the sole exception to this scanner's "GET the
2114
+ * target only" rule: a single POST to api.osv.dev (our own lookup, never the
2115
+ * scanned app) batching every detected package. Routed through ctx.http like
2116
+ * every other request, so it's still bound by the same timeout and the
2117
+ * production SSRF allowlist.
2118
+ */
2119
+ // `@license lodash 4.17.20`, `@license Chart.js v4.4.0`.