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.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # supascan
2
+
3
+ A security analysis CLI tool for Supabase databases that helps identify exposed data, analyze schemas, and test RPC functions.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g supascan
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic Analysis
14
+
15
+ Analyze your Supabase database for security issues:
16
+
17
+ ```bash
18
+ supascan --url https://your-project.supabase.co --key your-anon-key
19
+ ```
20
+
21
+ ### Available Commands
22
+
23
+ #### Database Analysis
24
+
25
+ ```bash
26
+ # Analyze all schemas
27
+ supascan --url <url> --key <key>
28
+
29
+ # Analyze specific schema
30
+ supascan --url <url> --key <key> --schema public
31
+
32
+ # Generate HTML report
33
+ supascan --url <url> --key <key> --html
34
+
35
+ # JSON output
36
+ supascan --url <url> --key <key> --json
37
+ ```
38
+
39
+ #### Data Dumping
40
+
41
+ ```bash
42
+ # Dump table data
43
+ supascan --url <url> --key <key> --dump public.users --limit 100
44
+
45
+ # Dump Swagger JSON for schema
46
+ supascan --url <url> --key <key> --dump public
47
+ ```
48
+
49
+ #### RPC Testing
50
+
51
+ ```bash
52
+ # Get RPC help
53
+ supascan --url <url> --key <key> --rpc public.get_user_stats
54
+
55
+ # Call RPC with parameters
56
+ supascan --url <url> --key <key> --rpc public.get_user_stats --args '{"user_id": "123"}'
57
+
58
+ # Show query execution plan
59
+ supascan --url <url> --key <key> --rpc public.get_user_stats --args '{"user_id": "123"}' --explain
60
+ ```
61
+
62
+ #### Credential Extraction (Experimental)
63
+
64
+ ```bash
65
+ # Extract credentials from JS file
66
+ supascan --extract https://example.com/app.js --url <url> --key <key>
67
+ ```
68
+
69
+ ## Options
70
+
71
+ | Option | Description |
72
+ | ---------------------------------- | ---------------------------------------------------------------- |
73
+ | `-u, --url <url>` | Supabase URL |
74
+ | `-k, --key <key>` | Supabase anon key |
75
+ | `-s, --schema <schema>` | Schema to analyze (default: all schemas) |
76
+ | `-x, --extract <url>` | Extract credentials from JS file URL (experimental) |
77
+ | `--dump <schema.table\|schema>` | Dump data from specific table or swagger JSON from schema |
78
+ | `--limit <number>` | Limit rows for dump or RPC results (default: 10) |
79
+ | `--rpc <schema.rpc_name>` | Call an RPC function (read-only operations only) |
80
+ | `--args <json>` | JSON arguments for RPC call (use $VAR for environment variables) |
81
+ | `--json` | Output as JSON |
82
+ | `--html` | Generate HTML report |
83
+ | `-d, --debug` | Enable debug mode |
84
+ | `--explain` | Show query execution plan |
85
+ | `--suppress-experimental-warnings` | Suppress experimental warnings |
86
+
87
+ ## What supascan Analyzes
88
+
89
+ ### Database Security Assessment
90
+
91
+ - **Schema Discovery**: Automatically discovers all available schemas
92
+ - **Table Access Analysis**: Identifies which tables are:
93
+ - ✅ **Readable** - Data is exposed and accessible
94
+ - ⚠️ **Empty/Protected** - No data or protected by RLS
95
+ - ❌ **Denied** - Access is explicitly denied
96
+
97
+ ### JWT Token Analysis
98
+
99
+ - Parses and displays JWT token information
100
+ - Shows issuer, audience, expiration, and role information
101
+
102
+ ### RPC Function Analysis
103
+
104
+ - Lists all available RPC functions
105
+ - Shows parameter requirements and types
106
+ - Validates parameters before execution
107
+
108
+ ### Output Formats
109
+
110
+ - **Console**: Colorized terminal output with detailed analysis
111
+ - **JSON**: Machine-readable output for scripting
112
+ - **HTML**: Visual report that opens in your browser
113
+
114
+ ## Examples
115
+
116
+ ### Security Analysis Report
117
+
118
+ ```bash
119
+ supascan --url https://abc123.supabase.co --key eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... --html
120
+ ```
121
+
122
+ ### Check Specific Table Access
123
+
124
+ ```bash
125
+ supascan --url https://abc123.supabase.co --key eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... --dump public.users --limit 5
126
+ ```
127
+
128
+ ### Test RPC Function
129
+
130
+ ```bash
131
+ supascan --url https://abc123.supabase.co --key eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... --rpc public.get_user_count --args '{"active": true}'
132
+ ```
133
+
134
+ ## Security Considerations
135
+
136
+ ⚠️ **Important**: This tool is designed for security analysis and testing. Only use it on:
137
+
138
+ - Your own databases
139
+ - Databases you have explicit permission to test
140
+ - Staging/development environments
141
+
142
+ Never use this tool on production databases without proper authorization.
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ # Install dependencies
148
+ bun install
149
+
150
+ # Run locally
151
+ bun run start
152
+
153
+ # Build
154
+ bun run build
155
+
156
+ # Test
157
+ bun test
158
+
159
+ # Lint
160
+ bun run lint
161
+ ```
162
+
163
+ ## License
164
+
165
+ Private - All rights reserved.
@@ -0,0 +1,187 @@
1
+ import pc from "picocolors";
2
+ import type { CLIContext } from "../context";
3
+ import {
4
+ AnalyzerService,
5
+ type AnalysisResult,
6
+ } from "../services/analyzer.service";
7
+ import { HtmlRendererService } from "../services/html-renderer.service";
8
+ import {
9
+ generateTempFilePath,
10
+ log,
11
+ openInBrowser,
12
+ writeHtmlFile,
13
+ } from "../utils";
14
+
15
+ export async function executeAnalyzeCommand(
16
+ ctx: CLIContext,
17
+ options: { schema?: string },
18
+ ): Promise<void> {
19
+ const analysisResult = await AnalyzerService.analyze(ctx, options.schema);
20
+
21
+ if (!analysisResult.success) {
22
+ log.error("Analysis failed", analysisResult.error.message);
23
+ process.exit(1);
24
+ }
25
+
26
+ if (ctx.json) {
27
+ console.log(JSON.stringify(analysisResult.value, null, 2));
28
+ } else if (ctx.html) {
29
+ const htmlContent = HtmlRendererService.generateHtmlReport(
30
+ analysisResult.value,
31
+ ctx.url,
32
+ ctx.key,
33
+ );
34
+ const filePath = generateTempFilePath();
35
+ writeHtmlFile(filePath, htmlContent);
36
+ openInBrowser(filePath);
37
+ log.success(`HTML report generated: ${filePath}`);
38
+ } else {
39
+ displayAnalysisResult(analysisResult.value);
40
+ }
41
+ }
42
+
43
+ function displayAnalysisResult(result: AnalysisResult): void {
44
+ console.log();
45
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
46
+ console.log(pc.bold(pc.cyan(" SUPABASE DATABASE ANALYSIS")));
47
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
48
+ console.log();
49
+
50
+ console.log(pc.bold(pc.yellow("TARGET SUMMARY")));
51
+ console.log(pc.dim("─".repeat(20)));
52
+ console.log(pc.bold("Domain:"), pc.white(result.summary.domain));
53
+
54
+ if (result.summary.metadata?.service) {
55
+ console.log(pc.bold("Service:"), pc.white(result.summary.metadata.service));
56
+ }
57
+
58
+ if (result.summary.metadata?.region) {
59
+ console.log(
60
+ pc.bold("Project ID:"),
61
+ pc.white(result.summary.metadata.region),
62
+ );
63
+ }
64
+
65
+ if (result.summary.metadata?.title) {
66
+ console.log(pc.bold("Title:"), pc.white(result.summary.metadata.title));
67
+ }
68
+
69
+ if (result.summary.metadata?.version) {
70
+ console.log(pc.bold("Version:"), pc.white(result.summary.metadata.version));
71
+ }
72
+
73
+ if (result.summary.jwtInfo) {
74
+ console.log();
75
+ console.log(pc.bold(pc.yellow("JWT TOKEN INFO")));
76
+ console.log(pc.dim("─".repeat(20)));
77
+
78
+ if (result.summary.jwtInfo.iss) {
79
+ console.log(pc.bold("Issuer:"), pc.white(result.summary.jwtInfo.iss));
80
+ }
81
+ if (result.summary.jwtInfo.aud) {
82
+ console.log(pc.bold("Audience:"), pc.white(result.summary.jwtInfo.aud));
83
+ }
84
+ if (result.summary.jwtInfo.role) {
85
+ console.log(pc.bold("Role:"), pc.white(result.summary.jwtInfo.role));
86
+ }
87
+ if (result.summary.jwtInfo.exp) {
88
+ const expDate = new Date(result.summary.jwtInfo.exp * 1000);
89
+ console.log(pc.bold("Expires:"), pc.white(expDate.toISOString()));
90
+ }
91
+ if (result.summary.jwtInfo.iat) {
92
+ const iatDate = new Date(result.summary.jwtInfo.iat * 1000);
93
+ console.log(pc.bold("Issued:"), pc.white(iatDate.toISOString()));
94
+ }
95
+ }
96
+
97
+ console.log();
98
+ console.log(pc.bold(pc.cyan("DATABASE ANALYSIS")));
99
+ console.log(pc.dim("─".repeat(20)));
100
+ console.log(
101
+ pc.bold("Schemas discovered:"),
102
+ pc.green(result.schemas.length.toString()),
103
+ );
104
+ console.log();
105
+
106
+ Object.entries(result.schemaDetails).forEach(([schema, analysis]) => {
107
+ console.log(pc.bold(pc.cyan(`Schema: ${schema}`)));
108
+ console.log();
109
+
110
+ const exposedCount = Object.values(analysis.tableAccess).filter(
111
+ (a) => a.status === "readable",
112
+ ).length;
113
+ const deniedCount = Object.values(analysis.tableAccess).filter(
114
+ (a) => a.status === "denied",
115
+ ).length;
116
+ const emptyCount = Object.values(analysis.tableAccess).filter(
117
+ (a) => a.status === "empty",
118
+ ).length;
119
+
120
+ console.log(
121
+ pc.bold("Tables:"),
122
+ pc.green(analysis.tables.length.toString()),
123
+ );
124
+ console.log(
125
+ pc.dim(
126
+ ` ${exposedCount} exposed • ${emptyCount} empty/protected • ${deniedCount} denied`,
127
+ ),
128
+ );
129
+ console.log();
130
+
131
+ if (analysis.tables.length > 0) {
132
+ analysis.tables.forEach((table) => {
133
+ const access = analysis.tableAccess[table];
134
+ let indicator = "";
135
+ let description = "";
136
+
137
+ switch (access?.status) {
138
+ case "readable":
139
+ indicator = pc.green("✓");
140
+ description = pc.dim("(data exposed)");
141
+ break;
142
+ case "empty":
143
+ indicator = pc.yellow("○");
144
+ description = pc.dim("(0 rows - empty or RLS)");
145
+ break;
146
+ case "denied":
147
+ indicator = pc.red("✗");
148
+ description = pc.dim("(access denied)");
149
+ break;
150
+ }
151
+
152
+ console.log(` ${indicator} ${pc.white(table)} ${description}`);
153
+ });
154
+ } else {
155
+ console.log(pc.dim(" No tables found"));
156
+ }
157
+ console.log();
158
+
159
+ console.log(pc.bold("RPCs:"), pc.green(analysis.rpcs.length.toString()));
160
+ if (analysis.rpcFunctions.length > 0) {
161
+ analysis.rpcFunctions.forEach((rpc) => {
162
+ console.log(` • ${pc.white(rpc.name)}`);
163
+ if (rpc.parameters.length > 0) {
164
+ rpc.parameters.forEach((param) => {
165
+ const required = param.required
166
+ ? pc.red("(required)")
167
+ : pc.dim("(optional)");
168
+ const type = param.format
169
+ ? `${param.type} (${param.format})`
170
+ : param.type;
171
+ console.log(
172
+ ` - ${pc.cyan(param.name)}: ${pc.yellow(type)} ${required}`,
173
+ );
174
+ });
175
+ } else {
176
+ console.log(pc.dim(" No parameters"));
177
+ }
178
+ });
179
+ } else {
180
+ console.log(pc.dim(" No RPCs found"));
181
+ }
182
+ console.log();
183
+ });
184
+
185
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
186
+ console.log();
187
+ }
@@ -0,0 +1,98 @@
1
+ import pc from "picocolors";
2
+ import type { CLIContext } from "../context";
3
+ import { SupabaseService } from "../services/supabase.service";
4
+ import { log } from "../utils";
5
+
6
+ export async function executeDumpCommand(
7
+ ctx: CLIContext,
8
+ options: {
9
+ dump: string;
10
+ limit: string;
11
+ },
12
+ ): Promise<void> {
13
+ const parts = options.dump.split(".");
14
+
15
+ if (parts.length === 1 && parts[0]) {
16
+ const schema = parts[0];
17
+
18
+ const swaggerResult = await SupabaseService.getSwagger(ctx, schema);
19
+
20
+ if (!swaggerResult.success) {
21
+ log.error("Failed to get swagger", swaggerResult.error.message);
22
+ process.exit(1);
23
+ }
24
+
25
+ if (ctx.json) {
26
+ console.log(JSON.stringify(swaggerResult.value, null, 2));
27
+ } else {
28
+ displaySwaggerResult(schema, swaggerResult.value);
29
+ }
30
+ return;
31
+ }
32
+
33
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
34
+ log.error("Invalid format. Use: schema.table or schema");
35
+ process.exit(1);
36
+ }
37
+
38
+ const schema = parts[0];
39
+ const table = parts[1];
40
+ const limit = parseInt(options.limit);
41
+
42
+ const dumpResult = await SupabaseService.dumpTable(ctx, schema, table, limit);
43
+
44
+ if (!dumpResult.success) {
45
+ log.error("Failed to dump table", dumpResult.error.message);
46
+ process.exit(1);
47
+ }
48
+
49
+ if (ctx.json) {
50
+ console.log(JSON.stringify(dumpResult.value, null, 2));
51
+ } else {
52
+ displayTableDumpResult(schema, table, dumpResult.value);
53
+ }
54
+ }
55
+
56
+ function displaySwaggerResult(schema: string, swagger: any): void {
57
+ console.log();
58
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
59
+ console.log(pc.bold(pc.cyan(` SWAGGER DUMP: ${schema}`)));
60
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
61
+ console.log();
62
+ console.log(JSON.stringify(swagger, null, 2));
63
+ console.log();
64
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
65
+ console.log();
66
+ }
67
+
68
+ function displayTableDumpResult(
69
+ schema: string,
70
+ table: string,
71
+ result: {
72
+ columns: string[];
73
+ rows: Record<string, unknown>[];
74
+ count: number;
75
+ },
76
+ ): void {
77
+ console.log();
78
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
79
+ console.log(pc.bold(pc.cyan(` TABLE DUMP: ${schema}.${table}`)));
80
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
81
+ console.log();
82
+ console.log(pc.bold("Total rows:"), pc.green(result.count.toString()));
83
+ console.log(pc.bold("Showing:"), pc.green(result.rows.length.toString()));
84
+ console.log(pc.bold("Columns:"), pc.green(result.columns.length.toString()));
85
+ console.log();
86
+ console.log(pc.dim(result.columns.join(", ")));
87
+ console.log();
88
+
89
+ if (result.rows.length > 0) {
90
+ console.table(result.rows);
91
+ } else {
92
+ console.log(pc.dim("No rows found"));
93
+ }
94
+
95
+ console.log();
96
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
97
+ console.log();
98
+ }
@@ -0,0 +1,205 @@
1
+ import pc from "picocolors";
2
+ import type { CLIContext } from "../context";
3
+ import {
4
+ SupabaseService,
5
+ type RPCFunction,
6
+ type RPCParameter,
7
+ } from "../services/supabase.service";
8
+ import { log, parseRPCArgs } from "../utils";
9
+
10
+ export async function executeRPCCommand(
11
+ ctx: CLIContext,
12
+ options: {
13
+ rpc: string;
14
+ args?: string;
15
+ limit: string;
16
+ explain?: boolean;
17
+ },
18
+ ): Promise<void> {
19
+ const parts = options.rpc.split(".");
20
+
21
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
22
+ log.error("Invalid RPC format. Use: schema.rpc_name");
23
+ process.exit(1);
24
+ }
25
+
26
+ const schema = parts[0];
27
+ const rpcName = parts[1];
28
+
29
+ const rpcFunctionsResult = await SupabaseService.getRPCsWithParameters(
30
+ ctx,
31
+ schema,
32
+ );
33
+
34
+ let rpcFunction: RPCFunction | null = null;
35
+
36
+ if (rpcFunctionsResult.success) {
37
+ rpcFunction =
38
+ rpcFunctionsResult.value.find((rpc) => rpc.name === `rpc/${rpcName}`) ||
39
+ null;
40
+ } else {
41
+ log.warn(
42
+ "Failed to get RPC functions from schema, proceeding without validation",
43
+ rpcFunctionsResult.error.message,
44
+ );
45
+ }
46
+
47
+ if (!options.args) {
48
+ displayRPCHelp(schema, rpcName, rpcFunction);
49
+ return;
50
+ }
51
+
52
+ let args: Record<string, any> = {};
53
+ try {
54
+ args = parseRPCArgs(options.args);
55
+ } catch (error) {
56
+ log.error(
57
+ "Failed to parse RPC arguments",
58
+ error instanceof Error ? error.message : String(error),
59
+ );
60
+ process.exit(1);
61
+ }
62
+
63
+ if (rpcFunction) {
64
+ const requiredParams = rpcFunction.parameters.filter(
65
+ (p: RPCParameter) => p.required,
66
+ );
67
+ const missingParams = requiredParams.filter(
68
+ (p: RPCParameter) => !(p.name in args),
69
+ );
70
+
71
+ if (missingParams.length > 0) {
72
+ log.error(
73
+ `Missing required parameters: ${missingParams.map((p: RPCParameter) => p.name).join(", ")}`,
74
+ );
75
+ process.exit(1);
76
+ }
77
+ } else {
78
+ log.warn(
79
+ "Skipping parameter validation due to schema introspection failure",
80
+ );
81
+ }
82
+
83
+ const rpcResult = await SupabaseService.callRPC(ctx, schema, rpcName, args, {
84
+ get: true,
85
+ explain: options.explain,
86
+ limit: parseInt(options.limit),
87
+ });
88
+
89
+ if (!rpcResult.success) {
90
+ log.error("RPC call failed", rpcResult.error.message);
91
+ process.exit(1);
92
+ }
93
+
94
+ if (ctx.json) {
95
+ console.log(JSON.stringify(rpcResult.value, null, 2));
96
+ } else {
97
+ displayRPCResult(schema, rpcName, rpcResult.value, options.explain);
98
+ }
99
+ }
100
+
101
+ function displayRPCHelp(
102
+ schema: string,
103
+ rpcName: string,
104
+ rpcFunction: RPCFunction | null,
105
+ ): void {
106
+ console.log();
107
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
108
+ console.log(pc.bold(pc.cyan(` RPC HELP: ${schema}.${rpcName}`)));
109
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
110
+ console.log();
111
+
112
+ if (rpcFunction && rpcFunction.parameters.length > 0) {
113
+ console.log(pc.bold("Parameters:"));
114
+ rpcFunction.parameters.forEach((param: RPCParameter) => {
115
+ const required = param.required
116
+ ? pc.red("(required)")
117
+ : pc.dim("(optional)");
118
+ const type = param.format
119
+ ? `${param.type} (${param.format})`
120
+ : param.type;
121
+ console.log(` • ${pc.cyan(param.name)}: ${pc.yellow(type)} ${required}`);
122
+ if (param.description) {
123
+ console.log(pc.dim(` ${param.description}`));
124
+ }
125
+ });
126
+ console.log();
127
+ console.log(pc.bold("Usage:"));
128
+ console.log(
129
+ pc.dim(
130
+ `supascan --rpc "${schema}.${rpcName}" --args '{"param1": "value1", "param2": "value2"}'`,
131
+ ),
132
+ );
133
+ } else if (rpcFunction) {
134
+ console.log(pc.dim("No parameters required"));
135
+ console.log();
136
+ console.log(pc.bold("Usage:"));
137
+ console.log(pc.dim(`supascan --rpc "${schema}.${rpcName}"`));
138
+ } else {
139
+ console.log(
140
+ pc.yellow(
141
+ "⚠️ Schema introspection failed - parameter information unavailable",
142
+ ),
143
+ );
144
+ console.log();
145
+ console.log(pc.bold("Usage:"));
146
+ console.log(
147
+ pc.dim(
148
+ `supascan --rpc "${schema}.${rpcName}" --args '{"param1": "value1"}'`,
149
+ ),
150
+ );
151
+ console.log();
152
+ console.log(
153
+ pc.dim(
154
+ "Note: You can still call the RPC, but parameter validation is disabled",
155
+ ),
156
+ );
157
+ }
158
+ console.log();
159
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
160
+ console.log();
161
+ }
162
+
163
+ function displayRPCResult(
164
+ schema: string,
165
+ rpcName: string,
166
+ result: any,
167
+ explain?: boolean,
168
+ ): void {
169
+ console.log();
170
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
171
+ if (explain) {
172
+ console.log(pc.bold(pc.cyan(` QUERY PLAN: ${schema}.${rpcName}`)));
173
+ } else {
174
+ console.log(pc.bold(pc.cyan(` RPC RESULT: ${schema}.${rpcName}`)));
175
+ }
176
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
177
+ console.log();
178
+
179
+ if (explain) {
180
+ console.log(pc.bold("Execution Plan:"));
181
+ console.log();
182
+ if (typeof result === "string") {
183
+ console.log(pc.yellow(result));
184
+ } else {
185
+ console.log(JSON.stringify(result, null, 2));
186
+ }
187
+ } else if (Array.isArray(result)) {
188
+ console.log(pc.bold("Results:"), pc.green(result.length.toString()));
189
+ console.log();
190
+ if (result.length > 0) {
191
+ console.table(result);
192
+ } else {
193
+ console.log(pc.dim("No results returned"));
194
+ }
195
+ } else if (typeof result === "object" && result !== null) {
196
+ console.log(pc.bold("Result:"));
197
+ console.table([result]);
198
+ } else {
199
+ console.log(pc.bold("Result:"), pc.green(String(result)));
200
+ }
201
+
202
+ console.log();
203
+ console.log(pc.bold(pc.cyan("━".repeat(60))));
204
+ console.log();
205
+ }