supascan 0.0.1

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,194 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { createMockCLIContext } from "../mocks";
3
+ import { ExtractorService } from "./extractor.service";
4
+
5
+ const mockFetch = mock(() => ({
6
+ ok: true,
7
+ text: () => Promise.resolve(""),
8
+ headers: {
9
+ get: () => "text/html",
10
+ },
11
+ })) as any;
12
+
13
+ global.fetch = mockFetch;
14
+
15
+ describe("ExtractorService", () => {
16
+ beforeEach(() => {
17
+ mock.restore();
18
+ });
19
+
20
+ describe("extractFromContent", () => {
21
+ test("extracts from createBrowserClient pattern", () => {
22
+ const content = `
23
+ const supabase = createBrowserClient(
24
+ "https://test.supabase.co",
25
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test"
26
+ );
27
+ `;
28
+ const ctx = createMockCLIContext();
29
+
30
+ const result = ExtractorService.extractFromContent(content, ctx);
31
+
32
+ expect(result.success).toBe(true);
33
+ if (result.success) {
34
+ expect(result.value.url).toBe("https://test.supabase.co");
35
+ expect(result.value.key).toBe(
36
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test",
37
+ );
38
+ }
39
+ });
40
+
41
+ test("extracts from closest URL-key pairs", () => {
42
+ const content = `
43
+ const url = "https://test.supabase.co";
44
+ const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test";
45
+ `;
46
+ const ctx = createMockCLIContext();
47
+
48
+ const result = ExtractorService.extractFromContent(content, ctx);
49
+
50
+ expect(result.success).toBe(true);
51
+ if (result.success) {
52
+ expect(result.value.url).toBe("https://test.supabase.co");
53
+ expect(result.value.key).toBe(
54
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test",
55
+ );
56
+ }
57
+ });
58
+
59
+ test("returns error when no credentials found", () => {
60
+ const content = "const x = 1;";
61
+ const ctx = createMockCLIContext();
62
+
63
+ const result = ExtractorService.extractFromContent(content, ctx);
64
+
65
+ expect(result.success).toBe(false);
66
+ if (!result.success) {
67
+ expect(result.error.message).toBe(
68
+ "No Supabase URL-key pairs found in content",
69
+ );
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("extractFromUrl", () => {
75
+ test("handles JavaScript files", async () => {
76
+ mockFetch.mockResolvedValueOnce({
77
+ ok: true,
78
+ text: () =>
79
+ Promise.resolve(`
80
+ const supabase = createBrowserClient(
81
+ "https://test.supabase.co",
82
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test"
83
+ );
84
+ `),
85
+ headers: {
86
+ get: () => "application/javascript",
87
+ },
88
+ });
89
+
90
+ const ctx = createMockCLIContext();
91
+ const result = await ExtractorService.extractFromUrl(
92
+ "https://example.com/app.js",
93
+ ctx,
94
+ );
95
+
96
+ expect(result.success).toBe(true);
97
+ expect(mockFetch).toHaveBeenCalledWith("https://example.com/app.js");
98
+ });
99
+
100
+ test("handles HTML files with inline scripts", async () => {
101
+ const htmlContent = `
102
+ <html>
103
+ <script>
104
+ const supabase = createBrowserClient(
105
+ "https://test.supabase.co",
106
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test"
107
+ );
108
+ </script>
109
+ </html>
110
+ `;
111
+
112
+ mockFetch.mockResolvedValueOnce({
113
+ ok: true,
114
+ text: () => Promise.resolve(htmlContent),
115
+ headers: {
116
+ get: () => "text/html",
117
+ },
118
+ });
119
+
120
+ const ctx = createMockCLIContext();
121
+ const result = await ExtractorService.extractFromUrl(
122
+ "https://example.com/index.html",
123
+ ctx,
124
+ );
125
+
126
+ expect(result.success).toBe(true);
127
+ if (result.success) {
128
+ expect(result.value.url).toBe("https://test.supabase.co");
129
+ }
130
+ });
131
+
132
+ test("handles HTML files with external scripts", async () => {
133
+ const htmlContent = `
134
+ <html>
135
+ <script src="/app.js"></script>
136
+ </html>
137
+ `;
138
+
139
+ mockFetch
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ text: () => Promise.resolve(htmlContent),
143
+ headers: {
144
+ get: () => "text/html",
145
+ },
146
+ })
147
+ .mockResolvedValueOnce({
148
+ ok: true,
149
+ text: () =>
150
+ Promise.resolve(`
151
+ const supabase = createBrowserClient(
152
+ "https://test.supabase.co",
153
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.test"
154
+ );
155
+ `),
156
+ headers: {
157
+ get: () => "application/javascript",
158
+ },
159
+ });
160
+
161
+ const ctx = createMockCLIContext();
162
+ const result = await ExtractorService.extractFromUrl(
163
+ "https://example.com/index.html",
164
+ ctx,
165
+ );
166
+
167
+ expect(result.success).toBe(true);
168
+ expect(mockFetch).toHaveBeenCalledTimes(4);
169
+ });
170
+
171
+ test("returns error when fetch fails", async () => {
172
+ mockFetch.mockResolvedValueOnce({
173
+ ok: false,
174
+ status: 404,
175
+ statusText: "Not Found",
176
+ text: () => Promise.resolve(""),
177
+ headers: {
178
+ get: () => "text/html",
179
+ },
180
+ });
181
+
182
+ const ctx = createMockCLIContext();
183
+ const result = await ExtractorService.extractFromUrl(
184
+ "https://example.com/missing.html",
185
+ ctx,
186
+ );
187
+
188
+ expect(result.success).toBe(false);
189
+ if (!result.success) {
190
+ expect(result.error.message).toBe("Failed to fetch URL: 404 Not Found");
191
+ }
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,230 @@
1
+ import type { CLIContext } from "../context";
2
+ import { type Result, err, log, ok } from "../utils";
3
+
4
+ export type ExtractedCredentials = {
5
+ url: string;
6
+ key: string;
7
+ source?: string;
8
+ };
9
+
10
+ export abstract class ExtractorService {
11
+ private static readonly URL_PATTERNS = [
12
+ /https:\/\/[a-z0-9-]+\.supabase\.co\/?/g,
13
+ /['"`]https:\/\/[a-z0-9-]+\.supabase\.co\/?['"`]/g,
14
+ ];
15
+
16
+ private static readonly KEY_PATTERNS = [
17
+ /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
18
+ /['"`]eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+['"`]/g,
19
+ ];
20
+
21
+ private static readonly CREATE_BROWSER_CLIENT_PATTERN =
22
+ /createBrowserClient\)\s*\(\s*["']([^"']+)["']\s*,\s*["']([^"']+)["']/g;
23
+
24
+ private static readonly SCRIPT_SRC_PATTERN =
25
+ /<script[^>]+src=["']([^"']+)["']/gi;
26
+ private static readonly INLINE_SCRIPT_PATTERN =
27
+ /<script[^>]*>([\s\S]*?)<\/script>/gi;
28
+
29
+ public static async extractFromUrl(
30
+ url: string,
31
+ ctx: CLIContext,
32
+ ): Promise<Result<ExtractedCredentials>> {
33
+ log.debug(ctx, `Fetching content from: ${url}`);
34
+
35
+ const response = await fetch(url);
36
+
37
+ if (!response.ok) {
38
+ return err(
39
+ new Error(
40
+ `Failed to fetch URL: ${response.status} ${response.statusText}`,
41
+ ),
42
+ );
43
+ }
44
+
45
+ const content = await response.text();
46
+ const contentType = response.headers.get("content-type") ?? "";
47
+
48
+ log.debug(ctx, `Fetched ${content.length} bytes (${contentType})`);
49
+
50
+ const isHtml =
51
+ contentType.includes("text/html") ||
52
+ content.trim().startsWith("<!DOCTYPE") ||
53
+ content.trim().startsWith("<html");
54
+ const isJs =
55
+ url.endsWith(".js") ||
56
+ contentType.includes("javascript") ||
57
+ contentType.includes("ecmascript");
58
+
59
+ if (isJs) {
60
+ return this.extractFromContent(content, ctx, url);
61
+ }
62
+
63
+ if (isHtml) {
64
+ return await this.extractFromHtml(content, url, ctx);
65
+ }
66
+
67
+ return this.extractFromContent(content, ctx, url);
68
+ }
69
+
70
+ public static async extractFromHtml(
71
+ html: string,
72
+ baseUrl: string,
73
+ ctx: CLIContext,
74
+ ): Promise<Result<ExtractedCredentials>> {
75
+ log.debug(ctx, "Detected HTML content, searching for JS files...");
76
+
77
+ const inlineScripts = Array.from(html.matchAll(this.INLINE_SCRIPT_PATTERN));
78
+ log.debug(ctx, `Found ${inlineScripts.length} inline scripts`);
79
+
80
+ for (const match of inlineScripts) {
81
+ const scriptContent = match[1];
82
+ if (!scriptContent) continue;
83
+
84
+ const result = this.extractFromContent(
85
+ scriptContent,
86
+ ctx,
87
+ "inline script",
88
+ );
89
+ if (result.success) {
90
+ log.debug(ctx, "Found credentials in inline script");
91
+ return result;
92
+ }
93
+ }
94
+
95
+ const scriptSrcs = Array.from(html.matchAll(this.SCRIPT_SRC_PATTERN));
96
+ log.debug(ctx, `Found ${scriptSrcs.length} external scripts`);
97
+
98
+ for (const match of scriptSrcs) {
99
+ const scriptSrc = match[1];
100
+ if (!scriptSrc) continue;
101
+
102
+ const scriptUrl = this.resolveUrl(scriptSrc, baseUrl);
103
+ log.debug(ctx, `Checking script: ${scriptUrl}`);
104
+
105
+ const response = await fetch(scriptUrl);
106
+ if (!response.ok) {
107
+ log.debug(ctx, `Failed to fetch ${scriptUrl}`);
108
+ continue;
109
+ }
110
+
111
+ const content = await response.text();
112
+ const result = this.extractFromContent(content, ctx, scriptUrl);
113
+
114
+ if (result.success) {
115
+ log.debug(ctx, `Found credentials in ${scriptUrl}`);
116
+ return result;
117
+ }
118
+ }
119
+
120
+ return err(new Error("No Supabase credentials found in any scripts"));
121
+ }
122
+
123
+ public static extractFromContent(
124
+ content: string,
125
+ ctx: CLIContext,
126
+ source?: string,
127
+ ): Result<ExtractedCredentials> {
128
+ log.debug(ctx, "Extracting Supabase credentials...");
129
+
130
+ // First, try to find createBrowserClient pattern (most specific)
131
+ const createBrowserClientMatch =
132
+ this.CREATE_BROWSER_CLIENT_PATTERN.exec(content);
133
+ if (createBrowserClientMatch) {
134
+ const url = createBrowserClientMatch[1];
135
+ const key = createBrowserClientMatch[2];
136
+
137
+ if (url && key) {
138
+ log.debug(ctx, "Found createBrowserClient pattern");
139
+ log.debug(ctx, `Extracted URL: ${url}`);
140
+ log.debug(ctx, `Extracted key: ${key.substring(0, 20)}...`);
141
+
142
+ return ok({ url, key, source });
143
+ }
144
+ }
145
+
146
+ // Fallback to the original closest pairs method
147
+ const pairs = this.findClosestPairs(content);
148
+
149
+ log.debug(ctx, `Found ${pairs.length} potential URL-key pairs`);
150
+
151
+ if (pairs.length === 0) {
152
+ return err(new Error("No Supabase URL-key pairs found in content"));
153
+ }
154
+
155
+ const pair = pairs[0];
156
+ if (!pair) {
157
+ return err(new Error("No valid URL-key pairs found"));
158
+ }
159
+
160
+ const { url, key } = pair;
161
+
162
+ log.debug(ctx, `Extracted URL: ${url}`);
163
+ log.debug(ctx, `Extracted key: ${key.substring(0, 20)}...`);
164
+
165
+ return ok({ url, key, source });
166
+ }
167
+
168
+ private static resolveUrl(url: string, baseUrl: string): string {
169
+ if (url.startsWith("http://") || url.startsWith("https://")) {
170
+ return url;
171
+ }
172
+
173
+ const base = new URL(baseUrl);
174
+
175
+ if (url.startsWith("//")) {
176
+ return `${base.protocol}${url}`;
177
+ }
178
+
179
+ if (url.startsWith("/")) {
180
+ return `${base.origin}${url}`;
181
+ }
182
+
183
+ const basePath = base.pathname.substring(
184
+ 0,
185
+ base.pathname.lastIndexOf("/") + 1,
186
+ );
187
+ return `${base.origin}${basePath}${url}`;
188
+ }
189
+
190
+ private static findClosestPairs(
191
+ content: string,
192
+ ): Array<{ url: string; key: string; distance: number }> {
193
+ const urlMatches = this.findAllMatches(content, this.URL_PATTERNS);
194
+ const keyMatches = this.findAllMatches(content, this.KEY_PATTERNS);
195
+
196
+ const pairs: Array<{ url: string; key: string; distance: number }> = [];
197
+
198
+ for (const urlMatch of urlMatches) {
199
+ for (const keyMatch of keyMatches) {
200
+ const distance = Math.abs(urlMatch.index - keyMatch.index);
201
+ pairs.push({
202
+ url: urlMatch.text.replace(/['"`;]/g, ""),
203
+ key: keyMatch.text.replace(/['"`;]/g, ""),
204
+ distance,
205
+ });
206
+ }
207
+ }
208
+
209
+ return pairs.sort((a, b) => a.distance - b.distance);
210
+ }
211
+
212
+ private static findAllMatches(
213
+ content: string,
214
+ patterns: RegExp[],
215
+ ): Array<{ text: string; index: number }> {
216
+ const matches: Array<{ text: string; index: number }> = [];
217
+
218
+ patterns.forEach((pattern) => {
219
+ let match;
220
+ while ((match = pattern.exec(content)) !== null) {
221
+ matches.push({
222
+ text: match[0],
223
+ index: match.index,
224
+ });
225
+ }
226
+ });
227
+
228
+ return matches;
229
+ }
230
+ }