mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(sdk): add Supabase provider foundation
This commit is contained in:
@@ -83,6 +83,7 @@ class Provider(str, Enum):
|
||||
LINODE = "linode"
|
||||
HUAWEICLOUD = "huaweicloud"
|
||||
E2ENETWORKS = "e2enetworks"
|
||||
SUPABASE = "supabase"
|
||||
|
||||
|
||||
# Compliance
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)",
|
||||
)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"""
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Supabase Assessment Summary</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item"><b>Organizations:</b> {organizations}</li>
|
||||
<li class="list-group-item"><b>Authentication:</b> Personal Access Token</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
@@ -0,0 +1,34 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from prowler.providers.supabase.models import (
|
||||
SupabaseIdentityInfo,
|
||||
SupabaseOrganization,
|
||||
SupabaseSession,
|
||||
)
|
||||
|
||||
ACCESS_TOKEN = "sbp_test_token"
|
||||
ORGANIZATION_ID = "org-id"
|
||||
ORGANIZATION_NAME = "Test Organization"
|
||||
ORGANIZATION_SLUG = "test-organization"
|
||||
USER_ID = "user-id"
|
||||
|
||||
|
||||
def set_mocked_supabase_provider():
|
||||
provider = MagicMock()
|
||||
provider.type = "supabase"
|
||||
provider.session = SupabaseSession(
|
||||
access_token=ACCESS_TOKEN,
|
||||
http_session=MagicMock(),
|
||||
)
|
||||
provider.identity = SupabaseIdentityInfo(
|
||||
organizations=[
|
||||
SupabaseOrganization(
|
||||
id=ORGANIZATION_ID,
|
||||
name=ORGANIZATION_NAME,
|
||||
slug=ORGANIZATION_SLUG,
|
||||
)
|
||||
]
|
||||
)
|
||||
provider.audit_config = {"max_retries": 0}
|
||||
provider.fixer_config = {}
|
||||
return provider
|
||||
@@ -0,0 +1,40 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.supabase.lib.mutelist.mutelist import SupabaseMutelist
|
||||
from tests.providers.supabase.supabase_fixtures import (
|
||||
ORGANIZATION_SLUG,
|
||||
USER_ID,
|
||||
)
|
||||
|
||||
|
||||
class TestSupabaseMutelist:
|
||||
@pytest.mark.parametrize(
|
||||
("resource_id", "expected"),
|
||||
[(USER_ID, True), ("another-user", False)],
|
||||
)
|
||||
def test_matches_organization_check_and_member(self, resource_id, expected):
|
||||
mutelist = SupabaseMutelist(
|
||||
mutelist_content={
|
||||
"Accounts": {
|
||||
ORGANIZATION_SLUG: {
|
||||
"Checks": {
|
||||
"organizations_member_mfa_enabled": {
|
||||
"Regions": ["global"],
|
||||
"Resources": [USER_ID],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
finding = MagicMock(
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
resource_id=resource_id,
|
||||
resource_name=f"member {resource_id}",
|
||||
resource_tags=[],
|
||||
)
|
||||
finding.check_metadata.CheckID = "organizations_member_mfa_enabled"
|
||||
|
||||
assert mutelist.is_finding_muted(finding) is expected
|
||||
@@ -0,0 +1,216 @@
|
||||
import os
|
||||
from argparse import Namespace
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.config.config import Provider as ProviderName
|
||||
from prowler.lib.cli.parser import ProwlerArgumentParser
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.supabase.exceptions.exceptions import (
|
||||
SupabaseAuthenticationError,
|
||||
SupabaseCredentialsError,
|
||||
SupabaseInsufficientPermissionsError,
|
||||
SupabaseRateLimitError,
|
||||
)
|
||||
from prowler.providers.supabase.models import SupabaseOrganization, SupabaseSession
|
||||
from prowler.providers.supabase.supabase_provider import SupabaseProvider
|
||||
from tests.providers.supabase.supabase_fixtures import (
|
||||
ACCESS_TOKEN,
|
||||
ORGANIZATION_ID,
|
||||
ORGANIZATION_NAME,
|
||||
ORGANIZATION_SLUG,
|
||||
)
|
||||
|
||||
|
||||
class TestSupabaseProvider:
|
||||
def test_setup_session_uses_environment_token(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"SUPABASE_ACCESS_TOKEN": ACCESS_TOKEN}, clear=True
|
||||
):
|
||||
session = SupabaseProvider.setup_session()
|
||||
|
||||
assert session.access_token == ACCESS_TOKEN
|
||||
assert session.http_session.headers["Authorization"] == f"Bearer {ACCESS_TOKEN}"
|
||||
|
||||
def test_setup_session_requires_environment_token(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with pytest.raises(SupabaseCredentialsError):
|
||||
SupabaseProvider.setup_session()
|
||||
|
||||
def test_access_token_is_not_serialized_or_represented(self):
|
||||
session = SupabaseSession(access_token=ACCESS_TOKEN)
|
||||
|
||||
assert ACCESS_TOKEN not in repr(session)
|
||||
assert ACCESS_TOKEN not in str(session)
|
||||
assert ACCESS_TOKEN not in session.model_dump_json()
|
||||
assert "access_token" not in session.model_dump()
|
||||
|
||||
def test_setup_identity_lists_organizations_without_member_pii(self):
|
||||
session = SupabaseSession(
|
||||
access_token=ACCESS_TOKEN,
|
||||
http_session=mock.MagicMock(),
|
||||
)
|
||||
response = mock.MagicMock(status_code=200)
|
||||
response.json.return_value = [
|
||||
{
|
||||
"id": ORGANIZATION_ID,
|
||||
"name": ORGANIZATION_NAME,
|
||||
"slug": ORGANIZATION_SLUG,
|
||||
}
|
||||
]
|
||||
session.http_session.get.return_value = response
|
||||
|
||||
identity = SupabaseProvider.setup_identity(session, max_retries=0)
|
||||
|
||||
assert identity.organizations[0].slug == ORGANIZATION_SLUG
|
||||
session.http_session.get.assert_called_once_with(
|
||||
"https://api.supabase.com/v1/organizations", timeout=30
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "exception"),
|
||||
[
|
||||
(401, SupabaseAuthenticationError),
|
||||
(403, SupabaseInsufficientPermissionsError),
|
||||
(429, SupabaseRateLimitError),
|
||||
],
|
||||
)
|
||||
def test_setup_identity_preserves_management_api_errors(
|
||||
self, status_code, exception
|
||||
):
|
||||
session = SupabaseSession(
|
||||
access_token=ACCESS_TOKEN,
|
||||
http_session=mock.MagicMock(),
|
||||
)
|
||||
session.http_session.get.return_value = mock.MagicMock(
|
||||
status_code=status_code,
|
||||
headers={"X-RateLimit-Reset": "0"},
|
||||
)
|
||||
|
||||
with pytest.raises(exception):
|
||||
SupabaseProvider.setup_identity(session, max_retries=0)
|
||||
|
||||
def test_from_cli_args_uses_environment_only(self):
|
||||
arguments = Namespace(
|
||||
config_file=None,
|
||||
mutelist_file=None,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.dict(
|
||||
os.environ, {"SUPABASE_ACCESS_TOKEN": ACCESS_TOKEN}, clear=True
|
||||
),
|
||||
mock.patch.object(
|
||||
SupabaseProvider,
|
||||
"setup_identity",
|
||||
return_value=mock.MagicMock(organizations=[]),
|
||||
),
|
||||
):
|
||||
provider = SupabaseProvider.from_cli_args(arguments, fixer_config={})
|
||||
|
||||
assert provider.type == "supabase"
|
||||
assert not hasattr(arguments, "supabase_access_token")
|
||||
|
||||
def test_parser_discovers_supabase_without_secret_argument(self):
|
||||
arguments = ProwlerArgumentParser().parse(
|
||||
["prowler", "supabase", "--list-checks"]
|
||||
)
|
||||
|
||||
assert arguments.provider == "supabase"
|
||||
assert not hasattr(arguments, "supabase_access_token")
|
||||
|
||||
def test_provider_registry_and_class_resolution(self):
|
||||
assert ProviderName.SUPABASE.value == "supabase"
|
||||
assert Provider.get_class("supabase") is SupabaseProvider
|
||||
assert SupabaseProvider.sdk_only is True
|
||||
|
||||
|
||||
class TestSupabaseProviderOutputHooks:
|
||||
def test_finding_output_uses_organization_and_member_ids(self):
|
||||
provider = SupabaseProvider.__new__(SupabaseProvider)
|
||||
provider._identity = mock.MagicMock(organizations=[])
|
||||
check_output = mock.MagicMock(
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
resource_name="member user-id",
|
||||
resource_id="user-id",
|
||||
)
|
||||
|
||||
output = provider.get_finding_output_data(check_output)
|
||||
|
||||
assert output == {
|
||||
"auth_method": "personal_access_token",
|
||||
"account_uid": ORGANIZATION_SLUG,
|
||||
"account_name": ORGANIZATION_NAME,
|
||||
"resource_name": "member user-id",
|
||||
"resource_uid": "user-id",
|
||||
"region": "global",
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output_filename", "expected"),
|
||||
[
|
||||
(None, f"prowler-output-{ORGANIZATION_SLUG}-"),
|
||||
("custom-report", "custom-report"),
|
||||
],
|
||||
)
|
||||
def test_output_options_use_organization_slug_or_explicit_name(
|
||||
self, output_filename, expected
|
||||
):
|
||||
provider = SupabaseProvider.__new__(SupabaseProvider)
|
||||
provider._identity = mock.MagicMock(
|
||||
organizations=[
|
||||
SupabaseOrganization(
|
||||
id=ORGANIZATION_ID,
|
||||
name=ORGANIZATION_NAME,
|
||||
slug=ORGANIZATION_SLUG,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
output_options = provider.get_output_options(
|
||||
Namespace(output_filename=output_filename), {}
|
||||
)
|
||||
|
||||
if output_filename:
|
||||
assert output_options.output_filename == expected
|
||||
else:
|
||||
assert output_options.output_filename.startswith(expected)
|
||||
|
||||
def test_html_assessment_summary_uses_supabase_hook(self):
|
||||
provider = SupabaseProvider.__new__(SupabaseProvider)
|
||||
provider._identity = mock.MagicMock(
|
||||
organizations=[
|
||||
SupabaseOrganization(
|
||||
id=ORGANIZATION_ID,
|
||||
name=ORGANIZATION_NAME,
|
||||
slug=ORGANIZATION_SLUG,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
summary = HTML.get_assessment_summary(provider)
|
||||
|
||||
assert "Supabase Assessment Summary" in summary
|
||||
assert f"<b>Organizations:</b> {ORGANIZATION_SLUG}" in summary
|
||||
assert "<b>Authentication:</b> Personal Access Token" in summary
|
||||
|
||||
def test_summary_and_stdout_hooks_are_global(self):
|
||||
provider = SupabaseProvider.__new__(SupabaseProvider)
|
||||
provider._identity = mock.MagicMock(
|
||||
organizations=[
|
||||
SupabaseOrganization(
|
||||
id=ORGANIZATION_ID,
|
||||
name=ORGANIZATION_NAME,
|
||||
slug=ORGANIZATION_SLUG,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert provider.get_summary_entity() == (
|
||||
"Organization",
|
||||
f"{ORGANIZATION_NAME} ({ORGANIZATION_SLUG})",
|
||||
)
|
||||
assert provider.get_stdout_detail(mock.MagicMock()) == "global"
|
||||
Reference in New Issue
Block a user