diff --git a/prowler/config/config.py b/prowler/config/config.py index 6d2e71132f..571bcadacd 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -83,6 +83,7 @@ class Provider(str, Enum): LINODE = "linode" HUAWEICLOUD = "huaweicloud" E2ENETWORKS = "e2enetworks" + SUPABASE = "supabase" # Compliance diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 7dd7c2bf3d..dcd92afa5f 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -716,6 +716,11 @@ cloudflare: # Set to 0 to disable retries max_retries: 3 +# Supabase Configuration +supabase: + # Maximum retries for Management API requests (0 disables retries) + max_retries: 3 + # Vercel Configuration vercel: # vercel.deployment_production_uses_stable_target diff --git a/prowler/config/schema/registry.py b/prowler/config/schema/registry.py index 074ccc264d..7eee522705 100644 --- a/prowler/config/schema/registry.py +++ b/prowler/config/schema/registry.py @@ -17,6 +17,7 @@ from prowler.config.schema.m365 import M365ProviderConfig from prowler.config.schema.mongodbatlas import MongoDBAtlasProviderConfig from prowler.config.schema.okta import OktaProviderConfig from prowler.config.schema.openstack import OpenStackProviderConfig +from prowler.config.schema.supabase import SupabaseProviderConfig from prowler.config.schema.vercel import VercelProviderConfig SCHEMAS: dict[str, type[ProviderConfigBase]] = { @@ -33,4 +34,5 @@ SCHEMAS: dict[str, type[ProviderConfigBase]] = { "okta": OktaProviderConfig, "alibabacloud": AlibabaCloudProviderConfig, "openstack": OpenStackProviderConfig, + "supabase": SupabaseProviderConfig, } diff --git a/prowler/config/schema/supabase.py b/prowler/config/schema/supabase.py new file mode 100644 index 0000000000..6b81526adb --- /dev/null +++ b/prowler/config/schema/supabase.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class SupabaseProviderConfig(ProviderConfigBase): + """Supabase provider configuration schema.""" + + max_retries: Optional[int] = Field( + default=None, + ge=0, + le=10, + description="Maximum retries for Supabase API requests. Range: 0..10.", + ) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index bd346ad8b3..2095515378 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1447,6 +1447,28 @@ class CheckReportVercel(Check_Report): return "global" +@dataclass +class CheckReportSupabase(Check_Report): + """Contains a Supabase organization member finding.""" + + resource_name: str + resource_id: str + organization_slug: str + organization_name: str + + def __init__(self, metadata: Dict, resource: Any) -> None: + super().__init__(metadata, resource) + self.resource_name = getattr(resource, "name", "") + self.resource_id = getattr(resource, "id", "") + self.organization_slug = getattr(resource, "organization_slug", "") + self.organization_name = getattr(resource, "organization_name", "") + + @property + def region(self) -> str: + """Supabase organization membership is global.""" + return "global" + + @dataclass class CheckReportScaleway(Check_Report): """Contains the Scaleway Check's finding information. diff --git a/prowler/providers/supabase/__init__.py b/prowler/providers/supabase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/exceptions/__init__.py b/prowler/providers/supabase/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/exceptions/exceptions.py b/prowler/providers/supabase/exceptions/exceptions.py new file mode 100644 index 0000000000..435cfab607 --- /dev/null +++ b/prowler/providers/supabase/exceptions/exceptions.py @@ -0,0 +1,104 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 21000 to 21999 are reserved for Supabase exceptions +class SupabaseBaseException(ProwlerException): + """Base exception for Supabase provider errors.""" + + SUPABASE_ERROR_CODES = { + (21000, "SupabaseCredentialsError"): { + "message": "Supabase credentials were not found.", + "remediation": "Set SUPABASE_ACCESS_TOKEN to a valid Supabase Personal Access Token.", + }, + (21001, "SupabaseAuthenticationError"): { + "message": "Supabase Management API authentication failed.", + "remediation": "Verify that SUPABASE_ACCESS_TOKEN is valid and has not expired or been revoked.", + }, + (21002, "SupabaseInsufficientPermissionsError"): { + "message": "Supabase Management API permissions are insufficient.", + "remediation": "Use a Personal Access Token from an account that can read the target organizations and their members.", + }, + (21003, "SupabaseRateLimitError"): { + "message": "The Supabase Management API rate limit was exceeded.", + "remediation": "Wait for the rate-limit window to reset before retrying the scan.", + }, + (21004, "SupabaseSessionError"): { + "message": "Failed to create a Supabase Management API session.", + "remediation": "Check the local HTTP client configuration and retry.", + }, + (21005, "SupabaseIdentityError"): { + "message": "Failed to retrieve Supabase organization identity information.", + "remediation": "Ensure the token can call GET /v1/organizations.", + }, + (21006, "SupabaseAPIError"): { + "message": "A Supabase Management API request failed.", + "remediation": "Check Supabase service status, network connectivity, and the API response before retrying.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + error_info = self.SUPABASE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Supabase error.", + "remediation": "Review the Supabase Management API documentation.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source="Supabase", + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class SupabaseCredentialsError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21000, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseAuthenticationError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21001, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseInsufficientPermissionsError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21002, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseRateLimitError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21003, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseSessionError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21004, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseIdentityError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21005, file=file, original_exception=original_exception, message=message + ) + + +class SupabaseAPIError(SupabaseBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21006, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/supabase/lib/__init__.py b/prowler/providers/supabase/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/lib/arguments/__init__.py b/prowler/providers/supabase/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/lib/arguments/arguments.py b/prowler/providers/supabase/lib/arguments/arguments.py new file mode 100644 index 0000000000..29e67f0409 --- /dev/null +++ b/prowler/providers/supabase/lib/arguments/arguments.py @@ -0,0 +1,7 @@ +def init_parser(self): + """Initialize the Supabase provider CLI parser.""" + self.subparsers.add_parser( + "supabase", + parents=[self.common_providers_parser], + help="Supabase Provider (PoC)", + ) diff --git a/prowler/providers/supabase/lib/mutelist/__init__.py b/prowler/providers/supabase/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/lib/mutelist/mutelist.py b/prowler/providers/supabase/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..1a7ad6422f --- /dev/null +++ b/prowler/providers/supabase/lib/mutelist/mutelist.py @@ -0,0 +1,16 @@ +from prowler.lib.check.models import CheckReportSupabase +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class SupabaseMutelist(Mutelist): + """Supabase-specific mutelist helper.""" + + def is_finding_muted(self, finding: CheckReportSupabase) -> bool: + return self.is_muted( + finding.organization_slug, + finding.check_metadata.CheckID, + "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/supabase/lib/service/__init__.py b/prowler/providers/supabase/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/lib/service/service.py b/prowler/providers/supabase/lib/service/service.py new file mode 100644 index 0000000000..a67f896ae3 --- /dev/null +++ b/prowler/providers/supabase/lib/service/service.py @@ -0,0 +1,132 @@ +import time +from email.utils import parsedate_to_datetime +from math import isfinite + +import requests + +from prowler.lib.logger import logger +from prowler.providers.supabase.exceptions.exceptions import ( + SupabaseAPIError, + SupabaseAuthenticationError, + SupabaseInsufficientPermissionsError, + SupabaseRateLimitError, +) + +MAX_RATE_LIMIT_DELAY = 3600 +MIN_UNIX_TIMESTAMP = 1_000_000_000 +RATE_LIMIT_RETRIES_EXHAUSTED_MESSAGE = ( + "Supabase API rate limit remained active after retries." +) +API_REQUEST_RETRIES_EXHAUSTED_MESSAGE = ( + "Supabase Management API request failed after retries." +) +SUPABASE_API_REQUEST_RETRY_WARNING = ( + "Supabase API request failed; retrying in {delay} seconds." +) + + +def _bounded_delay(seconds: float) -> int: + """Return a non-negative, bounded delay in whole seconds.""" + if not isfinite(seconds): + raise ValueError("Rate-limit delay must be finite.") + return int(max(0, min(seconds, MAX_RATE_LIMIT_DELAY))) + + +def _rate_limit_delay(headers: dict) -> int: + """Return the server-requested rate-limit delay in seconds.""" + reset = headers.get("X-RateLimit-Reset") + if reset is not None: + try: + reset_seconds = float(reset) + if reset_seconds >= MIN_UNIX_TIMESTAMP: + reset_seconds -= time.time() + return _bounded_delay(reset_seconds) + except (TypeError, ValueError): + pass + + retry_after = headers.get("Retry-After") + if retry_after is not None: + try: + return _bounded_delay(float(retry_after)) + except (TypeError, ValueError): + try: + retry_at = parsedate_to_datetime(retry_after) + return _bounded_delay(retry_at.timestamp() - time.time()) + except (TypeError, ValueError, OverflowError, OSError): + pass + + return 1 + + +def request_json(session, path: str, max_retries: int = 3): + """Make a Management API GET request with explicit authorization errors.""" + url = f"{session.base_url}{path}" + for attempt in range(max_retries + 1): + try: + response = session.http_session.get(url, timeout=30) + if response.status_code == 401: + raise SupabaseAuthenticationError( + file=__file__, + message="Invalid or expired Supabase access token.", + ) + if response.status_code == 403: + raise SupabaseInsufficientPermissionsError( + file=__file__, + message=( + "The Supabase access token cannot read the requested " + "organization data." + ), + ) + if response.status_code == 429: + delay = _rate_limit_delay(response.headers) + if attempt < max_retries: + logger.warning( + "Supabase API rate limit reached; " + f"retrying in {delay} seconds." + ) + time.sleep(delay) + continue + raise SupabaseRateLimitError( + file=__file__, + message=RATE_LIMIT_RETRIES_EXHAUSTED_MESSAGE, + ) + + response.raise_for_status() + return response.json() + except ( + SupabaseAuthenticationError, + SupabaseInsufficientPermissionsError, + SupabaseRateLimitError, + ): + raise + except (requests.exceptions.RequestException, ValueError) as error: + if attempt < max_retries: + delay = 2**attempt + msg = SUPABASE_API_REQUEST_RETRY_WARNING.format(delay=delay) + logger.warning(msg) + time.sleep(delay) + continue + raise SupabaseAPIError( + file=__file__, + original_exception=error, + message=API_REQUEST_RETRIES_EXHAUSTED_MESSAGE, + ) + + +class SupabaseService: + """Base class for Supabase services.""" + + def __init__(self, service: str, provider): + self.provider = provider + self.session = provider.session + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() + + def _get(self, path: str): + """Return decoded JSON from a Management API endpoint.""" + return request_json( + self.session, + path, + max_retries=self.audit_config.get("max_retries", 3), + ) diff --git a/prowler/providers/supabase/models.py b/prowler/providers/supabase/models.py new file mode 100644 index 0000000000..a521233d30 --- /dev/null +++ b/prowler/providers/supabase/models.py @@ -0,0 +1,42 @@ +from typing import Any + +from pydantic import BaseModel, Field + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class SupabaseSession(BaseModel): + """Supabase Management API session.""" + + access_token: str = Field(exclude=True, repr=False) + base_url: str = "https://api.supabase.com" + http_session: Any = Field(default=None, exclude=True, repr=False) + + +class SupabaseOrganization(BaseModel): + """Supabase organization visible to the authenticated account.""" + + id: str + slug: str + name: str + + +class SupabaseIdentityInfo(BaseModel): + """Supabase identity and organization scope.""" + + organizations: list[SupabaseOrganization] = Field(default_factory=list) + + +class SupabaseOutputOptions(ProviderOutputOptions): + """Customize output filenames for Supabase scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: SupabaseIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if getattr(arguments, "output_filename", None) is None: + fragment = ( + identity.organizations[0].slug if identity.organizations else "supabase" + ) + self.output_filename = f"prowler-output-{fragment}-{output_file_timestamp}" + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/supabase/services/__init__.py b/prowler/providers/supabase/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/supabase_provider.py b/prowler/providers/supabase/supabase_provider.py new file mode 100644 index 0000000000..37b3410de2 --- /dev/null +++ b/prowler/providers/supabase/supabase_provider.py @@ -0,0 +1,236 @@ +import os +from argparse import Namespace + +import requests +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.supabase.exceptions.exceptions import ( + SupabaseAPIError, + SupabaseAuthenticationError, + SupabaseCredentialsError, + SupabaseIdentityError, + SupabaseInsufficientPermissionsError, + SupabaseRateLimitError, + SupabaseSessionError, +) +from prowler.providers.supabase.lib.mutelist.mutelist import SupabaseMutelist +from prowler.providers.supabase.lib.service.service import request_json +from prowler.providers.supabase.models import ( + SupabaseIdentityInfo, + SupabaseOrganization, + SupabaseOutputOptions, + SupabaseSession, +) + + +class SupabaseProvider(Provider): + """Hosted Supabase Cloud Management API provider.""" + + _type = "supabase" + _cli_help_text = "Supabase Provider (PoC)" + sdk_only = True + audit_metadata: Audit_Metadata + + def __init__( + self, + access_token: str = None, + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + self._audit_config = config_content or load_and_validate_config_file( + self._type, config_path or default_config_file_path + ) + self._session = self.setup_session(access_token) + self._identity = self.setup_identity( + self._session, self._audit_config.get("max_retries", 3) + ) + self._fixer_config = fixer_config + self._mutelist = ( + SupabaseMutelist(mutelist_content=mutelist_content) + if mutelist_content + else SupabaseMutelist( + mutelist_path=mutelist_path or get_default_mute_file_path(self.type) + ) + ) + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self): + return self._mutelist + + @staticmethod + def setup_session(access_token: str = None) -> SupabaseSession: + """Create a Bearer-token Management API session.""" + token = access_token or os.environ.get("SUPABASE_ACCESS_TOKEN", "") + if not token: + raise SupabaseCredentialsError(file=os.path.basename(__file__)) + try: + http_session = requests.Session() + http_session.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + ) + return SupabaseSession( + access_token=token, + http_session=http_session, + ) + except Exception as error: + raise SupabaseSessionError( + file=os.path.basename(__file__), original_exception=error + ) + + @staticmethod + def setup_identity( + session: SupabaseSession, max_retries: int = 3 + ) -> SupabaseIdentityInfo: + """List organizations to validate credentials and establish scan scope.""" + try: + organizations = request_json( + session, "/v1/organizations", max_retries=max_retries + ) + return SupabaseIdentityInfo( + organizations=[ + SupabaseOrganization( + id=organization["id"], + slug=organization["slug"], + name=organization["name"], + ) + for organization in organizations + ] + ) + except ( + SupabaseAuthenticationError, + SupabaseInsufficientPermissionsError, + SupabaseRateLimitError, + SupabaseAPIError, + ): + raise + except Exception as error: + raise SupabaseIdentityError( + file=os.path.basename(__file__), original_exception=error + ) + + @classmethod + def from_cli_args(cls, arguments: Namespace, fixer_config: dict): + """Create the provider from non-secret CLI configuration.""" + return cls( + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) + + def print_credentials(self) -> None: + organizations = ( + ", ".join(organization.slug for organization in self.identity.organizations) + or "none" + ) + print_boxes( + [ + f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}", + f"Organizations: {Fore.YELLOW}{organizations}{Style.RESET_ALL}", + ], + f"{Style.BRIGHT}Using the Supabase credentials below:{Style.RESET_ALL}", + ) + + @staticmethod + def test_connection( + access_token: str = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test access to the Supabase organizations endpoint.""" + try: + session = SupabaseProvider.setup_session(access_token) + identity = SupabaseProvider.setup_identity(session, max_retries=0) + if provider_id and provider_id not in { + identifier + for organization in identity.organizations + for identifier in (organization.id, organization.slug) + }: + raise SupabaseIdentityError( + file=os.path.basename(__file__), + message=( + f"Supabase organization '{provider_id}' is not accessible " + "with the supplied token." + ), + ) + return Connection(is_connected=True) + except Exception as error: + if raise_on_exception: + raise + return Connection(is_connected=False, error=error) + + def validate_arguments(self) -> None: + return None + + def get_output_options(self, arguments, bulk_checks_metadata): + return SupabaseOutputOptions(arguments, bulk_checks_metadata, self.identity) + + def get_stdout_detail(self, _finding) -> str: + return "global" + + def get_summary_entity(self) -> tuple[str, str]: + organizations = ", ".join( + f"{organization.name} ({organization.slug})" + for organization in self.identity.organizations + ) + return "Organization", organizations or "No organizations" + + def get_finding_output_data(self, check_output) -> dict: + return { + "auth_method": "personal_access_token", + "account_uid": check_output.organization_slug, + "account_name": check_output.organization_name, + "resource_name": check_output.resource_name, + "resource_uid": check_output.resource_id, + "region": "global", + } + + def get_html_assessment_summary(self) -> str: + organizations = ( + ", ".join(organization.slug for organization in self.identity.organizations) + or "none" + ) + return f""" +