diff --git a/.gitignore b/.gitignore index 3820a95..e26601c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +app-arm64-v8a-release.apk diff --git a/README.md b/README.md index f231969..5bfda89 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RC Spy - Firebase Remote Config Scanner +# RC Spy - Firebase & Supabase Security Scanner

@@ -11,7 +11,7 @@ stars

-RC Spy is a security tool that scans installed Android apps to detect if their Firebase Remote Config is publicly accessible — a common misconfiguration that can expose sensitive configuration data. It extracts Firebase credentials from APKs and checks for vulnerable endpoints. Built using the [Flutter](https://flutter.dev/) framework. +RC Spy is a security tool that scans installed Android apps to detect backend misconfigurations. It identifies exposed Firebase Remote Config endpoints and Supabase instances with insecure storage buckets or tables. The tool extracts credentials from APKs (including Flutter apps) and tests for vulnerable endpoints. Built using the [Flutter](https://flutter.dev/) framework.

@@ -20,12 +20,27 @@ RC Spy is a security tool that scans installed Android apps to detect if their F ## Features +### Firebase Detection - **APK Analysis** — Extracts Firebase credentials (App IDs & API Keys) from installed apps - **Vulnerability Detection** — Checks if Remote Config endpoints are publicly accessible - **Multiple Views** — View exposed configs in List, Table, or raw JSON format -- **Smart Filtering** — Filter by All, Vulnerable, Firebase, Secure, or No Firebase + +### Supabase Detection +- **Credential Extraction** — Finds Supabase project URLs and API keys +- **Smart JWT Validation** — Validates JWT tokens to ensure they're actually Supabase keys (not Auth0, Firebase Auth, etc.) +- **Key Format Support** — Detects both old JWT format (`eyJ...`) and new format (`sb_publishable_...`) +- **Security Analysis** — Tests for exposed storage buckets and database tables +- **Schema Discovery** — Automatically discovers tables via PostgREST OpenAPI schema +- **Multiple Views** — View exposed data in List, Table, or raw JSON format (unified with Firebase UI) + +### General +- **Flutter App Support** — Scans native libraries (`.so` files) where Flutter stores compiled strings +- **Smart Filtering** — Filter by All, Vulnerable, Firebase, Supabase, Secure, or No Backend +- **Search** — Quick search to find apps by name +- **Manual Scan Mode** — Start scanning when you're ready with the "Start Scan" button - **Local Caching** — Results persist across app launches - **Fast Scanning** — Parallel analysis using isolates for smooth performance +- **Share Results** — Export and share analysis findings ## How it looks @@ -58,6 +73,7 @@ RC Spy is a security tool that scans installed Android apps to detect if their F - Security researchers auditing app configurations - Penetration testers identifying misconfigurations - Developers checking their own apps for vulnerabilities +- Bug bounty hunters looking for exposed backends ## Built With diff --git a/lib/src/pages/home_page.dart b/lib/src/pages/home_page.dart index 87d21f7..1ec2e0c 100644 --- a/lib/src/pages/home_page.dart +++ b/lib/src/pages/home_page.dart @@ -5,57 +5,118 @@ import 'package:rcspy/src/pages/settings_page.dart'; import 'package:rcspy/src/providers/analysis_provider.dart'; import 'package:rcspy/src/widgets/app_tile.dart'; -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _searchController = TextEditingController(); + bool _isSearching = false; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text( - "RC Spy", - style: TextStyle( - fontSize: 18, - color: Colors.black, - fontWeight: FontWeight.bold, + appBar: _buildAppBar(context), + body: _buildBody(context), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + if (_isSearching) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + _isSearching = false; + _searchController.clear(); + }); + context.read().setSearchQuery(''); + }, + ), + title: TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search apps...', + border: InputBorder.none, ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, ), actions: [ - Selector( - selector: (_, provider) => provider.isAnalyzing, - builder: (context, isAnalyzing, _) { - return IconButton( - icon: isAnalyzing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - year2023: false, - ), - ) - : const Icon(Icons.refresh), - tooltip: 'Re-analyze all apps', - onPressed: isAnalyzing - ? null - : () => _showReanalyzeDialog(context), - ); - }, - ), - IconButton( - icon: const Icon(Icons.settings_outlined), - tooltip: 'Settings', - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsPage()), - ); - }, - ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + ), ], + ); + } + + return AppBar( + title: const Text( + "RC Spy", + style: TextStyle( + fontSize: 18, + color: Colors.black, + fontWeight: FontWeight.bold, + ), ), - body: _buildBody(context), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search apps', + onPressed: () { + setState(() => _isSearching = true); + }, + ), + Selector( + selector: (_, provider) => provider.isAnalyzing, + builder: (context, isAnalyzing, _) { + return IconButton( + icon: isAnalyzing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + year2023: false, + ), + ) + : const Icon(Icons.refresh), + tooltip: 'Re-analyze all apps', + onPressed: isAnalyzing + ? null + : () => _showReanalyzeDialog(context), + ); + }, + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ); + }, + ), + ], ); } @@ -114,57 +175,151 @@ class _AppListWithHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - const _ProgressHeader(), - const SizedBox(height: 8), - const _FilterBar(), - const SizedBox(height: 8), - Expanded( - child: Selector packages, AppFilter filter})>( - selector: (_, provider) => ( - packages: provider.filteredPackages, - filter: provider.currentFilter, - ), - builder: (context, data, _) { - if (data.packages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 48, - color: Colors.grey[400], - ), - const SizedBox(height: 12), - Text( - 'No apps match this filter', - style: TextStyle( - color: Colors.grey[600], - fontSize: 16, - ), + return Selector( + selector: (_, provider) => ( + hasStartedScan: provider.hasStartedScan, + newApps: provider.newAppsCount, + ), + builder: (context, data, _) { + return Column( + children: [ + // Show scan button if not started yet + if (!data.hasStartedScan && data.newApps > 0) + _ScanPrompt(newAppsCount: data.newApps), + + if (data.hasStartedScan) ...[ + const _ProgressHeader(), + const SizedBox(height: 8), + ], + + const _FilterBar(), + const SizedBox(height: 8), + + Expanded( + child: Selector packages, AppFilter filter, String search})>( + selector: (_, provider) => ( + packages: provider.filteredPackages, + filter: provider.currentFilter, + search: provider.searchQuery, + ), + builder: (context, result, _) { + if (result.packages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + result.search.isNotEmpty + ? Icons.search_off + : Icons.apps_outlined, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 12), + Text( + result.search.isNotEmpty + ? 'No apps match "${result.search}"' + : 'No apps match this filter', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], ), - ], - ), - ); - } + ); + } - return ListView.builder( - itemCount: data.packages.length, - itemBuilder: (context, index) { - final package = data.packages[index]; - return AppTile( - key: ValueKey(package.id ?? package.name), - package: package, + return ListView.builder( + itemCount: result.packages.length, + itemBuilder: (context, index) { + final package = result.packages[index]; + return AppTile( + key: ValueKey(package.id ?? package.name), + package: package, + ); + }, ); }, - ); - }, - ), + ), + ), + ], + ); + }, + ); + } +} + +class _ScanPrompt extends StatelessWidget { + const _ScanPrompt({required this.newAppsCount}); + + final int newAppsCount; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + margin: const EdgeInsets.all(12), + color: Colors.blue[50], + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon( + Icons.security_outlined, + size: 48, + color: Colors.blue[700], + ), + const SizedBox(height: 12), + Text( + '$newAppsCount apps ready to scan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue[900], + ), + ), + const SizedBox(height: 8), + Text( + 'Scan your apps to detect Firebase and Supabase backends and check for security misconfigurations.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.blue[700], + ), + ), + const SizedBox(height: 16), + Selector( + selector: (_, provider) => provider.isAnalyzing, + builder: (context, isAnalyzing, _) { + return FilledButton.icon( + onPressed: isAnalyzing + ? null + : () => context.read().startScan(), + icon: isAnalyzing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + year2023: false, + ), + ) + : const Icon(Icons.play_arrow), + label: Text(isAnalyzing ? 'Scanning...' : 'Start Scan'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + ); + }, + ), + ], ), - ], + ), ); } } @@ -175,10 +330,11 @@ class _FilterBar extends StatelessWidget { @override Widget build(BuildContext context) { return Selector( + ({AppFilter filter, AnalysisProgress progress, bool hasStarted})>( selector: (_, provider) => ( filter: provider.currentFilter, progress: provider.progress, + hasStarted: provider.hasStartedScan, ), builder: (context, data, _) { return SingleChildScrollView( @@ -217,9 +373,22 @@ class _FilterBar extends StatelessWidget { .setFilter(AppFilter.firebase), ), const SizedBox(width: 8), + _FilterChip( + label: 'Supabase', + count: data.progress.withSupabase, + icon: Icons.storage, + color: Colors.teal, + isSelected: data.filter == AppFilter.supabase, + onTap: () => context + .read() + .setFilter(AppFilter.supabase), + ), + const SizedBox(width: 8), _FilterChip( label: 'Secure', - count: data.progress.withFirebase - data.progress.vulnerable, + count: data.progress.withFirebase + + data.progress.withSupabase - + data.progress.vulnerable, icon: Icons.lock, color: Colors.green, isSelected: data.filter == AppFilter.secure, @@ -228,14 +397,16 @@ class _FilterBar extends StatelessWidget { ), const SizedBox(width: 8), _FilterChip( - label: 'No Firebase', - count: data.progress.total - data.progress.withFirebase, + label: 'No Backend', + count: data.progress.total - + data.progress.withFirebase - + data.progress.withSupabase, icon: Icons.check_circle_outline, color: Colors.grey, - isSelected: data.filter == AppFilter.noFirebase, + isSelected: data.filter == AppFilter.noBackend, onTap: () => context .read() - .setFilter(AppFilter.noFirebase), + .setFilter(AppFilter.noBackend), ), ], ), @@ -337,7 +508,7 @@ class _ProgressHeader extends StatelessWidget { newApps: provider.newAppsCount, ), builder: (context, data, _) { - if (!data.isAnalyzing) { + if (!data.isAnalyzing && data.progress.isComplete) { return const SizedBox.shrink(); } diff --git a/lib/src/pages/remote_config_page.dart b/lib/src/pages/remote_config_page.dart index d52677f..c020326 100644 --- a/lib/src/pages/remote_config_page.dart +++ b/lib/src/pages/remote_config_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; enum ConfigViewMode { list, table, json } @@ -47,6 +48,11 @@ class _RemoteConfigPageState extends State { ], ), actions: [ + IconButton( + icon: const Icon(Icons.share), + tooltip: 'Share for analysis', + onPressed: () => _shareForAnalysis(context), + ), IconButton( icon: const Icon(Icons.copy_all), tooltip: 'Copy all as JSON', @@ -97,6 +103,36 @@ class _RemoteConfigPageState extends State { ), ); } + + void _shareForAnalysis(BuildContext context) { + final jsonString = const JsonEncoder.withIndent( + ' ', + ).convert(widget.configValues); + + final shareText = ''' +Firebase Remote Config Analysis Report +====================================== +App: ${widget.appName} +Config Values Found: ${_sortedKeys.length} + +This app has a publicly accessible Firebase Remote Config endpoint. + +**Exposed Configuration:** +$jsonString + +**Analysis Request:** +Please analyze these exposed configuration values and identify: +1. Any sensitive information that should not be public +2. API keys or secrets that could be exploited +3. Security risks from this exposed configuration +4. Recommendations for the app developer + +--- +Generated by RC Spy - Security Research Tool +'''; + + Share.share(shareText, subject: 'RC Spy: ${widget.appName} Analysis'); + } } diff --git a/lib/src/pages/settings_page.dart b/lib/src/pages/settings_page.dart index de314f0..e41eef0 100644 --- a/lib/src/pages/settings_page.dart +++ b/lib/src/pages/settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rcspy/src/services/settings_service.dart'; import 'package:rcspy/src/services/storage_service.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -13,17 +14,20 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { static const String _githubUrl = 'https://github.com/tusharonly/rcspy'; String _version = ''; + bool _hideEmptyRc = true; @override void initState() { super.initState(); - _loadVersion(); + _loadSettings(); } - Future _loadVersion() async { + Future _loadSettings() async { + await SettingsService.init(); final packageInfo = await PackageInfo.fromPlatform(); setState(() { _version = 'v${packageInfo.version} (${packageInfo.buildNumber})'; + _hideEmptyRc = SettingsService.hideEmptyRemoteConfig; }); } @@ -38,6 +42,45 @@ class _SettingsPageState extends State { children: [ const SizedBox(height: 8), + _SectionHeader(title: 'Analysis Settings'), + SwitchListTile( + secondary: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.filter_alt, color: Colors.orange, size: 20), + ), + title: const Text( + 'Hide Empty Remote Config', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15), + ), + subtitle: Text( + 'Don\'t count apps with accessible but empty Remote Config as vulnerable', + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + value: _hideEmptyRc, + onChanged: (value) async { + await SettingsService.setHideEmptyRemoteConfig(value); + setState(() => _hideEmptyRc = value); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + value + ? 'Empty Remote Config will be hidden from Vulnerable filter' + : 'All accessible Remote Config will be shown as Vulnerable', + ), + duration: const Duration(seconds: 2), + ), + ); + } + }, + ), + + const Divider(height: 32), + _SectionHeader(title: 'Links'), _SettingsTile( icon: Icons.code, @@ -71,6 +114,37 @@ class _SettingsPageState extends State { onTap: () => _showClearCacheDialog(context), ), + const Divider(height: 32), + + _SectionHeader(title: 'How It Works'), + _InfoTile( + icon: Icons.help_outline, + title: 'What does this app do?', + content: ''' +RC Spy scans installed Android apps to find security misconfigurations in Firebase and Supabase backends. + +**Firebase Remote Config** +- Extracts Google App IDs and API Keys from APK files +- Tests if Remote Config endpoints are publicly accessible +- Shows exposed configuration values that could leak secrets + +**Supabase** +- Finds Supabase project URLs and anon keys in APKs +- Checks for publicly accessible storage buckets +- Tests common database tables for exposed data + +**Why does this matter?** +Misconfigured backends can expose: +- API keys and secrets +- Feature flags +- Server URLs +- User data (in Supabase tables) +- Files (in public storage buckets) + +Security researchers can use this to identify vulnerable apps and responsibly disclose issues to developers. +''', + ), + const SizedBox(height: 32), Center( @@ -208,3 +282,58 @@ class _SettingsTile extends StatelessWidget { ); } } + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.icon, + required this.title, + required this.content, + }); + + final IconData icon; + final String title; + final String content; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.purple.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: Colors.purple, size: 20), + ), + title: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 15, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Text( + content.trim(), + style: TextStyle( + fontSize: 13, + color: Colors.grey[700], + height: 1.5, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/pages/supabase_results_page.dart b/lib/src/pages/supabase_results_page.dart new file mode 100644 index 0000000..47066da --- /dev/null +++ b/lib/src/pages/supabase_results_page.dart @@ -0,0 +1,1573 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rcspy/src/services/supabase_service.dart'; +import 'package:share_plus/share_plus.dart'; + +enum SupabaseViewMode { overview, list, table, json } + +class SupabaseResultsPage extends StatefulWidget { + const SupabaseResultsPage({ + super.key, + required this.appName, + required this.result, + }); + + final String appName; + final SupabaseSecurityResult result; + + @override + State createState() => _SupabaseResultsPageState(); +} + +class _SupabaseResultsPageState extends State { + SupabaseViewMode _viewMode = SupabaseViewMode.overview; + + int get _totalIssues => + widget.result.publicBuckets.length + + widget.result.exposedTables.length + + widget.result.exposedStorageObjects.length; + + List<_FindingItem> get _findings { + final items = <_FindingItem>[]; + + // Add project URL as first item + if (widget.result.workingProjectUrl != null) { + items.add(_FindingItem( + type: _FindingType.info, + title: 'Project URL', + value: widget.result.workingProjectUrl!, + icon: Icons.link, + color: Colors.teal, + )); + } + + // Add API key + if (widget.result.workingAnonKey != null) { + items.add(_FindingItem( + type: _FindingType.info, + title: 'Anon Key', + value: widget.result.workingAnonKey!, + icon: Icons.key, + color: Colors.indigo, + )); + } + + // Add exposed tables + for (final table in widget.result.exposedTables) { + items.add(_FindingItem( + type: _FindingType.table, + title: table.tableName, + value: table.sampleData.isNotEmpty + ? const JsonEncoder.withIndent(' ').convert(table.sampleData) + : 'Columns: ${table.columns.join(', ')}', + icon: Icons.table_chart, + color: Colors.purple, + metadata: { + 'columns': table.columns, + 'rowCount': table.rowCount, + 'sampleData': table.sampleData, + }, + )); + } + + // Add public buckets + for (final bucket in widget.result.publicBuckets) { + items.add(_FindingItem( + type: _FindingType.bucket, + title: bucket.name, + value: bucket.isPublic ? 'Public Access Enabled' : 'Private', + icon: Icons.folder_open, + color: Colors.orange, + metadata: { + 'id': bucket.id, + 'isPublic': bucket.isPublic, + 'exposedFiles': bucket.exposedFiles, + }, + )); + } + + // Add exposed storage objects + for (final object in widget.result.exposedStorageObjects) { + items.add(_FindingItem( + type: _FindingType.file, + title: object.split('/').last, + value: object, + icon: Icons.insert_drive_file, + color: Colors.blue, + )); + } + + return items; + } + + Map get _jsonData => { + 'projectUrl': widget.result.workingProjectUrl, + 'anonKey': widget.result.workingAnonKey, + 'isVulnerable': widget.result.isVulnerable, + 'summary': { + 'publicBuckets': widget.result.publicBuckets.length, + 'exposedTables': widget.result.exposedTables.length, + 'exposedStorageObjects': widget.result.exposedStorageObjects.length, + }, + 'publicBuckets': + widget.result.publicBuckets.map((b) => b.toMap()).toList(), + 'exposedTables': + widget.result.exposedTables.map((t) => t.toMap()).toList(), + 'exposedStorageObjects': widget.result.exposedStorageObjects, + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.appName, + style: const TextStyle(fontSize: 16), + ), + Text( + '$_totalIssues security issues found', + style: TextStyle( + fontSize: 12, + color: _totalIssues > 0 ? Colors.red[400] : Colors.green[400], + fontWeight: FontWeight.normal, + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.share), + tooltip: 'Share for analysis', + onPressed: () => _shareForAnalysis(context), + ), + IconButton( + icon: const Icon(Icons.copy_all), + tooltip: 'Copy all as JSON', + onPressed: () => _copyAllAsJson(context), + ), + ], + ), + body: _totalIssues == 0 + ? const _EmptyState() + : Column( + children: [ + _ViewModeSwitcher( + currentMode: _viewMode, + onModeChanged: (mode) => setState(() => _viewMode = mode), + result: widget.result, + ), + Expanded(child: _buildContent()), + ], + ), + ); + } + + Widget _buildContent() { + switch (_viewMode) { + case SupabaseViewMode.overview: + return _OverviewView(result: widget.result); + case SupabaseViewMode.list: + return _ListView(findings: _findings); + case SupabaseViewMode.table: + return _DataTableView(findings: _findings); + case SupabaseViewMode.json: + return _JsonView(jsonData: _jsonData); + } + } + + void _copyAllAsJson(BuildContext context) { + final jsonString = const JsonEncoder.withIndent(' ').convert(_jsonData); + Clipboard.setData(ClipboardData(text: jsonString)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied all results to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + + void _shareForAnalysis(BuildContext context) { + final bucketNames = + widget.result.publicBuckets.map((b) => b.name).join(', '); + final tableNames = + widget.result.exposedTables.map((t) => t.tableName).join(', '); + + final jsonString = const JsonEncoder.withIndent(' ').convert(_jsonData); + + final shareText = ''' +Supabase Security Analysis Report +================================== +App: ${widget.appName} +Project URL: ${widget.result.workingProjectUrl ?? 'Unknown'} +Anon Key: ${widget.result.workingAnonKey ?? 'Unknown'} + +**Security Issues Found:** +- Public Storage Buckets: ${widget.result.publicBuckets.length}${bucketNames.isNotEmpty ? ' ($bucketNames)' : ''} +- Exposed Database Tables: ${widget.result.exposedTables.length}${tableNames.isNotEmpty ? ' ($tableNames)' : ''} +- Exposed Storage Files: ${widget.result.exposedStorageObjects.length} + +**Full Details:** +$jsonString + +**Analysis Request:** +Please analyze these Supabase security findings and identify: +1. What sensitive data might be exposed through these misconfigurations +2. Potential attack vectors using the exposed endpoints +3. Security risks from public storage buckets and database tables +4. Row Level Security (RLS) recommendations +5. Steps the developer should take to secure this backend + +--- +Generated by RC Spy - Security Research Tool +'''; + + Share.share(shareText, subject: 'RC Spy: ${widget.appName} Supabase Analysis'); + } +} + +enum _FindingType { info, table, bucket, file } + +class _FindingItem { + final _FindingType type; + final String title; + final String value; + final IconData icon; + final Color color; + final Map? metadata; + + _FindingItem({ + required this.type, + required this.title, + required this.value, + required this.icon, + required this.color, + this.metadata, + }); + + String get typeLabel { + switch (type) { + case _FindingType.info: + return 'INFO'; + case _FindingType.table: + return 'TABLE'; + case _FindingType.bucket: + return 'BUCKET'; + case _FindingType.file: + return 'FILE'; + } + } +} + +class _ViewModeSwitcher extends StatelessWidget { + const _ViewModeSwitcher({ + required this.currentMode, + required this.onModeChanged, + required this.result, + }); + + final SupabaseViewMode currentMode; + final ValueChanged onModeChanged; + final SupabaseSecurityResult result; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: SupabaseViewMode.overview, + icon: Icon(Icons.dashboard, size: 18), + label: Text('Overview'), + ), + ButtonSegment( + value: SupabaseViewMode.list, + icon: Icon(Icons.view_list, size: 18), + label: Text('List'), + ), + ButtonSegment( + value: SupabaseViewMode.table, + icon: Icon(Icons.table_chart, size: 18), + label: Text('Table'), + ), + ButtonSegment( + value: SupabaseViewMode.json, + icon: Icon(Icons.data_object, size: 18), + label: Text('JSON'), + ), + ], + selected: {currentMode}, + onSelectionChanged: (selected) => onModeChanged(selected.first), + showSelectedIcon: false, + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, size: 64, color: Colors.green), + SizedBox(height: 16), + Text( + 'No vulnerabilities detected\nin Supabase configuration', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ], + ), + ); + } +} + +// ============================================ +// OVERVIEW VIEW - Category-based display +// ============================================ + +class _OverviewView extends StatelessWidget { + const _OverviewView({required this.result}); + + final SupabaseSecurityResult result; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Credentials Section + _SectionHeader( + icon: Icons.vpn_key, + title: 'Credentials', + color: Colors.teal, + ), + const SizedBox(height: 12), + if (result.workingProjectUrl != null) + _CredentialCard( + label: 'Project URL', + value: result.workingProjectUrl!, + icon: Icons.link, + color: Colors.teal, + ), + if (result.workingAnonKey != null) ...[ + const SizedBox(height: 8), + _CredentialCard( + label: 'Anon Key', + value: result.workingAnonKey!, + icon: Icons.key, + color: Colors.indigo, + ), + ], + const SizedBox(height: 24), + + // Summary Stats + _SectionHeader( + icon: Icons.warning_amber, + title: 'Security Issues Summary', + color: Colors.red, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _StatCard( + icon: Icons.table_chart, + label: 'Exposed Tables', + count: result.exposedTables.length, + color: Colors.purple, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatCard( + icon: Icons.folder_open, + label: 'Public Buckets', + count: result.publicBuckets.length, + color: Colors.orange, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _StatCard( + icon: Icons.insert_drive_file, + label: 'Exposed Files', + count: result.exposedStorageObjects.length, + color: Colors.blue, + ), + ), + const Expanded(child: SizedBox()), + ], + ), + const SizedBox(height: 24), + + // Exposed Tables + if (result.exposedTables.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.table_chart, + title: 'Exposed Database Tables', + color: Colors.purple, + ), + const SizedBox(height: 12), + ...result.exposedTables.map((table) => _TableCard(table: table)), + const SizedBox(height: 24), + ], + + // Public Buckets + if (result.publicBuckets.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.folder_open, + title: 'Public Storage Buckets', + color: Colors.orange, + ), + const SizedBox(height: 12), + ...result.publicBuckets.map((bucket) => _BucketCard(bucket: bucket)), + const SizedBox(height: 24), + ], + + // Exposed Files + if (result.exposedStorageObjects.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.insert_drive_file, + title: 'Exposed Storage Files', + color: Colors.blue, + ), + const SizedBox(height: 12), + ...result.exposedStorageObjects.map((obj) => _FileCard(path: obj)), + ], + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.icon, + required this.title, + required this.color, + }); + + final IconData icon; + final String title; + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 20, color: color), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + ], + ); + } +} + +class _CredentialCard extends StatelessWidget { + const _CredentialCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied to clipboard'), + duration: const Duration(seconds: 1), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 20, color: color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: color, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.copy, size: 20, color: color.withOpacity(0.5)), + ], + ), + ), + ), + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.icon, + required this.label, + required this.count, + required this.color, + }); + + final IconData icon; + final String label; + final int count; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: count > 0 ? color.withOpacity(0.1) : Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: count > 0 ? color.withOpacity(0.3) : Colors.grey[300]!, + ), + ), + child: Column( + children: [ + Icon(icon, size: 32, color: count > 0 ? color : Colors.grey), + const SizedBox(height: 8), + Text( + '$count', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: count > 0 ? color : Colors.grey, + ), + ), + Text( + label, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _TableCard extends StatefulWidget { + const _TableCard({required this.table}); + + final ExposedTableInfo table; + + @override + State<_TableCard> createState() => _TableCardState(); +} + +class _TableCardState extends State<_TableCard> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.purple.withOpacity(0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.purple.withOpacity(0.05), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.purple.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.table_chart, size: 16, color: Colors.purple), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.table.tableName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.grey.shade800, + ), + ), + Text( + '${widget.table.columns.length} columns • ${widget.table.rowCount ?? 0} rows', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'EXPOSED', + style: TextStyle( + color: Colors.red, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + // Columns + Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Columns:', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: widget.table.columns.map((col) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(4), + ), + child: Text( + col, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + )).toList(), + ), + ], + ), + ), + + // Sample Data (Expandable) + if (widget.table.sampleData.isNotEmpty) ...[ + const Divider(height: 1), + InkWell( + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + color: Colors.purple, + ), + const SizedBox(width: 8), + Text( + _isExpanded ? 'Hide Sample Data' : 'Show Sample Data', + style: const TextStyle( + color: Colors.purple, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const Spacer(), + if (!_isExpanded) + IconButton( + icon: const Icon(Icons.copy, size: 18), + color: Colors.purple, + onPressed: () { + final json = const JsonEncoder.withIndent(' ') + .convert(widget.table.sampleData); + Clipboard.setData(ClipboardData(text: json)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sample data copied'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + ), + ), + if (_isExpanded) + Container( + margin: const EdgeInsets.fromLTRB(14, 0, 14, 14), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + SelectableText( + const JsonEncoder.withIndent(' ') + .convert(widget.table.sampleData), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFFD4D4D4), + ), + ), + Positioned( + top: 0, + right: 0, + child: IconButton( + icon: const Icon(Icons.copy, size: 16, color: Colors.white54), + onPressed: () { + final json = const JsonEncoder.withIndent(' ') + .convert(widget.table.sampleData); + Clipboard.setData(ClipboardData(text: json)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sample data copied'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +class _BucketCard extends StatelessWidget { + const _BucketCard({required this.bucket}); + + final StorageBucketInfo bucket; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: bucket.name)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Bucket "${bucket.name}" copied'), + duration: const Duration(seconds: 1), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.folder_open, size: 24, color: Colors.orange), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bucket.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + bucket.isPublic ? 'Public Access Enabled' : 'Private', + style: TextStyle( + fontSize: 12, + color: bucket.isPublic ? Colors.red : Colors.green, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'PUBLIC', + style: TextStyle( + color: Colors.red, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _FileCard extends StatelessWidget { + const _FileCard({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.2)), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: path)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('File path copied'), + duration: Duration(seconds: 1), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.blue[700]), + const SizedBox(width: 12), + Expanded( + child: Text( + path, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + Icon(Icons.copy, size: 18, color: Colors.blue[300]), + ], + ), + ), + ), + ), + ); + } +} + +// ============================================ +// LIST VIEW - Unified card display +// ============================================ + +class _ListView extends StatelessWidget { + const _ListView({required this.findings}); + + final List<_FindingItem> findings; + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + itemCount: findings.length, + itemBuilder: (context, index) { + final finding = findings[index]; + return _FindingCard(finding: finding); + }, + ); + } +} + +class _FindingCard extends StatefulWidget { + const _FindingCard({required this.finding}); + + final _FindingItem finding; + + @override + State<_FindingCard> createState() => _FindingCardState(); +} + +class _FindingCardState extends State<_FindingCard> { + bool _isExpanded = false; + + static const int _maxShortValueLength = 100; + static const int _maxShortValueLines = 3; + + bool get _isLargeValue { + final valueStr = widget.finding.value; + final lineCount = '\n'.allMatches(valueStr).length + 1; + return valueStr.length > _maxShortValueLength || + lineCount > _maxShortValueLines; + } + + String get _previewValue { + final valueStr = widget.finding.value; + final lines = valueStr.split('\n'); + + if (lines.length > _maxShortValueLines) { + return '${lines.take(_maxShortValueLines).join('\n')}...'; + } + + if (valueStr.length > _maxShortValueLength) { + return '${valueStr.substring(0, _maxShortValueLength)}...'; + } + + return valueStr; + } + + @override + Widget build(BuildContext context) { + final finding = widget.finding; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => _copyValue(context), + onLongPress: () => _showFullValue(context), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.05), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + border: Border( + bottom: BorderSide(color: finding.color.withOpacity(0.1)), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(finding.icon, size: 14, color: finding.color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + finding.title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 2), + Text( + finding.typeLabel, + style: TextStyle( + fontSize: 11, + color: finding.color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (finding.type != _FindingType.info) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'EXPOSED', + style: TextStyle( + color: Colors.red, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + if (_isLargeValue) _buildExpandableValue() else _buildSimpleValue(), + ], + ), + ); + } + + Widget _buildSimpleValue() { + return InkWell( + onTap: () => _copyValue(context), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: SelectableText( + widget.finding.value, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Colors.grey.shade800, + height: 1.4, + ), + ), + ), + ), + ); + } + + Widget _buildExpandableValue() { + final finding = widget.finding; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + constraints: BoxConstraints(maxHeight: _isExpanded ? 350 : 100), + child: SingleChildScrollView( + physics: _isExpanded + ? const AlwaysScrollableScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: SelectableText( + _isExpanded ? finding.value : _previewValue, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Colors.grey.shade800, + height: 1.4, + ), + ), + ), + ), + ), + InkWell( + onTap: () => setState(() => _isExpanded = !_isExpanded), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isExpanded ? Icons.unfold_less : Icons.unfold_more, + size: 16, + color: finding.color, + ), + const SizedBox(width: 4), + Text( + _isExpanded ? 'Collapse' : 'Expand', + style: TextStyle( + color: finding.color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + void _copyValue(BuildContext context) { + Clipboard.setData(ClipboardData(text: widget.finding.value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied "${widget.finding.title}" to clipboard'), + duration: const Duration(seconds: 1), + ), + ); + } + + void _showFullValue(BuildContext context) { + final finding = widget.finding; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: finding.color.withOpacity(0.1)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(finding.icon, size: 20, color: finding.color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + finding.title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.grey.shade800, + ), + ), + Text( + finding.typeLabel, + style: TextStyle(fontSize: 12, color: finding.color), + ), + ], + ), + ), + Material( + color: finding.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: finding.value)); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 1), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.copy_rounded, size: 20, color: finding.color), + ), + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: SelectableText( + finding.value, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Colors.grey.shade800, + height: 1.5, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// ============================================ +// TABLE VIEW - Data table display +// ============================================ + +class _DataTableView extends StatelessWidget { + const _DataTableView({required this.findings}); + + final List<_FindingItem> findings; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Card( + elevation: 0, + clipBehavior: Clip.antiAlias, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: FixedColumnWidth(80), + 1: FixedColumnWidth(150), + 2: FixedColumnWidth(400), + }, + border: TableBorder( + horizontalInside: BorderSide(color: Colors.grey.shade200), + ), + children: [ + TableRow( + decoration: BoxDecoration(color: Colors.grey.shade100), + children: const [ + _TableHeader('Type'), + _TableHeader('Name'), + _TableHeader('Details'), + ], + ), + ...findings.map((finding) => _buildTableRow(context, finding)), + ], + ), + ), + ), + ); + } + + TableRow _buildTableRow(BuildContext context, _FindingItem finding) { + final isLong = finding.value.length > 50 || finding.value.contains('\n'); + + return TableRow( + children: [ + _TableCell( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: finding.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + finding.typeLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: finding.color, + ), + ), + ), + ), + _TableCell( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(finding.icon, size: 16, color: finding.color), + const SizedBox(width: 8), + Text( + finding.title, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: finding.color, + ), + ), + ], + ), + onTap: () { + Clipboard.setData(ClipboardData(text: finding.title)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Name copied'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + _TableCell( + child: Text( + isLong + ? '${finding.value.substring(0, 50.clamp(0, finding.value.length))}...' + : finding.value, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Clipboard.setData(ClipboardData(text: finding.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Value copied'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ], + ); + } +} + +class _TableHeader extends StatelessWidget { + const _TableHeader(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ); + } +} + +class _TableCell extends StatelessWidget { + const _TableCell({required this.child, this.onTap}); + final Widget child; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: child, + ), + ); + } +} + +// ============================================ +// JSON VIEW - Raw JSON display +// ============================================ + +class _JsonView extends StatelessWidget { + const _JsonView({required this.jsonData}); + + final Map jsonData; + + String get _jsonString => const JsonEncoder.withIndent(' ').convert(jsonData); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 80), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(12), + ), + child: SelectableText( + _jsonString, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Color(0xFFD4D4D4), + height: 1.5, + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 20, + child: FloatingActionButton.small( + onPressed: () { + Clipboard.setData(ClipboardData(text: _jsonString)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('JSON copied to clipboard'), + duration: Duration(seconds: 1), + ), + ); + }, + child: const Icon(Icons.copy), + ), + ), + ], + ); + } +} diff --git a/lib/src/providers/analysis_provider.dart b/lib/src/providers/analysis_provider.dart index 2e15650..a078aaf 100644 --- a/lib/src/providers/analysis_provider.dart +++ b/lib/src/providers/analysis_provider.dart @@ -2,32 +2,64 @@ import 'package:device_packages/device_packages.dart'; import 'package:flutter/foundation.dart'; import 'package:rcspy/src/services/apk_analyzer.dart'; import 'package:rcspy/src/services/remote_config_service.dart'; +import 'package:rcspy/src/services/settings_service.dart'; import 'package:rcspy/src/services/storage_service.dart'; +import 'package:rcspy/src/services/supabase_service.dart'; class AppAnalysisState { - final FirebaseAnalysisResult? apkResult; + final ApkAnalysisResult? apkResult; final RemoteConfigResult? rcResult; + final SupabaseSecurityResult? supabaseResult; final bool isAnalyzingApk; final bool isCheckingRc; + final bool isCheckingSupabase; const AppAnalysisState({ this.apkResult, this.rcResult, + this.supabaseResult, this.isAnalyzingApk = false, this.isCheckingRc = false, + this.isCheckingSupabase = false, }); + bool get hasFirebase => apkResult?.firebase.hasFirebase == true; + bool get hasSupabase => apkResult?.supabase.hasSupabase == true; + bool get hasAnyBackend => hasFirebase || hasSupabase; + + /// Returns true only if RC is accessible AND has values (respects settings) + bool get isFirebaseVulnerable { + if (rcResult == null || !rcResult!.isAccessible) return false; + return SettingsService.isReallyVulnerable( + isAccessible: rcResult!.isAccessible, + configValueCount: rcResult!.configValues?.length ?? 0, + ); + } + + /// Returns true if RC is accessible but empty (for display purposes) + bool get isFirebaseAccessibleButEmpty { + if (rcResult == null || !rcResult!.isAccessible) return false; + return (rcResult!.configValues?.length ?? 0) == 0; + } + + bool get isSupabaseVulnerable => supabaseResult?.isVulnerable == true; + bool get isVulnerable => isFirebaseVulnerable || isSupabaseVulnerable; + AppAnalysisState copyWith({ - FirebaseAnalysisResult? apkResult, + ApkAnalysisResult? apkResult, RemoteConfigResult? rcResult, + SupabaseSecurityResult? supabaseResult, bool? isAnalyzingApk, bool? isCheckingRc, + bool? isCheckingSupabase, }) { return AppAnalysisState( apkResult: apkResult ?? this.apkResult, rcResult: rcResult ?? this.rcResult, + supabaseResult: supabaseResult ?? this.supabaseResult, isAnalyzingApk: isAnalyzingApk ?? this.isAnalyzingApk, isCheckingRc: isCheckingRc ?? this.isCheckingRc, + isCheckingSupabase: isCheckingSupabase ?? this.isCheckingSupabase, ); } } @@ -36,7 +68,10 @@ class AnalysisProgress { final int total; final int completed; final int withFirebase; + final int withSupabase; final int vulnerable; + final int firebaseVulnerable; + final int supabaseVulnerable; final int cached; final bool isComplete; @@ -44,21 +79,28 @@ class AnalysisProgress { this.total = 0, this.completed = 0, this.withFirebase = 0, + this.withSupabase = 0, this.vulnerable = 0, + this.firebaseVulnerable = 0, + this.supabaseVulnerable = 0, this.cached = 0, this.isComplete = false, }); double get progress => total > 0 ? completed / total : 0; int get remaining => total - completed; + int get withAnyBackend => withFirebase + withSupabase; } enum AppFilter { all, vulnerable, firebase, + supabase, + firebaseVulnerable, + supabaseVulnerable, secure, - noFirebase, + noBackend, } class AnalysisProvider extends ChangeNotifier { @@ -69,22 +111,39 @@ class AnalysisProvider extends ChangeNotifier { AnalysisProgress _progress = const AnalysisProgress(); bool _isLoadingPackages = true; bool _isAnalyzing = false; + bool _hasStartedScan = false; int _newAppsCount = 0; AppFilter _currentFilter = AppFilter.all; + String _searchQuery = ''; List get packages => _packages; AnalysisProgress get progress => _progress; bool get isLoadingPackages => _isLoadingPackages; bool get isAnalyzing => _isAnalyzing; + bool get hasStartedScan => _hasStartedScan; + String get searchQuery => _searchQuery; int get newAppsCount => _newAppsCount; AppFilter get currentFilter => _currentFilter; List get filteredPackages { + var result = _packages; + + // Apply search filter first + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + result = result.where((package) { + final name = (package.name ?? '').toLowerCase(); + final id = (package.id ?? '').toLowerCase(); + return name.contains(query) || id.contains(query); + }).toList(); + } + + // Then apply category filter if (_currentFilter == AppFilter.all) { - return _packages; + return result; } - return _packages.where((package) { + return result.where((package) { final packageId = _getPackageId(package); final state = _analysisStates[packageId]; @@ -94,19 +153,30 @@ class AnalysisProvider extends ChangeNotifier { case AppFilter.all: return true; case AppFilter.vulnerable: - return state.rcResult?.isAccessible == true; + return state.isVulnerable; case AppFilter.firebase: - return state.apkResult?.hasFirebase == true; + return state.hasFirebase; + case AppFilter.supabase: + return state.hasSupabase; + case AppFilter.firebaseVulnerable: + return state.isFirebaseVulnerable; + case AppFilter.supabaseVulnerable: + return state.isSupabaseVulnerable; case AppFilter.secure: - return state.apkResult?.hasFirebase == true && - state.rcResult?.isAccessible == false; - case AppFilter.noFirebase: - return state.apkResult?.hasFirebase == false && - state.apkResult?.error == null; + return state.hasAnyBackend && !state.isVulnerable; + case AppFilter.noBackend: + return !state.hasAnyBackend && state.apkResult?.error == null; } }).toList(); } + void setSearchQuery(String query) { + if (_searchQuery != query) { + _searchQuery = query; + notifyListeners(); + } + } + void setFilter(AppFilter filter) { if (_currentFilter != filter) { _currentFilter = filter; @@ -121,19 +191,40 @@ class AnalysisProvider extends ChangeNotifier { notifyListeners(); await StorageService.init(); + await SettingsService.init(); final cachedData = StorageService.loadCache(); int cachedFirebase = 0; + int cachedSupabase = 0; int cachedVulnerable = 0; + int cachedFirebaseVulnerable = 0; + int cachedSupabaseVulnerable = 0; for (final entry in cachedData.entries) { final data = entry.value; _analysisStates[entry.key] = AppAnalysisState( apkResult: data.toApkResult(), rcResult: data.toRcResult(), + supabaseResult: data.toSupabaseSecurityResult(), ); if (data.hasFirebase) cachedFirebase++; - if (data.rcAccessible == true) cachedVulnerable++; + if (data.hasSupabase) cachedSupabase++; + + // Check if Firebase RC is really vulnerable (respects settings) + final rcConfigCount = data.rcConfigValues?.length ?? 0; + if (data.rcAccessible == true && + SettingsService.isReallyVulnerable( + isAccessible: true, + configValueCount: rcConfigCount, + )) { + cachedVulnerable++; + cachedFirebaseVulnerable++; + } + + if (data.supabaseVulnerable == true) { + cachedVulnerable++; + cachedSupabaseVulnerable++; + } } _packages = await DevicePackages.getInstalledPackages( @@ -161,13 +252,31 @@ class AnalysisProvider extends ChangeNotifier { total: _packages.length, completed: _packages.length - newApps.length, withFirebase: cachedFirebase, + withSupabase: cachedSupabase, vulnerable: cachedVulnerable, + firebaseVulnerable: cachedFirebaseVulnerable, + supabaseVulnerable: cachedSupabaseVulnerable, cached: cachedData.length, isComplete: newApps.isEmpty, ); + notifyListeners(); + // Don't auto-scan - wait for user to press scan button + } + + /// Start scanning apps that haven't been analyzed yet + Future startScan() async { + if (_isAnalyzing) return; + + _hasStartedScan = true; notifyListeners(); + final analyzedIds = StorageService.getAnalyzedPackageIds(); + final newApps = _packages.where((p) { + final id = _getPackageId(p); + return !analyzedIds.contains(id); + }).toList(); + if (newApps.isNotEmpty) { await _analyzePackages(newApps, isFullReanalysis: false); } @@ -206,13 +315,19 @@ class AnalysisProvider extends ChangeNotifier { int completed = isFullReanalysis ? 0 : _progress.completed; int withFirebase = isFullReanalysis ? 0 : _progress.withFirebase; + int withSupabase = isFullReanalysis ? 0 : _progress.withSupabase; int vulnerable = isFullReanalysis ? 0 : _progress.vulnerable; + int firebaseVulnerable = isFullReanalysis ? 0 : _progress.firebaseVulnerable; + int supabaseVulnerable = isFullReanalysis ? 0 : _progress.supabaseVulnerable; _progress = AnalysisProgress( total: _packages.length, completed: completed, withFirebase: withFirebase, + withSupabase: withSupabase, vulnerable: vulnerable, + firebaseVulnerable: firebaseVulnerable, + supabaseVulnerable: supabaseVulnerable, ); notifyListeners(); @@ -235,14 +350,25 @@ class AnalysisProvider extends ChangeNotifier { for (final result in results) { completed++; if (result.hasFirebase) withFirebase++; - if (result.isVulnerable) vulnerable++; + if (result.hasSupabase) withSupabase++; + if (result.isFirebaseVulnerable) { + vulnerable++; + firebaseVulnerable++; + } + if (result.isSupabaseVulnerable) { + vulnerable++; + supabaseVulnerable++; + } } _progress = AnalysisProgress( total: _packages.length, completed: completed, withFirebase: withFirebase, + withSupabase: withSupabase, vulnerable: vulnerable, + firebaseVulnerable: firebaseVulnerable, + supabaseVulnerable: supabaseVulnerable, ); notifyListeners(); } @@ -253,13 +379,22 @@ class AnalysisProvider extends ChangeNotifier { total: _packages.length, completed: completed, withFirebase: withFirebase, + withSupabase: withSupabase, vulnerable: vulnerable, + firebaseVulnerable: firebaseVulnerable, + supabaseVulnerable: supabaseVulnerable, isComplete: true, ); notifyListeners(); } - Future<({bool hasFirebase, bool isVulnerable})> _analyzePackage( + Future< + ({ + bool hasFirebase, + bool hasSupabase, + bool isFirebaseVulnerable, + bool isSupabaseVulnerable, + })> _analyzePackage( PackageInfo package, { bool saveToCache = false, }) async { @@ -268,7 +403,7 @@ class AnalysisProvider extends ChangeNotifier { if (apkPath == null || apkPath.isEmpty) { _analysisStates[packageId] = AppAnalysisState( - apkResult: FirebaseAnalysisResult.error('No APK path'), + apkResult: ApkAnalysisResult.error('No APK path'), isAnalyzingApk: false, ); if (saveToCache) { @@ -281,18 +416,26 @@ class AnalysisProvider extends ChangeNotifier { ), ); } - return (hasFirebase: false, isVulnerable: false); + return ( + hasFirebase: false, + hasSupabase: false, + isFirebaseVulnerable: false, + isSupabaseVulnerable: false, + ); } try { final apkResult = await ApkAnalyzer.analyzeApk(apkPath); - bool isVulnerable = false; + bool isFirebaseVulnerable = false; + bool isSupabaseVulnerable = false; RemoteConfigResult? rcResult; + SupabaseSecurityResult? supabaseResult; - if (apkResult.hasFirebase && - apkResult.googleAppIds.isNotEmpty && - apkResult.googleApiKeys.isNotEmpty) { + // Check Firebase Remote Config + if (apkResult.firebase.hasFirebase && + apkResult.firebase.googleAppIds.isNotEmpty && + apkResult.firebase.googleApiKeys.isNotEmpty) { _analysisStates[packageId] = AppAnalysisState( apkResult: apkResult, isAnalyzingApk: false, @@ -300,43 +443,70 @@ class AnalysisProvider extends ChangeNotifier { ); rcResult = await RemoteConfigService.checkMultipleCombinations( - googleAppIds: apkResult.googleAppIds, - apiKeys: apkResult.googleApiKeys, + googleAppIds: apkResult.firebase.googleAppIds, + apiKeys: apkResult.firebase.googleApiKeys, ); + isFirebaseVulnerable = rcResult.isAccessible; + } + + // Check Supabase security + if (apkResult.supabase.hasSupabase && + apkResult.supabase.projectUrls.isNotEmpty && + apkResult.supabase.anonKeys.isNotEmpty) { _analysisStates[packageId] = AppAnalysisState( apkResult: apkResult, rcResult: rcResult, isAnalyzingApk: false, isCheckingRc: false, + isCheckingSupabase: true, ); - isVulnerable = rcResult.isAccessible; - } else { - _analysisStates[packageId] = AppAnalysisState( - apkResult: apkResult, - isAnalyzingApk: false, + supabaseResult = await SupabaseService.checkMultipleCombinations( + projectUrls: apkResult.supabase.projectUrls, + anonKeys: apkResult.supabase.anonKeys, ); + + isSupabaseVulnerable = supabaseResult.isVulnerable; } + _analysisStates[packageId] = AppAnalysisState( + apkResult: apkResult, + rcResult: rcResult, + supabaseResult: supabaseResult, + isAnalyzingApk: false, + isCheckingRc: false, + isCheckingSupabase: false, + ); + if (saveToCache) { await StorageService.saveAppData( CachedAppData( packageId: packageId, - hasFirebase: apkResult.hasFirebase, - googleAppIds: apkResult.googleAppIds, - googleApiKeys: apkResult.googleApiKeys, + hasFirebase: apkResult.firebase.hasFirebase, + googleAppIds: apkResult.firebase.googleAppIds, + googleApiKeys: apkResult.firebase.googleApiKeys, rcAccessible: rcResult?.isAccessible, rcConfigValues: rcResult?.configValues, + hasSupabase: apkResult.supabase.hasSupabase, + supabaseUrls: apkResult.supabase.projectUrls, + supabaseAnonKeys: apkResult.supabase.anonKeys, + supabaseVulnerable: supabaseResult?.isVulnerable, + supabaseSecurityData: supabaseResult?.toMap(), analyzedAt: DateTime.now(), ), ); } - return (hasFirebase: apkResult.hasFirebase, isVulnerable: isVulnerable); + return ( + hasFirebase: apkResult.firebase.hasFirebase, + hasSupabase: apkResult.supabase.hasSupabase, + isFirebaseVulnerable: isFirebaseVulnerable, + isSupabaseVulnerable: isSupabaseVulnerable, + ); } catch (e) { _analysisStates[packageId] = AppAnalysisState( - apkResult: FirebaseAnalysisResult.error(e.toString()), + apkResult: ApkAnalysisResult.error(e.toString()), isAnalyzingApk: false, ); if (saveToCache) { @@ -349,24 +519,43 @@ class AnalysisProvider extends ChangeNotifier { ), ); } - return (hasFirebase: false, isVulnerable: false); + return ( + hasFirebase: false, + hasSupabase: false, + isFirebaseVulnerable: false, + isSupabaseVulnerable: false, + ); } } void _updateProgressStats() { int withFirebase = 0; + int withSupabase = 0; int vulnerable = 0; + int firebaseVulnerable = 0; + int supabaseVulnerable = 0; for (final state in _analysisStates.values) { - if (state.apkResult?.hasFirebase == true) withFirebase++; - if (state.rcResult?.isAccessible == true) vulnerable++; + if (state.hasFirebase) withFirebase++; + if (state.hasSupabase) withSupabase++; + if (state.isFirebaseVulnerable) { + vulnerable++; + firebaseVulnerable++; + } + if (state.isSupabaseVulnerable) { + vulnerable++; + supabaseVulnerable++; + } } _progress = AnalysisProgress( total: _packages.length, completed: _packages.length, withFirebase: withFirebase, + withSupabase: withSupabase, vulnerable: vulnerable, + firebaseVulnerable: firebaseVulnerable, + supabaseVulnerable: supabaseVulnerable, isComplete: true, ); } diff --git a/lib/src/services/apk_analyzer.dart b/lib/src/services/apk_analyzer.dart index 6f9fc64..43f8298 100644 --- a/lib/src/services/apk_analyzer.dart +++ b/lib/src/services/apk_analyzer.dart @@ -42,21 +42,113 @@ class FirebaseAnalysisResult { } } +class SupabaseAnalysisResult { + final bool hasSupabase; + final List projectUrls; + final List anonKeys; + final String? error; + + SupabaseAnalysisResult({ + required this.hasSupabase, + this.projectUrls = const [], + this.anonKeys = const [], + this.error, + }); + + factory SupabaseAnalysisResult.error(String message) { + return SupabaseAnalysisResult( + hasSupabase: false, + projectUrls: [], + anonKeys: [], + error: message, + ); + } + + factory SupabaseAnalysisResult.none() { + return SupabaseAnalysisResult( + hasSupabase: false, + projectUrls: [], + anonKeys: [], + ); + } + + Map toMap() => { + 'hasSupabase': hasSupabase, + 'projectUrls': projectUrls, + 'anonKeys': anonKeys, + 'error': error, + }; + + factory SupabaseAnalysisResult.fromMap(Map map) { + return SupabaseAnalysisResult( + hasSupabase: map['hasSupabase'] as bool? ?? false, + projectUrls: List.from(map['projectUrls'] ?? []), + anonKeys: List.from(map['anonKeys'] ?? []), + error: map['error'] as String?, + ); + } +} + +class ApkAnalysisResult { + final FirebaseAnalysisResult firebase; + final SupabaseAnalysisResult supabase; + final String? error; + + ApkAnalysisResult({ + required this.firebase, + required this.supabase, + this.error, + }); + + bool get hasAnyBackend => firebase.hasFirebase || supabase.hasSupabase; + + factory ApkAnalysisResult.error(String message) { + return ApkAnalysisResult( + firebase: FirebaseAnalysisResult.error(message), + supabase: SupabaseAnalysisResult.error(message), + error: message, + ); + } + + Map toMap() => { + 'firebase': firebase.toMap(), + 'supabase': supabase.toMap(), + 'error': error, + }; + + factory ApkAnalysisResult.fromMap(Map map) { + return ApkAnalysisResult( + firebase: FirebaseAnalysisResult.fromMap( + Map.from(map['firebase'] ?? {'hasFirebase': false}), + ), + supabase: SupabaseAnalysisResult.fromMap( + Map.from(map['supabase'] ?? {'hasSupabase': false}), + ), + error: map['error'] as String?, + ); + } +} + Future> _analyzeApkInIsolate(String apkPath) async { try { final file = File(apkPath); if (!await file.exists()) { - return FirebaseAnalysisResult.error('APK file not found').toMap(); + return ApkAnalysisResult.error('APK file not found').toMap(); } final bytes = await file.readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); + // Firebase patterns final Set foundAppIds = {}; final Set foundApiKeys = {}; + // Supabase patterns + final Set foundSupabaseUrls = {}; + final Set foundSupabaseKeys = {}; + final googleAppIdPattern = RegExp( r'\d+:\d+:android:[a-f0-9]+', caseSensitive: false, @@ -65,32 +157,62 @@ Future> _analyzeApkInIsolate(String apkPath) async { r'AIza[0-9A-Za-z_-]{35}', ); + // Supabase URL pattern: https://.supabase.co + final supabaseUrlPattern = RegExp( + r'https?://[a-z0-9-]+\.supabase\.co', + caseSensitive: false, + ); + + // Supabase anon/publishable key patterns: + // Old format (JWT): eyJ.. + // New format: sb_publishable_ or sb_secret_ + final supabaseKeyPatternJWT = RegExp( + r'eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}', + ); + final supabaseKeyPatternNew = RegExp( + r'sb_(publishable|secret)_[A-Za-z0-9_-]{20,}', + ); + for (final archiveFile in archive) { if (archiveFile.isFile) { final fileName = archiveFile.name.toLowerCase(); if (_shouldAnalyzeFileStatic(fileName)) { final content = archiveFile.content as List; - final extractedData = _extractGoogleCredentialsStatic( + final extractedData = _extractAllCredentialsStatic( content, googleAppIdPattern, googleApiKeyPattern, + supabaseUrlPattern, + supabaseKeyPatternJWT, + supabaseKeyPatternNew, ); - foundAppIds.addAll(extractedData.$1); - foundApiKeys.addAll(extractedData.$2); + foundAppIds.addAll(extractedData.firebaseAppIds); + foundApiKeys.addAll(extractedData.firebaseApiKeys); + foundSupabaseUrls.addAll(extractedData.supabaseUrls); + foundSupabaseKeys.addAll(extractedData.supabaseKeys); } } } final hasFirebase = foundAppIds.isNotEmpty || foundApiKeys.isNotEmpty; + // Detect Supabase if URL OR key found (security check requires both) + final hasSupabase = foundSupabaseUrls.isNotEmpty || foundSupabaseKeys.isNotEmpty; - return FirebaseAnalysisResult( - hasFirebase: hasFirebase, - googleAppIds: foundAppIds.toList(), - googleApiKeys: foundApiKeys.toList(), + return ApkAnalysisResult( + firebase: FirebaseAnalysisResult( + hasFirebase: hasFirebase, + googleAppIds: foundAppIds.toList(), + googleApiKeys: foundApiKeys.toList(), + ), + supabase: SupabaseAnalysisResult( + hasSupabase: hasSupabase, + projectUrls: foundSupabaseUrls.toList(), + anonKeys: foundSupabaseKeys.toList(), + ), ).toMap(); } catch (e) { - return FirebaseAnalysisResult.error('Failed to analyze APK: $e').toMap(); + return ApkAnalysisResult.error('Failed to analyze APK: $e').toMap(); } } @@ -99,34 +221,149 @@ bool _shouldAnalyzeFileStatic(String fileName) { fileName.endsWith('.xml') || fileName.endsWith('.json') || fileName.endsWith('.dex') || + fileName.endsWith('.properties') || + fileName.endsWith('.so') || // Flutter apps store strings in native libs fileName.contains('google-services') || - fileName.contains('firebase'); + fileName.contains('firebase') || + fileName.contains('supabase') || + fileName.contains('config'); } -(Set, Set) _extractGoogleCredentialsStatic( +class _ExtractedCredentials { + final Set firebaseAppIds; + final Set firebaseApiKeys; + final Set supabaseUrls; + final Set supabaseKeys; + + _ExtractedCredentials({ + required this.firebaseAppIds, + required this.firebaseApiKeys, + required this.supabaseUrls, + required this.supabaseKeys, + }); +} + +_ExtractedCredentials _extractAllCredentialsStatic( List content, - RegExp appIdPattern, - RegExp apiKeyPattern, + RegExp firebaseAppIdPattern, + RegExp firebaseApiKeyPattern, + RegExp supabaseUrlPattern, + RegExp supabaseKeyPatternJWT, + RegExp supabaseKeyPatternNew, ) { - final Set foundAppIds = {}; - final Set foundApiKeys = {}; + final Set foundFirebaseAppIds = {}; + final Set foundFirebaseApiKeys = {}; + final Set foundSupabaseUrls = {}; + final Set foundSupabaseKeys = {}; try { final stringContent = _extractStringsFromBytesStatic(content); - final appIdMatches = appIdPattern.allMatches(stringContent); + // Firebase credentials + final appIdMatches = firebaseAppIdPattern.allMatches(stringContent); for (final match in appIdMatches) { - foundAppIds.add(match.group(0)!); + foundFirebaseAppIds.add(match.group(0)!); } - final apiKeyMatches = apiKeyPattern.allMatches(stringContent); + final apiKeyMatches = firebaseApiKeyPattern.allMatches(stringContent); for (final match in apiKeyMatches) { - foundApiKeys.add(match.group(0)!); + foundFirebaseApiKeys.add(match.group(0)!); + } + + // Supabase credentials + final supabaseUrlMatches = supabaseUrlPattern.allMatches(stringContent); + for (final match in supabaseUrlMatches) { + foundSupabaseUrls.add(match.group(0)!); + } + + // Old JWT format keys + final supabaseKeyMatchesJWT = supabaseKeyPatternJWT.allMatches(stringContent); + for (final match in supabaseKeyMatchesJWT) { + final key = match.group(0)!; + if (_isLikelySupabaseKeyJWT(key)) { + foundSupabaseKeys.add(key); + } + } + + // New sb_publishable/sb_secret format keys + final supabaseKeyMatchesNew = supabaseKeyPatternNew.allMatches(stringContent); + for (final match in supabaseKeyMatchesNew) { + foundSupabaseKeys.add(match.group(0)!); } } catch (e) { + // Ignore extraction errors } - return (foundAppIds, foundApiKeys); + return _ExtractedCredentials( + firebaseAppIds: foundFirebaseAppIds, + firebaseApiKeys: foundFirebaseApiKeys, + supabaseUrls: foundSupabaseUrls, + supabaseKeys: foundSupabaseKeys, + ); +} + +bool _isLikelySupabaseKeyJWT(String jwt) { + try { + final parts = jwt.split('.'); + if (parts.length != 3) return false; + + // Decode the payload (second part) + String payload = parts[1]; + // Add padding if needed + while (payload.length % 4 != 0) { + payload += '='; + } + // Replace URL-safe characters + payload = payload.replaceAll('-', '+').replaceAll('_', '/'); + + // Decode base64 and check for Supabase-specific fields + final decodedBytes = _base64Decode(payload); + if (decodedBytes == null) return false; + + final decodedStr = String.fromCharCodes(decodedBytes); + + // Check for Supabase-specific indicators in the JWT payload: + // - "iss" containing "supabase" + // - "role" field (anon, authenticated, service_role) + // - "ref" field (project reference) + final isSupabase = decodedStr.contains('"iss"') && + (decodedStr.contains('supabase') || + decodedStr.contains('"role":"anon"') || + decodedStr.contains('"role":"service_role"') || + decodedStr.contains('"role":"authenticated"')); + + return isSupabase; + } catch (e) { + return false; + } +} + +List? _base64Decode(String input) { + try { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + final bytes = []; + int buffer = 0; + int bitsCollected = 0; + + for (int i = 0; i < input.length; i++) { + final char = input[i]; + if (char == '=') break; + final value = chars.indexOf(char); + if (value == -1) continue; + + buffer = (buffer << 6) | value; + bitsCollected += 6; + + if (bitsCollected >= 8) { + bitsCollected -= 8; + bytes.add((buffer >> bitsCollected) & 0xFF); + } + } + + return bytes; + } catch (e) { + return null; + } } String _extractStringsFromBytesStatic(List bytes) { @@ -153,12 +390,19 @@ String _extractStringsFromBytesStatic(List bytes) { } class ApkAnalyzer { - static Future analyzeApk(String apkPath) async { + static Future analyzeApk(String apkPath) async { try { final resultMap = await Isolate.run(() => _analyzeApkInIsolate(apkPath)); - return FirebaseAnalysisResult.fromMap(resultMap); + return ApkAnalysisResult.fromMap(resultMap); } catch (e) { - return FirebaseAnalysisResult.error('Isolate error: $e'); + return ApkAnalysisResult.error('Isolate error: $e'); } } + + // Legacy method for backwards compatibility + static Future analyzeApkFirebaseOnly( + String apkPath) async { + final result = await analyzeApk(apkPath); + return result.firebase; + } } diff --git a/lib/src/services/settings_service.dart b/lib/src/services/settings_service.dart new file mode 100644 index 0000000..b491293 --- /dev/null +++ b/lib/src/services/settings_service.dart @@ -0,0 +1,43 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsService { + static const String _hideEmptyRcKey = 'hide_empty_rc'; + static const String _minConfigValuesKey = 'min_config_values'; + + static SharedPreferences? _prefs; + + static Future init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + /// Hide apps with accessible but empty Remote Config from "Vulnerable" filter + static bool get hideEmptyRemoteConfig { + return _prefs?.getBool(_hideEmptyRcKey) ?? true; // Default: hide empty RC + } + + static Future setHideEmptyRemoteConfig(bool value) async { + await _prefs?.setBool(_hideEmptyRcKey, value); + } + + /// Minimum number of config values to be considered "vulnerable" + /// Default: 1 (at least one value must be exposed) + static int get minConfigValuesToBeVulnerable { + return _prefs?.getInt(_minConfigValuesKey) ?? 1; + } + + static Future setMinConfigValuesToBeVulnerable(int value) async { + await _prefs?.setInt(_minConfigValuesKey, value); + } + + /// Check if a Remote Config result should be considered vulnerable + static bool isReallyVulnerable({ + required bool isAccessible, + required int configValueCount, + }) { + if (!isAccessible) return false; + if (hideEmptyRemoteConfig && configValueCount < minConfigValuesToBeVulnerable) { + return false; + } + return true; + } +} diff --git a/lib/src/services/storage_service.dart b/lib/src/services/storage_service.dart index 82343d4..b49741d 100644 --- a/lib/src/services/storage_service.dart +++ b/lib/src/services/storage_service.dart @@ -2,15 +2,24 @@ import 'dart:convert'; import 'package:rcspy/src/services/apk_analyzer.dart'; import 'package:rcspy/src/services/remote_config_service.dart'; +import 'package:rcspy/src/services/supabase_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class CachedAppData { final String packageId; + // Firebase data final bool hasFirebase; final List googleAppIds; final List googleApiKeys; final bool? rcAccessible; final Map? rcConfigValues; + // Supabase data + final bool hasSupabase; + final List supabaseUrls; + final List supabaseAnonKeys; + final bool? supabaseVulnerable; + final Map? supabaseSecurityData; + // Common final String? error; final DateTime analyzedAt; @@ -21,10 +30,19 @@ class CachedAppData { this.googleApiKeys = const [], this.rcAccessible, this.rcConfigValues, + this.hasSupabase = false, + this.supabaseUrls = const [], + this.supabaseAnonKeys = const [], + this.supabaseVulnerable, + this.supabaseSecurityData, this.error, required this.analyzedAt, }); + bool get hasAnyBackend => hasFirebase || hasSupabase; + bool get hasAnyVulnerability => + rcAccessible == true || supabaseVulnerable == true; + Map toJson() => { 'packageId': packageId, 'hasFirebase': hasFirebase, @@ -32,6 +50,11 @@ class CachedAppData { 'googleApiKeys': googleApiKeys, 'rcAccessible': rcAccessible, 'rcConfigValues': rcConfigValues, + 'hasSupabase': hasSupabase, + 'supabaseUrls': supabaseUrls, + 'supabaseAnonKeys': supabaseAnonKeys, + 'supabaseVulnerable': supabaseVulnerable, + 'supabaseSecurityData': supabaseSecurityData, 'error': error, 'analyzedAt': analyzedAt.toIso8601String(), }; @@ -46,12 +69,19 @@ class CachedAppData { rcConfigValues: json['rcConfigValues'] != null ? Map.from(json['rcConfigValues']) : null, + hasSupabase: json['hasSupabase'] as bool? ?? false, + supabaseUrls: List.from(json['supabaseUrls'] ?? []), + supabaseAnonKeys: List.from(json['supabaseAnonKeys'] ?? []), + supabaseVulnerable: json['supabaseVulnerable'] as bool?, + supabaseSecurityData: json['supabaseSecurityData'] != null + ? Map.from(json['supabaseSecurityData']) + : null, error: json['error'] as String?, analyzedAt: DateTime.parse(json['analyzedAt'] as String), ); } - FirebaseAnalysisResult toApkResult() { + FirebaseAnalysisResult toFirebaseResult() { if (error != null) { return FirebaseAnalysisResult.error(error!); } @@ -62,6 +92,27 @@ class CachedAppData { ); } + SupabaseAnalysisResult toSupabaseAnalysisResult() { + if (error != null) { + return SupabaseAnalysisResult.error(error!); + } + return SupabaseAnalysisResult( + hasSupabase: hasSupabase, + projectUrls: supabaseUrls, + anonKeys: supabaseAnonKeys, + ); + } + + ApkAnalysisResult toApkResult() { + if (error != null) { + return ApkAnalysisResult.error(error!); + } + return ApkAnalysisResult( + firebase: toFirebaseResult(), + supabase: toSupabaseAnalysisResult(), + ); + } + RemoteConfigResult? toRcResult() { if (rcAccessible == null) return null; if (rcAccessible!) { @@ -69,6 +120,14 @@ class CachedAppData { } return RemoteConfigResult.secure(); } + + SupabaseSecurityResult? toSupabaseSecurityResult() { + if (supabaseVulnerable == null) return null; + if (supabaseVulnerable!) { + return SupabaseSecurityResult.fromMap(supabaseSecurityData ?? {}); + } + return SupabaseSecurityResult.secure(); + } } class StorageService { diff --git a/lib/src/services/supabase_service.dart b/lib/src/services/supabase_service.dart new file mode 100644 index 0000000..b3da3e1 --- /dev/null +++ b/lib/src/services/supabase_service.dart @@ -0,0 +1,513 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class SupabaseCredentials { + final List projectUrls; + final List anonKeys; + + SupabaseCredentials({ + this.projectUrls = const [], + this.anonKeys = const [], + }); + + bool get hasSupabase => projectUrls.isNotEmpty && anonKeys.isNotEmpty; + + Map toMap() => { + 'projectUrls': projectUrls, + 'anonKeys': anonKeys, + }; + + factory SupabaseCredentials.fromMap(Map map) { + return SupabaseCredentials( + projectUrls: List.from(map['projectUrls'] ?? []), + anonKeys: List.from(map['anonKeys'] ?? []), + ); + } +} + +class StorageBucketInfo { + final String id; + final String name; + final bool isPublic; + final List exposedFiles; + + StorageBucketInfo({ + required this.id, + required this.name, + required this.isPublic, + this.exposedFiles = const [], + }); + + Map toMap() => { + 'id': id, + 'name': name, + 'isPublic': isPublic, + 'exposedFiles': exposedFiles, + }; + + factory StorageBucketInfo.fromMap(Map map) { + return StorageBucketInfo( + id: map['id'] as String, + name: map['name'] as String, + isPublic: map['isPublic'] as bool? ?? map['public'] as bool? ?? false, + exposedFiles: List.from(map['exposedFiles'] ?? []), + ); + } +} + +class ExposedTableInfo { + final String tableName; + final int? rowCount; + final List columns; + final List> sampleData; + + ExposedTableInfo({ + required this.tableName, + this.rowCount, + this.columns = const [], + this.sampleData = const [], + }); + + Map toMap() => { + 'tableName': tableName, + 'rowCount': rowCount, + 'columns': columns, + 'sampleData': sampleData, + }; + + factory ExposedTableInfo.fromMap(Map map) { + return ExposedTableInfo( + tableName: map['tableName'] as String, + rowCount: map['rowCount'] as int?, + columns: List.from(map['columns'] ?? []), + sampleData: (map['sampleData'] as List?) + ?.map((e) => Map.from(e)) + .toList() ?? + [], + ); + } +} + +class SupabaseSecurityResult { + final bool isVulnerable; + final String? workingProjectUrl; + final String? workingAnonKey; + final List publicBuckets; + final List allBuckets; + final List exposedTables; + final List exposedStorageObjects; + final String? error; + + SupabaseSecurityResult({ + required this.isVulnerable, + this.workingProjectUrl, + this.workingAnonKey, + this.publicBuckets = const [], + this.allBuckets = const [], + this.exposedTables = const [], + this.exposedStorageObjects = const [], + this.error, + }); + + factory SupabaseSecurityResult.secure() { + return SupabaseSecurityResult(isVulnerable: false); + } + + factory SupabaseSecurityResult.error(String message) { + return SupabaseSecurityResult( + isVulnerable: false, + error: message, + ); + } + + factory SupabaseSecurityResult.vulnerable({ + required String projectUrl, + required String anonKey, + List publicBuckets = const [], + List allBuckets = const [], + List exposedTables = const [], + List exposedStorageObjects = const [], + }) { + return SupabaseSecurityResult( + isVulnerable: true, + workingProjectUrl: projectUrl, + workingAnonKey: anonKey, + publicBuckets: publicBuckets, + allBuckets: allBuckets, + exposedTables: exposedTables, + exposedStorageObjects: exposedStorageObjects, + ); + } + + Map toMap() => { + 'isVulnerable': isVulnerable, + 'workingProjectUrl': workingProjectUrl, + 'workingAnonKey': workingAnonKey, + 'publicBuckets': publicBuckets.map((b) => b.toMap()).toList(), + 'allBuckets': allBuckets.map((b) => b.toMap()).toList(), + 'exposedTables': exposedTables.map((t) => t.toMap()).toList(), + 'exposedStorageObjects': exposedStorageObjects, + 'error': error, + }; + + factory SupabaseSecurityResult.fromMap(Map map) { + return SupabaseSecurityResult( + isVulnerable: map['isVulnerable'] as bool? ?? false, + workingProjectUrl: map['workingProjectUrl'] as String?, + workingAnonKey: map['workingAnonKey'] as String?, + publicBuckets: (map['publicBuckets'] as List?) + ?.map((e) => StorageBucketInfo.fromMap(Map.from(e))) + .toList() ?? + [], + allBuckets: (map['allBuckets'] as List?) + ?.map((e) => StorageBucketInfo.fromMap(Map.from(e))) + .toList() ?? + [], + exposedTables: (map['exposedTables'] as List?) + ?.map((e) => ExposedTableInfo.fromMap(Map.from(e))) + .toList() ?? + [], + exposedStorageObjects: + List.from(map['exposedStorageObjects'] ?? []), + error: map['error'] as String?, + ); + } +} + +class SupabaseService { + static const List _commonTableNames = [ + // User/Auth related + 'users', + 'profiles', + 'accounts', + 'customers', + 'members', + 'admins', + 'user_profiles', + 'user_data', + 'users_exposed', + // Content + 'posts', + 'comments', + 'messages', + 'articles', + 'content', + 'media', + // E-commerce + 'orders', + 'products', + 'items', + 'cart', + 'payments', + 'transactions', + 'invoices', + // Files/Documents + 'documents', + 'files', + 'uploads', + 'attachments', + 'images', + 'backups', + // Config/Settings + 'settings', + 'config', + 'app_config', + 'configurations', + 'preferences', + 'options', + // Data/Logs + 'data', + 'logs', + 'events', + 'analytics', + 'metrics', + 'audit_logs', + // Notifications + 'notifications', + 'alerts', + 'emails', + // Sessions/Auth + 'sessions', + 'tokens', + 'api_keys', + 'credentials', + 'auth_tokens', + // Security-sensitive (test for vulnerabilities) + 'secrets', + 'test_secrets', + 'keys', + 'passwords', + 'private', + 'sensitive', + 'internal', + // Generic + 'records', + 'entries', + 'info', + 'metadata', + 'public', + 'test', + 'demo', + 'sample', + ]; + + static Future checkSecurity({ + required String projectUrl, + required String anonKey, + }) async { + try { + final normalizedUrl = _normalizeProjectUrl(projectUrl); + + final headers = { + 'apikey': anonKey, + 'Authorization': 'Bearer $anonKey', + }; + + final List allBuckets = []; + final List publicBuckets = []; + final List exposedTables = []; + final List exposedStorageObjects = []; + + // Check storage buckets + final bucketsResult = await _checkStorageBuckets(normalizedUrl, headers); + allBuckets.addAll(bucketsResult); + publicBuckets.addAll(bucketsResult.where((b) => b.isPublic)); + + // Check for exposed storage objects via REST API + final storageObjects = await _checkStorageObjects(normalizedUrl, headers); + exposedStorageObjects.addAll(storageObjects); + + // Check for exposed tables + final tables = await _checkExposedTables(normalizedUrl, headers); + exposedTables.addAll(tables); + + final isVulnerable = publicBuckets.isNotEmpty || + exposedTables.isNotEmpty || + exposedStorageObjects.isNotEmpty; + + if (isVulnerable) { + return SupabaseSecurityResult.vulnerable( + projectUrl: normalizedUrl, + anonKey: anonKey, + publicBuckets: publicBuckets, + allBuckets: allBuckets, + exposedTables: exposedTables, + exposedStorageObjects: exposedStorageObjects, + ); + } + + return SupabaseSecurityResult.secure(); + } catch (e) { + return SupabaseSecurityResult.error('Failed to check Supabase: $e'); + } + } + + static Future> _checkStorageBuckets( + String projectUrl, + Map headers, + ) async { + final buckets = []; + + try { + final url = Uri.parse('$projectUrl/storage/v1/bucket'); + final response = await http.get(url, headers: headers).timeout( + const Duration(seconds: 10), + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + for (final bucket in data) { + final bucketInfo = StorageBucketInfo( + id: bucket['id'] as String? ?? '', + name: bucket['name'] as String? ?? '', + isPublic: bucket['public'] as bool? ?? false, + ); + buckets.add(bucketInfo); + } + } + } catch (e) { + // Bucket listing failed - might not have permission + } + + return buckets; + } + + static Future> _checkStorageObjects( + String projectUrl, + Map headers, + ) async { + final objects = []; + + try { + // Try to query storage.objects via REST API + final url = Uri.parse( + '$projectUrl/rest/v1/objects?select=name,bucket_id&limit=50', + ); + final storageHeaders = { + ...headers, + 'accept-profile': 'storage', + }; + + final response = await http.get(url, headers: storageHeaders).timeout( + const Duration(seconds: 10), + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + for (final obj in data) { + final name = obj['name'] as String?; + final bucketId = obj['bucket_id'] as String?; + if (name != null) { + objects.add(bucketId != null ? '$bucketId/$name' : name); + } + } + } + } catch (e) { + // Storage objects query failed + } + + return objects; + } + + static Future> _checkExposedTables( + String projectUrl, + Map headers, + ) async { + final exposedTables = []; + final checkedTables = {}; + + // First, try to discover tables from PostgREST OpenAPI schema + final discoveredTables = await _discoverTablesFromSchema(projectUrl, headers); + + // Combine discovered tables with common table names + final allTablesToCheck = { + ...discoveredTables, + ..._commonTableNames, + }; + + // Try each table name + for (final tableName in allTablesToCheck) { + if (checkedTables.contains(tableName)) continue; + checkedTables.add(tableName); + + try { + final url = Uri.parse( + '$projectUrl/rest/v1/$tableName?select=*&limit=5', + ); + + final response = await http.get(url, headers: headers).timeout( + const Duration(seconds: 5), + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + if (data.isNotEmpty) { + final firstRow = data.first as Map; + exposedTables.add(ExposedTableInfo( + tableName: tableName, + rowCount: data.length, + columns: firstRow.keys.toList(), + sampleData: data + .take(3) + .map((e) => Map.from(e)) + .toList(), + )); + } + } + } catch (e) { + // Table not accessible or doesn't exist + } + } + + return exposedTables; + } + + /// Discovers available tables from PostgREST OpenAPI schema endpoint + static Future> _discoverTablesFromSchema( + String projectUrl, + Map headers, + ) async { + final tables = []; + + try { + // PostgREST exposes an OpenAPI schema at the root + final url = Uri.parse('$projectUrl/rest/v1/'); + final response = await http.get(url, headers: headers).timeout( + const Duration(seconds: 10), + ); + + if (response.statusCode == 200) { + final dynamic data = jsonDecode(response.body); + + // OpenAPI 3.0 format - paths contain table endpoints + if (data is Map && data.containsKey('paths')) { + final paths = data['paths'] as Map; + for (final path in paths.keys) { + // Extract table name from path like "/tablename" + if (path.startsWith('/') && !path.contains('{')) { + final tableName = path.substring(1); + if (tableName.isNotEmpty && !tableName.contains('/')) { + tables.add(tableName); + } + } + } + } + + // Try definitions/components schemas + if (data is Map && data.containsKey('definitions')) { + final definitions = data['definitions'] as Map; + tables.addAll(definitions.keys.where((k) => !k.startsWith('_'))); + } + + if (data is Map && data.containsKey('components')) { + final components = data['components'] as Map?; + final schemas = components?['schemas'] as Map?; + if (schemas != null) { + tables.addAll(schemas.keys.where((k) => !k.startsWith('_'))); + } + } + } + } catch (e) { + // Schema discovery failed - fall back to common names + } + + return tables; + } + + static Future checkMultipleCombinations({ + required List projectUrls, + required List anonKeys, + }) async { + for (final projectUrl in projectUrls) { + for (final anonKey in anonKeys) { + final result = await checkSecurity( + projectUrl: projectUrl, + anonKey: anonKey, + ); + + if (result.isVulnerable) { + return result; + } + } + } + + return SupabaseSecurityResult.secure(); + } + + static String _normalizeProjectUrl(String url) { + var normalized = url.trim(); + + // Remove trailing slash + if (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + + // Ensure https + if (!normalized.startsWith('http')) { + normalized = 'https://$normalized'; + } + + return normalized; + } +} diff --git a/lib/src/widgets/app_tile.dart b/lib/src/widgets/app_tile.dart index 8d05d60..f323a1b 100644 --- a/lib/src/widgets/app_tile.dart +++ b/lib/src/widgets/app_tile.dart @@ -2,8 +2,10 @@ import 'package:device_packages/device_packages.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rcspy/src/pages/remote_config_page.dart'; +import 'package:rcspy/src/pages/supabase_results_page.dart'; import 'package:rcspy/src/providers/analysis_provider.dart'; import 'package:rcspy/src/services/apk_analyzer.dart'; +import 'package:rcspy/src/services/supabase_service.dart'; class AppTile extends StatelessWidget { const AppTile({super.key, required this.package}); @@ -38,8 +40,10 @@ class AppTile extends StatelessWidget { } void _showOptionsMenu(BuildContext context, AppAnalysisState? state) { - final isLoading = - state == null || state.isAnalyzingApk || state.isCheckingRc; + final isLoading = state == null || + state.isAnalyzingApk || + state.isCheckingRc || + state.isCheckingSupabase; showModalBottomSheet( context: context, @@ -70,13 +74,22 @@ class AppTile extends StatelessWidget { ); }, ), - if (state?.apkResult?.hasFirebase == true) + if (state?.hasFirebase == true) ListTile( - leading: const Icon(Icons.info_outline), + leading: const Icon(Icons.local_fire_department), title: const Text('View Firebase details'), onTap: () { Navigator.pop(context); - _showFirebaseDetails(context, state!.apkResult!); + _showFirebaseDetails(context, state!.apkResult!.firebase); + }, + ), + if (state?.hasSupabase == true) + ListTile( + leading: const Icon(Icons.storage), + title: const Text('View Supabase details'), + onTap: () { + Navigator.pop(context); + _showSupabaseDetails(context, state!.apkResult!.supabase); }, ), if (state?.rcResult?.isAccessible == true) @@ -96,6 +109,23 @@ class AppTile extends StatelessWidget { ); }, ), + if (state?.supabaseResult?.isVulnerable == true) + ListTile( + leading: const Icon(Icons.security), + title: const Text('View Supabase vulnerabilities'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SupabaseResultsPage( + appName: package.name ?? 'Unknown App', + result: state!.supabaseResult!, + ), + ), + ); + }, + ), const SizedBox(height: 8), ], ), @@ -105,7 +135,7 @@ class AppTile extends StatelessWidget { void _handleTap(BuildContext context, AppAnalysisState? state) { final rcResult = state?.rcResult; - final apkResult = state?.apkResult; + final supabaseResult = state?.supabaseResult; if (rcResult?.isAccessible == true) { Navigator.push( @@ -117,8 +147,20 @@ class AppTile extends StatelessWidget { ), ), ); - } else if (apkResult?.hasFirebase == true) { - _showFirebaseDetails(context, apkResult!); + } else if (supabaseResult?.isVulnerable == true) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SupabaseResultsPage( + appName: package.name ?? 'Unknown App', + result: supabaseResult!, + ), + ), + ); + } else if (state?.hasFirebase == true) { + _showFirebaseDetails(context, state!.apkResult!.firebase); + } else if (state?.hasSupabase == true) { + _showSupabaseDetails(context, state!.apkResult!.supabase); } } @@ -139,6 +181,15 @@ class AppTile extends StatelessWidget { isLoading: true, ), ); + } else if (state?.isCheckingSupabase == true) { + chips.add( + _buildChip( + 'Checking Supabase', + Colors.teal, + Icons.storage, + isLoading: true, + ), + ); } else { chips.add(_buildChip('Waiting', Colors.grey, Icons.hourglass_empty)); } @@ -147,13 +198,15 @@ class AppTile extends StatelessWidget { final apkResult = state.apkResult!; final rcResult = state.rcResult; + final supabaseResult = state.supabaseResult; if (apkResult.error != null) { chips.add(_buildChip('Error', Colors.red, Icons.error_outline)); return Wrap(spacing: 6, runSpacing: 4, children: chips); } - if (apkResult.hasFirebase) { + // Firebase status + if (apkResult.firebase.hasFirebase) { chips.add( _buildChip('Firebase', Colors.orange, Icons.local_fire_department), ); @@ -173,8 +226,8 @@ class AppTile extends StatelessWidget { chips.add(_buildChip('RC Secure', Colors.green, Icons.lock)); } } else { - final hasAppId = apkResult.googleAppIds.isNotEmpty; - final hasApiKey = apkResult.googleApiKeys.isNotEmpty; + final hasAppId = apkResult.firebase.googleAppIds.isNotEmpty; + final hasApiKey = apkResult.firebase.googleApiKeys.isNotEmpty; if (!hasAppId || !hasApiKey) { chips.add( _buildChip( @@ -185,9 +238,37 @@ class AppTile extends StatelessWidget { ); } } - } else { + } + + // Supabase status + if (apkResult.supabase.hasSupabase) { + chips.add( + _buildChip('Supabase', Colors.teal, Icons.storage), + ); + + if (supabaseResult != null) { + if (supabaseResult.isVulnerable) { + final buckets = supabaseResult.publicBuckets.length; + final tables = supabaseResult.exposedTables.length; + final total = buckets + tables; + chips.add( + _buildChip( + 'Exposed ($total)', + Colors.red, + Icons.warning_amber, + isBold: true, + ), + ); + } else { + chips.add(_buildChip('SB Secure', Colors.green, Icons.lock)); + } + } + } + + // No backend detected + if (!apkResult.firebase.hasFirebase && !apkResult.supabase.hasSupabase) { chips.add( - _buildChip('No Firebase', Colors.grey, Icons.check_circle_outline), + _buildChip('No Backend', Colors.grey, Icons.check_circle_outline), ); } @@ -238,8 +319,8 @@ class AppTile extends StatelessWidget { } Widget _buildTrailingWidget(BuildContext context, AppAnalysisState? state) { - final apkResult = state?.apkResult; final rcResult = state?.rcResult; + final supabaseResult = state?.supabaseResult; return PopupMenuButton( icon: Icon(Icons.more_vert, color: Colors.grey[600]), @@ -249,12 +330,17 @@ class AppTile extends StatelessWidget { case 'reanalyze': context.read().reanalyzePackage(package); break; - case 'details': - if (apkResult?.hasFirebase == true) { - _showFirebaseDetails(context, apkResult!); + case 'firebase_details': + if (state?.hasFirebase == true) { + _showFirebaseDetails(context, state!.apkResult!.firebase); } break; - case 'config': + case 'supabase_details': + if (state?.hasSupabase == true) { + _showSupabaseDetails(context, state!.apkResult!.supabase); + } + break; + case 'rc_config': if (rcResult?.isAccessible == true) { Navigator.push( context, @@ -267,6 +353,19 @@ class AppTile extends StatelessWidget { ); } break; + case 'supabase_vuln': + if (supabaseResult?.isVulnerable == true) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SupabaseResultsPage( + appName: package.name ?? 'Unknown App', + result: supabaseResult!, + ), + ), + ); + } + break; } }, itemBuilder: (context) => [ @@ -280,20 +379,31 @@ class AppTile extends StatelessWidget { ], ), ), - if (apkResult?.hasFirebase == true) + if (state?.hasFirebase == true) const PopupMenuItem( - value: 'details', + value: 'firebase_details', child: Row( children: [ - Icon(Icons.info_outline, size: 20), + Icon(Icons.local_fire_department, size: 20), SizedBox(width: 8), Text('Firebase details'), ], ), ), + if (state?.hasSupabase == true) + const PopupMenuItem( + value: 'supabase_details', + child: Row( + children: [ + Icon(Icons.storage, size: 20), + SizedBox(width: 8), + Text('Supabase details'), + ], + ), + ), if (rcResult?.isAccessible == true) const PopupMenuItem( - value: 'config', + value: 'rc_config', child: Row( children: [ Icon(Icons.vpn_key, size: 20), @@ -302,6 +412,17 @@ class AppTile extends StatelessWidget { ], ), ), + if (supabaseResult?.isVulnerable == true) + const PopupMenuItem( + value: 'supabase_vuln', + child: Row( + children: [ + Icon(Icons.security, size: 20), + SizedBox(width: 8), + Text('Supabase vulnerabilities'), + ], + ), + ), ], ); } @@ -406,4 +527,105 @@ class AppTile extends StatelessWidget { ), ); } + + void _showSupabaseDetails( + BuildContext context, + SupabaseAnalysisResult supabaseResult, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) => SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + package.name ?? 'Unknown App', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + + if (supabaseResult.projectUrls.isNotEmpty) ...[ + const Text( + '🌐 Supabase Project URLs:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 8), + ...supabaseResult.projectUrls.map( + (url) => Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.teal.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.teal.withOpacity(0.3)), + ), + child: SelectableText( + url, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Colors.teal, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + + if (supabaseResult.anonKeys.isNotEmpty) ...[ + const Text( + '🔑 Supabase Keys:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 8), + ...supabaseResult.anonKeys.map( + (key) => Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purple.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.purple.withOpacity(0.3)), + ), + child: SelectableText( + key, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.purple, + ), + ), + ), + ), + ], + + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 68c991a..e9245ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" crypto: dependency: transitive description: @@ -124,6 +132,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -219,6 +235,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -331,6 +355,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: @@ -512,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 66dc839..f9c7ab5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: shared_preferences: ^2.3.4 url_launcher: ^6.3.2 package_info_plus: ^9.0.0 + share_plus: ^10.0.0 dev_dependencies: flutter_test: diff --git a/test_supabase_insecure.sql b/test_supabase_insecure.sql new file mode 100644 index 0000000..fa27f24 --- /dev/null +++ b/test_supabase_insecure.sql @@ -0,0 +1,113 @@ +-- ============================================ +-- SUPABASE MAXIMUM INSECURE TEST SETUP +-- NUR FÜR TESTZWECKE - NICHT IN PRODUKTION! +-- ============================================ + +-- ===================== +-- 1. UNSICHERE TABELLEN +-- ===================== + +-- Test-Tabelle mit "geheimen" Daten +CREATE TABLE public.test_secrets ( + id SERIAL PRIMARY KEY, + secret_name TEXT, + secret_value TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +INSERT INTO public.test_secrets (secret_name, secret_value) VALUES + ('api_key', 'sk-test-12345'), + ('database_password', 'supersecret123'), + ('admin_token', 'admin-token-xyz'), + ('stripe_key', 'sk_live_fake123456'), + ('jwt_secret', 'my-super-secret-jwt-key'); + +-- User-Tabelle mit sensiblen Daten +CREATE TABLE public.users_exposed ( + id SERIAL PRIMARY KEY, + email TEXT, + password_hash TEXT, + phone TEXT, + credit_card_last4 TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +INSERT INTO public.users_exposed (email, password_hash, phone, credit_card_last4) VALUES + ('admin@test.com', '$2b$10$fakehash123', '+49123456789', '4242'), + ('user@test.com', '$2b$10$fakehash456', '+49987654321', '1234'), + ('vip@test.com', '$2b$10$fakehash789', '+49111222333', '5678'); + +-- Config-Tabelle +CREATE TABLE public.app_config ( + id SERIAL PRIMARY KEY, + key TEXT, + value TEXT +); + +INSERT INTO public.app_config (key, value) VALUES + ('feature_flags', '{"premium": true, "beta": true}'), + ('api_endpoints', '{"internal": "https://internal-api.com"}'), + ('admin_emails', '["admin@company.com", "ceo@company.com"]'); + +-- ===================== +-- 2. RLS KOMPLETT OFFEN +-- ===================== + +-- Alle Tabellen: RLS an aber Policy erlaubt ALLES für anon +ALTER TABLE public.test_secrets ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.users_exposed ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY; + +-- UNSICHER: Anonymer Vollzugriff auf alle Tabellen +CREATE POLICY "anon_full_access_secrets" ON public.test_secrets FOR ALL TO anon USING (true) WITH CHECK (true); +CREATE POLICY "anon_full_access_users" ON public.users_exposed FOR ALL TO anon USING (true) WITH CHECK (true); +CREATE POLICY "anon_full_access_config" ON public.app_config FOR ALL TO anon USING (true) WITH CHECK (true); + +-- ===================== +-- 3. UNSICHERE STORAGE +-- ===================== + +-- Öffentliche Buckets erstellen +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES + ('public-uploads', 'public-uploads', true, 52428800, NULL), + ('user-documents', 'user-documents', true, 52428800, NULL), + ('backups', 'backups', true, 104857600, NULL); + +-- Storage Policies: Erlaube ALLES für anon +CREATE POLICY "anon_storage_all_public" ON storage.objects FOR ALL TO anon USING (bucket_id = 'public-uploads') WITH CHECK (bucket_id = 'public-uploads'); +CREATE POLICY "anon_storage_all_docs" ON storage.objects FOR ALL TO anon USING (bucket_id = 'user-documents') WITH CHECK (bucket_id = 'user-documents'); +CREATE POLICY "anon_storage_all_backups" ON storage.objects FOR ALL TO anon USING (bucket_id = 'backups') WITH CHECK (bucket_id = 'backups'); + +-- ===================== +-- 4. UNSICHERE FUNKTIONEN +-- ===================== + +-- Funktion die alle User-Daten zurückgibt +CREATE OR REPLACE FUNCTION public.get_all_users() +RETURNS SETOF public.users_exposed +LANGUAGE sql +SECURITY DEFINER +AS $$ + SELECT * FROM public.users_exposed; +$$; + +-- Funktion die Secrets zurückgibt +CREATE OR REPLACE FUNCTION public.get_secrets() +RETURNS SETOF public.test_secrets +LANGUAGE sql +SECURITY DEFINER +AS $$ + SELECT * FROM public.test_secrets; +$$; + +-- Grants für anon +GRANT EXECUTE ON FUNCTION public.get_all_users() TO anon; +GRANT EXECUTE ON FUNCTION public.get_secrets() TO anon; + +-- ============================================ +-- FERTIG! Dieses Setup ist MAXIMAL UNSICHER: +-- - Alle Tabellen lesbar/schreibbar für anon +-- - Storage Buckets öffentlich +-- - RPC Funktionen für anon zugänglich +-- ============================================