diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml
index 2492fe3d49..73a7a3fb94 100644
--- a/.github/workflows/sdk-tests.yml
+++ b/.github/workflows/sdk-tests.yml
@@ -60,6 +60,7 @@ jobs:
cli.codecov.io:443
auth.docker.io:443
api.vercel.com:443
+ api.supabase.com:443
api.atlassian.com:443
aka.ms:443
@@ -544,6 +545,30 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
+ # Supabase Provider
+ - name: Check if Supabase files changed
+ if: steps.check-changes.outputs.any_changed == 'true'
+ id: changed-supabase
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
+ with:
+ files: |
+ ./prowler/**/supabase/**
+ ./tests/**/supabase/**
+ ./uv.lock
+
+ - name: Run Supabase tests
+ if: steps.changed-supabase.outputs.any_changed == 'true'
+ run: uv run pytest -n auto --cov=./prowler/providers/supabase --cov-report=xml:supabase_coverage.xml tests/providers/supabase
+
+ - name: Upload Supabase coverage to Codecov
+ if: steps.changed-supabase.outputs.any_changed == 'true'
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ with:
+ flags: prowler-py${{ matrix.python-version }}-supabase
+ files: ./supabase_coverage.xml
+
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
diff --git a/README.md b/README.md
index 2851101f05..c54cb2e2ff 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
+| Supabase | 25 | 5 | 0 | 8 | Official | CLI |
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
diff --git a/action.yml b/action.yml
index 2be0649402..96cdd433a8 100644
--- a/action.yml
+++ b/action.yml
@@ -226,6 +226,7 @@ runs:
openstack) provider_name="OpenStack" ;;
oraclecloud) provider_name="Oracle Cloud" ;;
vercel) provider_name="Vercel" ;;
+ supabase) provider_name="Supabase" ;;
*) provider_name="${INPUT_PROVIDER^}" ;;
esac
diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx
index c44f82c5aa..5f624e0e5e 100644
--- a/docs/developer-guide/configurable-checks.mdx
+++ b/docs/developer-guide/configurable-checks.mdx
@@ -221,6 +221,15 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
| `max_owner_percentage` | `1..50` % | guidance recommends ≤25% |
| `max_owners` | `1..1000` | absolute cap, overrides percentage for large teams |
+### Supabase
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `max_retries` | `0..10` | Management API retries |
+| `postgrest_max_rows` | `1..1000000` rows | Supabase default is 1000; lower is safer |
+| `auth_password_min_length` | `6..128` characters | Supabase default is 6; 8 or more recommended |
+| `auth_otp_expiry_seconds` | `60..86400` seconds | Supabase advisor flags expiries above one hour |
+
These bounds live in the provider schemas under `prowler/config/schema/`; each field's `Field(ge=..., le=...)` (or `field_validator`) is the source of truth and the descriptions there carry the full rationale.
This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements.
diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx
index 8c7ef9a90e..d72f3f56af 100644
--- a/docs/user-guide/cli/tutorials/configuration_file.mdx
+++ b/docs/user-guide/cli/tutorials/configuration_file.mdx
@@ -296,6 +296,16 @@ The following list includes all the Vercel checks with configurable variables th
| `team_member_role_least_privilege` | `max_owners` | Integer | `3` |
| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer | `30` |
+## Supabase
+
+The following list includes all the Supabase checks with configurable variables that can be changed in the configuration YAML file:
+
+| Check Name | Value | Type | Default |
+|----------------------------------------|----------------------------|---------|---------|
+| `projects_postgrest_max_rows_limited` | `postgrest_max_rows` | Integer | `1000` |
+| `auth_password_min_length` | `auth_password_min_length` | Integer | `8` |
+| `auth_otp_expiry_within_threshold` | `auth_otp_expiry_seconds` | Integer | `3600` |
+
## Okta
### Configurable Checks
@@ -890,6 +900,20 @@ github:
# github.repository_inactive_not_archived
inactive_not_archived_days_threshold: 180
+# Supabase Configuration
+supabase:
+ # Maximum retries for Management API requests (0 disables retries)
+ max_retries: 3
+ # supabase.projects_postgrest_max_rows_limited
+ # Maximum rows a single Data API request may return
+ postgrest_max_rows: 1000
+ # supabase.auth_password_min_length
+ # Minimum password length Supabase Auth must enforce
+ auth_password_min_length: 8
+ # supabase.auth_otp_expiry_within_threshold
+ # Maximum validity of email one-time passcodes, in seconds
+ auth_otp_expiry_seconds: 3600
+
# Vercel Configuration
vercel:
# vercel.deployment_production_uses_stable_target
diff --git a/docs/user-guide/providers/supabase/authentication.mdx b/docs/user-guide/providers/supabase/authentication.mdx
index b7769cffe4..41e9e57b84 100644
--- a/docs/user-guide/providers/supabase/authentication.mdx
+++ b/docs/user-guide/providers/supabase/authentication.mdx
@@ -2,41 +2,34 @@
title: "Supabase Authentication in Prowler"
---
-Prowler for Supabase authenticates to the hosted Supabase Management API using a **Personal Access Token** (PAT). Prowler reads the token exclusively from `SUPABASE_ACCESS_TOKEN`; there is no credential CLI argument, and Prowler does not read Supabase CLI state files.
+Prowler authenticates to the Supabase Management API with a Personal Access Token (PAT) read from the `SUPABASE_ACCESS_TOKEN` environment variable. The token is never accepted on the command line, so it does not end up in shell history or process listings.
-## Personal Access Token Security
+## Create a Personal Access Token
-Supabase PATs inherit the privileges of the user account that created them. Use a dedicated least-privilege or read-only organization account where available, set a suitable token expiry, store the token in a secret manager, and rotate it regularly.
-
-
-A PAT is not independently scoped to read-only Management API access. Compromise of the token grants the same Management API privileges as its issuing user account.
-
-
-## Required Access
-
-The issuing account must be able to call:
-
-- [`GET /v1/organizations`](https://supabase.com/docs/reference/api/v1-list-all-organizations)
-- [`GET /v1/organizations/{slug}/members`](https://supabase.com/docs/reference/api/v1-list-organization-members)
-
-An invalid token returns `401`, insufficient organization permissions return `403`, and rate limiting returns `429`. Prowler treats all three as scan errors rather than compliant results. Supabase applies a standard limit of approximately 120 Management API requests per minute for each user and scope; Prowler uses the response rate-limit reset headers before retrying.
-
-## Configure Authentication
-
-Create a PAT from [Supabase Account Tokens](https://supabase.com/dashboard/account/tokens), then export it:
+1. Open the [Supabase Dashboard](https://supabase.com/dashboard/account/tokens) and go to **Account** > **Access Tokens**.
+2. Click **Generate new token**, give it a name such as `prowler-read-only`, and copy the value once.
+3. Export it before running Prowler:
```bash
-export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
+export SUPABASE_ACCESS_TOKEN="sbp_..."
prowler supabase
```
-For CI/CD, configure `SUPABASE_ACCESS_TOKEN` as a masked secret environment variable. Do not place it in command arguments, repository files, or job logs.
+## Required Access
-## Out of Scope
+Personal Access Tokens inherit the permissions of the account that created them. The account must be a member of every organization you want to audit; Prowler reads organizations, members, projects and project configuration and never writes.
-This proof of concept does not support:
+
+Create the token from a dedicated organization member with the **Read-only** role where your plan supports it, enable MFA on that account, and rotate the token periodically. Prowler does not use the project `anon` or `service_role` keys.
+
-- OAuth 2.0 Authorization Code with Proof Key for Code Exchange (PKCE)
-- Direct PostgreSQL authentication
-- Self-hosted Supabase deployments
-- Supabase CLI credential or state files
+## Troubleshooting
+
+| Error | Cause |
+|---|---|
+| `SupabaseCredentialsError` | `SUPABASE_ACCESS_TOKEN` is not set |
+| `SupabaseAuthenticationError` | The token is invalid, expired or revoked |
+| `SupabaseInsufficientPermissionsError` | The account cannot read the requested organization or project |
+| `SupabaseRateLimitError` | The Management API rate limit stayed active after the configured retries |
+
+Prowler honours `X-RateLimit-Reset` and `Retry-After` headers and retries requests up to `max_retries` times (configurable in the audit configuration).
diff --git a/docs/user-guide/providers/supabase/getting-started-supabase.mdx b/docs/user-guide/providers/supabase/getting-started-supabase.mdx
index 7b2188751f..267c902ef2 100644
--- a/docs/user-guide/providers/supabase/getting-started-supabase.mdx
+++ b/docs/user-guide/providers/supabase/getting-started-supabase.mdx
@@ -2,17 +2,17 @@
title: "Getting Started With Supabase on Prowler"
---
-Prowler for Supabase scans hosted Supabase Cloud organizations through the Management API. This proof of concept checks whether each organization member has multi-factor authentication (MFA) enabled.
+Prowler for Supabase scans hosted Supabase Cloud organizations and projects through the Management API. It covers organization membership, project platform settings, Supabase Auth configuration, Storage buckets and the Postgres security posture reported by the Supabase security advisor, without executing SQL against your databases.
-
-This proof of concept supports Prowler SDK and Prowler CLI only. It does not support onboarding through Prowler Cloud, Prowler Private Cloud, or Prowler Local Server; self-hosted Supabase; direct PostgreSQL access; or OAuth.
-
+
+Supabase is supported in Prowler SDK and Prowler CLI. Self-hosted Supabase and direct PostgreSQL access are not covered.
+
## Prerequisites
1. A hosted Supabase Cloud account with access to at least one organization.
-2. A Supabase Personal Access Token (PAT) for an account that can list organizations and organization members.
-3. A dedicated least-privilege or read-only organization account where your Supabase plan and organization roles allow one.
+2. A Supabase Personal Access Token (PAT) for an account that can read the organizations and projects you want to audit.
+3. A dedicated read-only organization member for Prowler where your Supabase plan and organization roles allow one.
See [Supabase Authentication in Prowler](./authentication) for PAT setup and security guidance.
@@ -25,16 +25,32 @@ export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
prowler supabase
```
-To run only the organization member MFA check:
+Limit the scan to specific projects by reference or name:
```bash
-prowler supabase --check organizations_member_mfa_enabled
+prowler supabase --project abcdefghijklmnopqrst storefront
```
-## Checks Shipped
+Run a subset of checks:
-| Check ID | Severity | Description |
+```bash
+prowler supabase --check projects_ssl_enforcement_enabled database_rls_enabled_on_public_tables
+```
+
+## Services and Checks
+
+| Service | What is audited | Management API endpoints |
|---|---|---|
-| `organizations_member_mfa_enabled` | High | Creates one finding per organization member and fails members whose Supabase account does not have MFA enabled. |
+| `organizations` | Member MFA | `/v1/organizations/{slug}/members` |
+| `projects` | SSL enforcement, network restrictions, Data API max rows, Point-in-Time Recovery, legacy API keys | `/v1/projects`, `/v1/projects/{ref}/ssl-enforcement`, `/v1/projects/{ref}/network-restrictions`, `/v1/projects/{ref}/postgrest`, `/v1/projects/{ref}/database/backups`, `/v1/projects/{ref}/api-keys/legacy` |
+| `auth` | Leaked password protection, password length, MFA enrollment, OTP expiry, refresh token rotation, CAPTCHA, email confirmation, anonymous sign-ins | `/v1/projects/{ref}/config/auth` |
+| `storage` | Public buckets | `/v1/projects/{ref}/storage/buckets` |
+| `database` | Row Level Security, SECURITY DEFINER views, mutable `search_path`, exposed `auth.users`, leaked service keys, extensions in `public`, policies on user metadata, vulnerable Postgres versions | `/v1/projects/{ref}/advisors/security` |
-The finding resource UID is the Management API `user_id`. Prowler does not include member email addresses in findings or normal logs.
+Project findings carry the project reference as the account scope and the project region as the finding region; organization findings use the organization slug and the `global` region.
+
+Thresholds for the Data API row limit, the minimum password length and the OTP expiry are configurable; see the [configuration file](/user-guide/cli/tutorials/configuration_file#supabase).
+
+## Data Handling
+
+Prowler stores only the settings each check evaluates. Member email addresses, JWT secrets, provider client secrets, SMTP passwords and hook secrets returned by the Management API are discarded and never appear in findings or logs. Projects that are paused or whose advisor cannot run are skipped rather than reported as failures.
diff --git a/prowler/changelog.d/supabase-provider.added.md b/prowler/changelog.d/supabase-provider.added.md
index 36be668051..75dec8ac06 100644
--- a/prowler/changelog.d/supabase-provider.added.md
+++ b/prowler/changelog.d/supabase-provider.added.md
@@ -1 +1 @@
-Supabase provider with the `organizations_member_mfa_enabled` check
+Supabase provider with organization, project, auth, storage and database checks for the hosted Supabase platform
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index a25b87ebb5..4f6f92c5a5 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -763,6 +763,15 @@ cloudflare:
supabase:
# Maximum retries for Management API requests (0 disables retries)
max_retries: 3
+ # supabase.projects_postgrest_max_rows_limited
+ # Maximum rows a single Data API request may return
+ postgrest_max_rows: 1000
+ # supabase.auth_password_min_length
+ # Minimum password length Supabase Auth must enforce
+ auth_password_min_length: 8
+ # supabase.auth_otp_expiry_within_threshold
+ # Maximum validity of email one-time passcodes, in seconds
+ auth_otp_expiry_seconds: 3600
# Vercel Configuration
vercel:
diff --git a/prowler/config/schema/supabase.py b/prowler/config/schema/supabase.py
index 6b81526adb..6e0df79a0e 100644
--- a/prowler/config/schema/supabase.py
+++ b/prowler/config/schema/supabase.py
@@ -14,3 +14,21 @@ class SupabaseProviderConfig(ProviderConfigBase):
le=10,
description="Maximum retries for Supabase API requests. Range: 0..10.",
)
+ postgrest_max_rows: Optional[int] = Field(
+ default=None,
+ ge=1,
+ le=1000000,
+ description="Maximum rows a Data API request may return. Range: 1..1000000.",
+ )
+ auth_password_min_length: Optional[int] = Field(
+ default=None,
+ ge=6,
+ le=128,
+ description="Minimum password length Supabase Auth must enforce. Range: 6..128.",
+ )
+ auth_otp_expiry_seconds: Optional[int] = Field(
+ default=None,
+ ge=60,
+ le=86400,
+ description="Maximum email OTP validity in seconds. Range: 60..86400.",
+ )
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index 5a3780b201..7a69be6274 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -1451,12 +1451,15 @@ class CheckReportVercel(Check_Report):
@dataclass
class CheckReportSupabase(Check_Report):
- """Contains a Supabase organization member finding."""
+ """Contains a Supabase finding for an organization, project or project resource."""
resource_name: str
resource_id: str
organization_slug: str
organization_name: str
+ project_ref: str
+ project_name: str
+ project_region: str
def __init__(self, metadata: Dict, resource: Any) -> None:
super().__init__(metadata, resource)
@@ -1464,11 +1467,14 @@ class CheckReportSupabase(Check_Report):
self.resource_id = getattr(resource, "id", "")
self.organization_slug = getattr(resource, "organization_slug", "")
self.organization_name = getattr(resource, "organization_name", "")
+ self.project_ref = getattr(resource, "project_ref", "")
+ self.project_name = getattr(resource, "project_name", "")
+ self.project_region = getattr(resource, "region", "") or ""
@property
def region(self) -> str:
- """Supabase organization membership is global."""
- return "global"
+ """Project resources report the Supabase region; organization resources are global."""
+ return self.project_region or "global"
@dataclass
diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py
index 68c08e8f6b..fb490f1648 100644
--- a/prowler/lib/cli/parser.py
+++ b/prowler/lib/cli/parser.py
@@ -54,6 +54,7 @@ class ProwlerArgumentParser:
"stackit",
"linode",
"huaweicloud",
+ "supabase",
}
all_providers = set(Provider.get_available_providers())
new_providers = sorted(all_providers - known_providers)
@@ -76,10 +77,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
- usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...",
+ usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,supabase,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...",
epilog=f"""
Available Cloud Providers:
- {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks{extra_providers_csv}}}
+ {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,supabase,linode,huaweicloud,e2enetworks{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -100,6 +101,7 @@ Available Cloud Providers:
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
vercel Vercel Provider
+ supabase Supabase Provider
linode Linode Provider
huaweicloud Huawei Cloud Provider
e2enetworks E2E Networks Provider{extra_providers_text}
diff --git a/prowler/providers/supabase/lib/arguments/arguments.py b/prowler/providers/supabase/lib/arguments/arguments.py
index 29e67f0409..f3e9fbceb9 100644
--- a/prowler/providers/supabase/lib/arguments/arguments.py
+++ b/prowler/providers/supabase/lib/arguments/arguments.py
@@ -1,7 +1,18 @@
def init_parser(self):
"""Initialize the Supabase provider CLI parser."""
- self.subparsers.add_parser(
+ supabase_parser = self.subparsers.add_parser(
"supabase",
parents=[self.common_providers_parser],
- help="Supabase Provider (PoC)",
+ help="Supabase Provider",
+ )
+
+ # Scope
+ scope_group = supabase_parser.add_argument_group("Scope")
+ scope_group.add_argument(
+ "--project",
+ "--projects",
+ nargs="*",
+ default=None,
+ metavar="PROJECT",
+ help="Filter the scan to specific Supabase project refs or names.",
)
diff --git a/prowler/providers/supabase/lib/mutelist/mutelist.py b/prowler/providers/supabase/lib/mutelist/mutelist.py
index 1a7ad6422f..36cb92592b 100644
--- a/prowler/providers/supabase/lib/mutelist/mutelist.py
+++ b/prowler/providers/supabase/lib/mutelist/mutelist.py
@@ -10,7 +10,7 @@ class SupabaseMutelist(Mutelist):
return self.is_muted(
finding.organization_slug,
finding.check_metadata.CheckID,
- "global",
+ finding.region,
finding.resource_id or finding.resource_name,
unroll_dict(unroll_tags(finding.resource_tags)),
)
diff --git a/prowler/providers/supabase/lib/service/service.py b/prowler/providers/supabase/lib/service/service.py
index a67f896ae3..ea5a878398 100644
--- a/prowler/providers/supabase/lib/service/service.py
+++ b/prowler/providers/supabase/lib/service/service.py
@@ -1,8 +1,11 @@
import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
from email.utils import parsedate_to_datetime
from math import isfinite
+from typing import Callable, Iterable
import requests
+from pydantic import BaseModel
from prowler.lib.logger import logger
from prowler.providers.supabase.exceptions.exceptions import (
@@ -13,6 +16,7 @@ from prowler.providers.supabase.exceptions.exceptions import (
)
MAX_RATE_LIMIT_DELAY = 3600
+MAX_WORKERS = 10
MIN_UNIX_TIMESTAMP = 1_000_000_000
RATE_LIMIT_RETRIES_EXHAUSTED_MESSAGE = (
"Supabase API rate limit remained active after retries."
@@ -113,6 +117,19 @@ def request_json(session, path: str, max_retries: int = 3):
)
+class SupabaseProjectScope(BaseModel):
+ """Project identity shared by every project-level Supabase resource."""
+
+ id: str
+ name: str
+ project_ref: str
+ project_name: str
+ organization_slug: str
+ organization_name: str
+ region: str = ""
+ status: str = ""
+
+
class SupabaseService:
"""Base class for Supabase services."""
@@ -130,3 +147,65 @@ class SupabaseService:
path,
max_retries=self.audit_config.get("max_retries", 3),
)
+
+ def _get_optional(self, path: str, label: str):
+ """Return decoded JSON, or ``None`` when the endpoint is unavailable.
+
+ Authentication, permission and rate-limit errors still propagate so a
+ scan never silently reports an empty estate; any other API failure
+ (paused project, feature not provisioned, transient error) is logged
+ and the attribute is left unknown.
+ """
+ try:
+ return self._get(path)
+ except (
+ SupabaseAuthenticationError,
+ SupabaseInsufficientPermissionsError,
+ SupabaseRateLimitError,
+ ):
+ raise
+ except SupabaseAPIError as error:
+ logger.warning(f"{self.service} - {label} unavailable at {path}: {error}")
+ return None
+
+ def _list_projects(self) -> list[SupabaseProjectScope]:
+ """List projects in scope, honouring the ``--project`` filter."""
+ filter_projects = set(getattr(self.provider, "filter_projects", []) or [])
+ projects = []
+ for raw in self._get("/v1/projects") or []:
+ ref = raw.get("ref") or raw.get("id") or ""
+ name = raw.get("name", "")
+ if not ref:
+ continue
+ if (
+ filter_projects
+ and ref not in filter_projects
+ and name not in filter_projects
+ ):
+ continue
+ organization_slug = raw.get("organization_slug") or raw.get(
+ "organization_id", ""
+ )
+ projects.append(
+ SupabaseProjectScope(
+ id=ref,
+ name=name,
+ project_ref=ref,
+ project_name=name,
+ organization_slug=organization_slug,
+ organization_name=self.provider.identity.organization_name(
+ organization_slug
+ ),
+ region=raw.get("region", "") or "",
+ status=raw.get("status", "") or "",
+ )
+ )
+ logger.info(f"{self.service} - Found {len(projects)} project(s)")
+ return projects
+
+ def _threading_call(self, call: Callable, items: Iterable) -> None:
+ """Run ``call`` for every item on a bounded thread pool."""
+ with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
+ futures = {executor.submit(call, item): item for item in items}
+ for future in as_completed(futures):
+ future.result()
diff --git a/prowler/providers/supabase/models.py b/prowler/providers/supabase/models.py
index a521233d30..247da14e76 100644
--- a/prowler/providers/supabase/models.py
+++ b/prowler/providers/supabase/models.py
@@ -27,6 +27,13 @@ class SupabaseIdentityInfo(BaseModel):
organizations: list[SupabaseOrganization] = Field(default_factory=list)
+ def organization_name(self, slug: str) -> str:
+ """Return the display name of an organization in scope, or its slug."""
+ for organization in self.organizations:
+ if organization.slug == slug:
+ return organization.name
+ return slug
+
class SupabaseOutputOptions(ProviderOutputOptions):
"""Customize output filenames for Supabase scans."""
diff --git a/prowler/providers/supabase/services/auth/__init__.py b/prowler/providers/supabase/services/auth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/__init__.py b/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.metadata.json b/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.metadata.json
new file mode 100644
index 0000000000..ade724bd98
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_anonymous_sign_ins_disabled",
+ "CheckTitle": "Supabase Auth has anonymous sign-ins disabled",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **anonymous sign-ins**. Anonymous users are issued JWTs with the `authenticated` role, so every policy written for `authenticated` applies to them unless it also checks `is_anonymous`.",
+ "Risk": "Policies that only check `auth.role() = 'authenticated'` silently **grant anonymous visitors the same data access as real users**, and anonymous accounts can be created in bulk to abuse quotas.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/auth-anonymous"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sign In / Providers\n3. Disable Allow anonymous sign-ins, or keep it on and add `(auth.jwt() ->> 'is_anonymous')::boolean is false` to sensitive policies",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n external_anonymous_users_enabled = false\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Disable anonymous sign-ins unless the product needs them; when enabled, exclude anonymous users in row level security policies.",
+ "Url": "https://hub.prowler.com/check/auth_anonymous_sign_ins_disabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.py b/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.py
new file mode 100644
index 0000000000..0e105bd042
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled.py
@@ -0,0 +1,29 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_anonymous_sign_ins_disabled(Check):
+ """Check that anonymous sign-ins are disabled unless deliberately used.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.external_anonymous_users_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if not config.external_anonymous_users_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Project {config.name} has anonymous sign-ins disabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} allows anonymous sign-ins, which receive the authenticated role."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_captcha_enabled/__init__.py b/prowler/providers/supabase/services/auth/auth_captcha_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.metadata.json b/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.metadata.json
new file mode 100644
index 0000000000..7b4d30e833
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_captcha_enabled",
+ "CheckTitle": "Supabase Auth protects sign-up and sign-in with CAPTCHA",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **CAPTCHA protection** (hCaptcha or Turnstile) on sign-up, sign-in, magic link and password recovery requests.",
+ "Risk": "Auth endpoints are public and keyed only by the anon key, so bots can **mass register accounts**, run credential stuffing and drive up email and SMS costs without a human challenge.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/auth-captcha"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Attack Protection\n3. Enable Captcha protection and configure the hCaptcha or Turnstile secret\n4. Add the captcha token to client sign-up and sign-in calls",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n security_captcha_enabled = true\n security_captcha_provider = \"turnstile\"\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable CAPTCHA on Auth endpoints that accept anonymous traffic and keep rate limits tight.",
+ "Url": "https://hub.prowler.com/check/auth_captcha_enabled"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.py b/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.py
new file mode 100644
index 0000000000..d0b25c645c
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled.py
@@ -0,0 +1,29 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_captcha_enabled(Check):
+ """Check that bot protection is enabled on Supabase Auth endpoints.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.security_captcha_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.security_captcha_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Project {config.name} protects sign-up and sign-in with CAPTCHA."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} exposes sign-up and sign-in without CAPTCHA protection."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_client.py b/prowler/providers/supabase/services/auth/auth_client.py
new file mode 100644
index 0000000000..331357a54a
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.supabase.services.auth.auth_service import Auth
+
+auth_client = Auth(Provider.get_global_provider())
diff --git a/prowler/providers/supabase/services/auth/auth_email_confirmation_required/__init__.py b/prowler/providers/supabase/services/auth/auth_email_confirmation_required/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.metadata.json b/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.metadata.json
new file mode 100644
index 0000000000..b6381895c4
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_email_confirmation_required",
+ "CheckTitle": "Supabase Auth requires email confirmation for new sign-ups",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **email auto-confirmation**. When enabled, any sign-up is immediately active without proving control of the mailbox.",
+ "Risk": "Unverified sign-ups let attackers **claim other people's email addresses**, create unlimited throwaway accounts and reach `authenticated` row level security policies without owning a real identity.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/auth-email"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sign In / Providers > Email\n3. Enable Confirm email",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n mailer_autoconfirm = false\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Require confirmed email addresses; if a frictionless flow is needed, gate sensitive policies on `email_confirmed_at`.",
+ "Url": "https://hub.prowler.com/check/auth_email_confirmation_required"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.py b/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.py
new file mode 100644
index 0000000000..b549f01af2
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_email_confirmation_required(Check):
+ """Check that new email sign-ups must confirm their address.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.mailer_autoconfirm is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if not config.mailer_autoconfirm:
+ report.status = "PASS"
+ report.status_extended = f"Project {config.name} requires email confirmation before a sign-up is active."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} auto-confirms email sign-ups without verifying the address."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/__init__.py b/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.metadata.json b/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.metadata.json
new file mode 100644
index 0000000000..a82e65a74c
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_leaked_password_protection_enabled",
+ "CheckTitle": "Supabase Auth rejects passwords that appear in known data breaches",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **leaked password protection**, which checks new passwords against the HaveIBeenPwned Pwned Passwords corpus using k-anonymity before accepting them.",
+ "Risk": "Users who reuse breached passwords are the first targets of **credential stuffing**; without the check, attackers can log in with lists of known credentials and reach every row the user's policies allow.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sign In / Providers > Email (or Authentication > Policies)\n3. Enable Prevent use of leaked passwords",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n password_hibp_enabled = true\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable leaked password protection together with a minimum length and character requirements.",
+ "Url": "https://hub.prowler.com/check/auth_leaked_password_protection_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.py b/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.py
new file mode 100644
index 0000000000..cad0b9fb09
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_leaked_password_protection_enabled(Check):
+ """Check that Supabase Auth rejects passwords found in known breaches.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.password_hibp_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.password_hibp_enabled:
+ report.status = "PASS"
+ report.status_extended = f"Project {config.name} rejects leaked passwords through HaveIBeenPwned."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} accepts passwords that appear in known data breaches."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/__init__.py b/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.metadata.json b/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.metadata.json
new file mode 100644
index 0000000000..37a1eadf85
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_mfa_enrollment_enabled",
+ "CheckTitle": "Supabase Auth allows users to enroll multi-factor authentication",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **MFA enrollment** options (TOTP, phone, WebAuthn). When none is enabled, application users cannot add a second factor even if the application asks for it.",
+ "Risk": "Without a second factor, a phished or reused password is enough to **take over an account** and read or modify every row its policies permit; the application has no way to request step-up authentication.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/auth-mfa"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Multi-Factor\n3. Enable TOTP (and optionally WebAuthn or phone) enrollment and verification\n4. Enforce aal2 in sensitive row level security policies",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n mfa_totp_enroll_enabled = true\n mfa_totp_verify_enabled = true\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable TOTP enrollment at minimum and require `aal2` in policies that guard sensitive data.",
+ "Url": "https://hub.prowler.com/check/auth_mfa_enrollment_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.py b/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.py
new file mode 100644
index 0000000000..d39d595f70
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_mfa_enrollment_enabled(Check):
+ """Check that users can enroll a second authentication factor.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.mfa_totp_enroll_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.mfa_enrollment_enabled:
+ report.status = "PASS"
+ report.status_extended = f"Project {config.name} lets users enroll multi-factor authentication."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} has every multi-factor enrollment method disabled."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/__init__.py b/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.metadata.json b/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.metadata.json
new file mode 100644
index 0000000000..0a4ae85893
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_otp_expiry_within_threshold",
+ "CheckTitle": "Supabase Auth email one-time passcodes expire within the recommended window",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for the **email OTP expiry**. Magic links and passcodes are bearer secrets; Supabase recommends keeping them valid for at most one hour.",
+ "Risk": "Long-lived passcodes widen the window in which a code read from a **forwarded email, shared inbox or compromised mail account** still signs the attacker in as the victim.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/auth-email-passwordless",
+ "https://supabase.com/docs/guides/database/database-linter?lint=0017_auth_otp_long_expiry"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sign In / Providers > Email\n3. Set Email OTP Expiration to 3600 seconds or less",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n mailer_otp_exp = 3600\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Keep OTP expiry at or below one hour; the threshold is configurable with `auth_otp_expiry_seconds`.",
+ "Url": "https://hub.prowler.com/check/auth_otp_expiry_within_threshold"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.py b/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.py
new file mode 100644
index 0000000000..a749e8df00
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold.py
@@ -0,0 +1,32 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+DEFAULT_AUTH_OTP_EXPIRY_SECONDS = 3600
+
+
+class auth_otp_expiry_within_threshold(Check):
+ """Check that email one-time passcodes expire within the recommended window.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ max_seconds = auth_client.audit_config.get(
+ "auth_otp_expiry_seconds", DEFAULT_AUTH_OTP_EXPIRY_SECONDS
+ )
+ for config in auth_client.configs.values():
+ if config.mailer_otp_exp is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.mailer_otp_exp <= max_seconds:
+ report.status = "PASS"
+ report.status_extended = f"Project {config.name} expires email OTPs after {config.mailer_otp_exp} seconds (threshold {max_seconds})."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} keeps email OTPs valid for {config.mailer_otp_exp} seconds, above the threshold of {max_seconds}."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_password_min_length/__init__.py b/prowler/providers/supabase/services/auth/auth_password_min_length/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.metadata.json b/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.metadata.json
new file mode 100644
index 0000000000..63589763fd
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_password_min_length",
+ "CheckTitle": "Supabase Auth requires a minimum password length",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for the **minimum password length** enforced at sign-up and password change. Supabase defaults to 6 characters, which is below common guidance.",
+ "Risk": "Short passwords are **brute forced** or guessed quickly, and the Auth endpoint is reachable from the internet with the public anon key, so weak passwords expose user accounts and the data their policies grant.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/password-security"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sign In / Providers > Email\n3. Set Minimum password length to 8 or more and require letters, digits and symbols",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n password_min_length = 12\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Require at least 8 characters (12 recommended) plus character classes; the threshold is configurable with `auth_password_min_length`.",
+ "Url": "https://hub.prowler.com/check/auth_password_min_length"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.py b/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.py
new file mode 100644
index 0000000000..a9450efdd8
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length.py
@@ -0,0 +1,32 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+DEFAULT_AUTH_PASSWORD_MIN_LENGTH = 8
+
+
+class auth_password_min_length(Check):
+ """Check that Supabase Auth requires passwords of a minimum length.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ min_length = auth_client.audit_config.get(
+ "auth_password_min_length", DEFAULT_AUTH_PASSWORD_MIN_LENGTH
+ )
+ for config in auth_client.configs.values():
+ if config.password_min_length is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.password_min_length >= min_length:
+ report.status = "PASS"
+ report.status_extended = f"Project {config.name} requires passwords of at least {config.password_min_length} characters (threshold {min_length})."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} accepts passwords of only {config.password_min_length} characters, below the threshold of {min_length}."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/__init__.py b/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.metadata.json b/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.metadata.json
new file mode 100644
index 0000000000..f7e14972ee
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "auth_refresh_token_rotation_enabled",
+ "CheckTitle": "Supabase Auth rotates refresh tokens on every use",
+ "CheckType": [],
+ "ServiceName": "auth",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase Auth** is assessed for **refresh token rotation**. With rotation, each refresh issues a new token and invalidates the old one, so replaying a captured token is detected.",
+ "Risk": "Without rotation, a refresh token stolen from local storage, logs or a device backup **keeps minting access tokens** until the user signs out everywhere, enabling persistent silent account access.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/auth/sessions#refresh-token-rotation"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Sessions (or Settings)\n3. Enable Refresh token rotation and keep the reuse interval short",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n auth = jsonencode({\n refresh_token_rotation_enabled = true\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable refresh token rotation and a short reuse interval; pair it with session timeboxing for sensitive apps.",
+ "Url": "https://hub.prowler.com/check/auth_refresh_token_rotation_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.py b/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.py
new file mode 100644
index 0000000000..b32b3105e2
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled.py
@@ -0,0 +1,29 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.auth.auth_client import auth_client
+
+
+class auth_refresh_token_rotation_enabled(Check):
+ """Check that refresh tokens are rotated on every use.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for config in auth_client.configs.values():
+ if config.refresh_token_rotation_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=config)
+ if config.refresh_token_rotation_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Project {config.name} rotates refresh tokens on use."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {config.name} reuses refresh tokens, so a stolen token stays valid indefinitely."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/auth/auth_service.py b/prowler/providers/supabase/services/auth/auth_service.py
new file mode 100644
index 0000000000..c9f4840ca9
--- /dev/null
+++ b/prowler/providers/supabase/services/auth/auth_service.py
@@ -0,0 +1,72 @@
+from typing import Optional
+
+from prowler.providers.supabase.lib.service.service import (
+ SupabaseProjectScope,
+ SupabaseService,
+)
+
+
+class Auth(SupabaseService):
+ """Retrieve the Supabase Auth (GoTrue) configuration of every project."""
+
+ def __init__(self, provider):
+ super().__init__("Auth", provider)
+ self.configs: dict[str, SupabaseAuthConfig] = {}
+ self._threading_call(self._fetch_config, self._list_projects())
+
+ def _fetch_config(self, scope: SupabaseProjectScope) -> None:
+ """Keep only the non-secret settings the checks evaluate."""
+ raw = self._get_optional(
+ f"/v1/projects/{scope.project_ref}/config/auth", "Auth config"
+ )
+ if raw is None:
+ return
+ self.configs[scope.project_ref] = SupabaseAuthConfig(
+ **scope.model_dump(),
+ disable_signup=raw.get("disable_signup"),
+ external_email_enabled=raw.get("external_email_enabled"),
+ external_anonymous_users_enabled=raw.get(
+ "external_anonymous_users_enabled"
+ ),
+ mailer_autoconfirm=raw.get("mailer_autoconfirm"),
+ mailer_otp_exp=raw.get("mailer_otp_exp"),
+ password_min_length=raw.get("password_min_length"),
+ password_required_characters=raw.get("password_required_characters"),
+ password_hibp_enabled=raw.get("password_hibp_enabled"),
+ mfa_totp_enroll_enabled=raw.get("mfa_totp_enroll_enabled"),
+ mfa_phone_enroll_enabled=raw.get("mfa_phone_enroll_enabled"),
+ mfa_web_authn_enroll_enabled=raw.get("mfa_web_authn_enroll_enabled"),
+ refresh_token_rotation_enabled=raw.get("refresh_token_rotation_enabled"),
+ security_captcha_enabled=raw.get("security_captcha_enabled"),
+ )
+
+
+class SupabaseAuthConfig(SupabaseProjectScope):
+ """Non-secret Supabase Auth settings of a project.
+
+ Provider client secrets, SMTP passwords, hook secrets and SMS credentials
+ returned by the Management API are deliberately not stored.
+ """
+
+ disable_signup: Optional[bool] = None
+ external_email_enabled: Optional[bool] = None
+ external_anonymous_users_enabled: Optional[bool] = None
+ mailer_autoconfirm: Optional[bool] = None
+ mailer_otp_exp: Optional[int] = None
+ password_min_length: Optional[int] = None
+ password_required_characters: Optional[str] = None
+ password_hibp_enabled: Optional[bool] = None
+ mfa_totp_enroll_enabled: Optional[bool] = None
+ mfa_phone_enroll_enabled: Optional[bool] = None
+ mfa_web_authn_enroll_enabled: Optional[bool] = None
+ refresh_token_rotation_enabled: Optional[bool] = None
+ security_captcha_enabled: Optional[bool] = None
+
+ @property
+ def mfa_enrollment_enabled(self) -> bool:
+ """True when users can enroll at least one second factor."""
+ return bool(
+ self.mfa_totp_enroll_enabled
+ or self.mfa_phone_enroll_enabled
+ or self.mfa_web_authn_enroll_enabled
+ )
diff --git a/prowler/providers/supabase/services/database/__init__.py b/prowler/providers/supabase/services/database/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_auth_users_not_exposed/__init__.py b/prowler/providers/supabase/services/database/database_auth_users_not_exposed/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.metadata.json b/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.metadata.json
new file mode 100644
index 0000000000..18fad49000
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_auth_users_not_exposed",
+ "CheckTitle": "Supabase auth.users data is not exposed through views or tables in the Data API",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase projects** are assessed for views or tables in exposed schemas that **select from `auth.users`** and are readable by `anon` or `authenticated`, publishing every user's email, metadata and identities.",
+ "Risk": "Exposing `auth.users` leaks the **entire user directory** including emails, phone numbers and raw metadata to anyone with the anon key, enabling phishing, account enumeration and privacy violations.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "REVOKE SELECT ON public. FROM anon, authenticated;",
+ "NativeIaC": "",
+ "Other": "1. Identify the view or table from the finding\n2. Revoke access from `anon` and `authenticated`, or rebuild it with `security_invoker = on` exposing only the columns of the calling user\n3. Keep a `public.profiles` table synced by trigger for data that must be public",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Never expose `auth.users`; maintain a profiles table with explicit RLS for the data the application needs.",
+ "Url": "https://hub.prowler.com/check/database_auth_users_not_exposed"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.py b/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.py
new file mode 100644
index 0000000000..67c23630b3
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["auth_users_exposed"]
+
+
+class database_auth_users_not_exposed(Check):
+ """Supabase auth.users data is not exposed through views or tables in the Data API.
+
+ Uses the Supabase security advisor lint(s) ``auth_users_exposed``. A project passes
+ when the advisor ran and reported no such lint; every reported object
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged object."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} does not expose auth.users through the Data API."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"{lint.object_type.capitalize() or 'Object'} {lint.qualified_name} in project {database.name} exposes auth.users data to the anon or authenticated roles."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_client.py b/prowler/providers/supabase/services/database/database_client.py
new file mode 100644
index 0000000000..fc207d3d5c
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.supabase.services.database.database_service import Database
+
+database_client = Database(Provider.get_global_provider())
diff --git a/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/__init__.py b/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.metadata.json b/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.metadata.json
new file mode 100644
index 0000000000..061b46ffbb
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_extensions_not_in_public_schema",
+ "CheckTitle": "Supabase database extensions are not installed in the public schema",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase database extensions** are assessed for installation in the **public schema**, whose objects are reachable through the Data API and can shadow application functions.",
+ "Risk": "Extension functions in `public` are callable by `anon` and `authenticated` through RPC and can **collide with or shadow** application objects, enlarging the attack surface and enabling search_path tricks.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "DROP EXTENSION ; CREATE EXTENSION WITH SCHEMA extensions;",
+ "NativeIaC": "",
+ "Other": "1. Identify the extension from the finding\n2. Reinstall it with `CREATE EXTENSION WITH SCHEMA extensions;` after dropping it from public\n3. Update application code that referenced its functions unqualified",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Install extensions in the `extensions` schema and keep `public` for application objects only.",
+ "Url": "https://hub.prowler.com/check/database_extensions_not_in_public_schema"
+ }
+ },
+ "Categories": [
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.py b/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.py
new file mode 100644
index 0000000000..531430288b
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["extension_in_public"]
+
+
+class database_extensions_not_in_public_schema(Check):
+ """Supabase database extensions are not installed in the public schema.
+
+ Uses the Supabase security advisor lint(s) ``extension_in_public``. A project passes
+ when the advisor ran and reported no such lint; every reported extension
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged extension."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no extension installed in the public schema."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Extension {lint.qualified_name} in project {database.name} is installed in the public schema and its functions are exposed through the Data API."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_functions_search_path_set/__init__.py b/prowler/providers/supabase/services/database/database_functions_search_path_set/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.metadata.json b/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.metadata.json
new file mode 100644
index 0000000000..bd9699e2a8
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_functions_search_path_set",
+ "CheckTitle": "Supabase database functions pin their search_path",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase database functions** are assessed for a **fixed search_path**. A function without `SET search_path` resolves unqualified object names at call time, so a caller who can create objects in an earlier schema can redirect the function.",
+ "Risk": "A SECURITY DEFINER function with a mutable search_path can be made to execute attacker-controlled functions or tables, leading to **privilege escalation to the function owner**, often `postgres`.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "ALTER FUNCTION public.() SET search_path = '';",
+ "NativeIaC": "",
+ "Other": "1. Identify the function from the finding\n2. Run `ALTER FUNCTION .() SET search_path = '';`\n3. Fully qualify every object reference inside the function body",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Set an empty search_path on every function (especially SECURITY DEFINER ones) and qualify object names.",
+ "Url": "https://hub.prowler.com/check/database_functions_search_path_set"
+ }
+ },
+ "Categories": [
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.py b/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.py
new file mode 100644
index 0000000000..9a0820ff75
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["function_search_path_mutable"]
+
+
+class database_functions_search_path_set(Check):
+ """Supabase database functions pin their search_path.
+
+ Uses the Supabase security advisor lint(s) ``function_search_path_mutable``. A project passes
+ when the advisor ran and reported no such lint; every reported function
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged function."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no function with a mutable search_path."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Function {lint.qualified_name} in project {database.name} has a mutable search_path and can be hijacked through schema shadowing."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_no_leaked_service_key/__init__.py b/prowler/providers/supabase/services/database/database_no_leaked_service_key/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.metadata.json b/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.metadata.json
new file mode 100644
index 0000000000..c93a10b56a
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_no_leaked_service_key",
+ "CheckTitle": "Supabase project service role key has not been detected in a public location",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase projects** are assessed for **service role keys detected in public locations** (public repositories, package registries, paste sites) by Supabase's secret scanning.",
+ "Risk": "The service role key **bypasses Row Level Security** on every table and grants full read and write access to the database, storage and auth users; a leaked key is an active breach until rotated.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/api/api-keys"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > API Keys and rotate the JWT secret or revoke the leaked secret key\n3. Remove the key from the public location and purge history\n4. Review database and storage logs for abuse",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Rotate immediately, move servers to revocable secret keys, and keep service keys out of client bundles and repositories.",
+ "Url": "https://hub.prowler.com/check/database_no_leaked_service_key"
+ }
+ },
+ "Categories": [
+ "secrets"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.py b/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.py
new file mode 100644
index 0000000000..18ef0d6474
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key.py
@@ -0,0 +1,48 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["leaked_service_key"]
+
+
+class database_no_leaked_service_key(Check):
+ """Supabase project service role key has not been detected in a public location.
+
+ Uses the Supabase security advisor lint(s) ``leaked_service_key``. A project passes
+ when the advisor ran and reported no such lint; every reported project
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged project."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no service role key reported as leaked."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Project {database.name} has a service role key detected in a public location and must be rotated immediately."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/__init__.py b/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.metadata.json b/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.metadata.json
new file mode 100644
index 0000000000..817d927793
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_postgres_version_not_vulnerable",
+ "CheckTitle": "Supabase project runs a Postgres version without known security vulnerabilities",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase projects** are assessed for **Postgres versions with published security vulnerabilities**, as reported by the Supabase security advisor.",
+ "Risk": "Known Postgres CVEs can allow **privilege escalation, denial of service or data disclosure** by any authenticated database user, including through SQL reachable from the Data API.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/platform/upgrading"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > Infrastructure\n3. Schedule the Postgres upgrade during a maintenance window and verify the application afterwards",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Upgrade to the latest Postgres version offered by Supabase and keep projects on a supported release.",
+ "Url": "https://hub.prowler.com/check/database_postgres_version_not_vulnerable"
+ }
+ },
+ "Categories": [
+ "vulnerabilities"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.py b/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.py
new file mode 100644
index 0000000000..a994e0f554
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable.py
@@ -0,0 +1,48 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["vulnerable_postgres_version"]
+
+
+class database_postgres_version_not_vulnerable(Check):
+ """Supabase project runs a Postgres version without known security vulnerabilities.
+
+ Uses the Supabase security advisor lint(s) ``vulnerable_postgres_version``. A project passes
+ when the advisor ran and reported no such lint; every reported project
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged project."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} runs a Postgres version without known security vulnerabilities."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Project {database.name} runs a Postgres version with known security vulnerabilities and should be upgraded."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/__init__.py b/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.metadata.json b/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.metadata.json
new file mode 100644
index 0000000000..18fc3c01f7
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_rls_enabled_on_public_tables",
+ "CheckTitle": "Supabase public tables exposed through the Data API have Row Level Security enabled",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase tables** in schemas exposed through PostgREST are assessed for **Row Level Security (RLS)**. Without RLS, anyone holding the public anon key can read and modify every row with a single HTTP request.",
+ "Risk": "A table without RLS in an exposed schema is **fully readable and writable from the internet** with the anon key shipped in the frontend, which is the most common cause of Supabase data breaches.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public",
+ "https://supabase.com/docs/guides/database/postgres/row-level-security"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "ALTER TABLE public. ENABLE ROW LEVEL SECURITY;",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Policies (or Table Editor > table > RLS)\n3. Enable RLS on every exposed table\n4. Add policies that scope rows to auth.uid() or move internal tables out of the exposed schemas",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable RLS on all tables in exposed schemas and write explicit policies; keep internal tables in a private schema.",
+ "Url": "https://hub.prowler.com/check/database_rls_enabled_on_public_tables"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.py b/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.py
new file mode 100644
index 0000000000..f167299dde
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["rls_disabled_in_public"]
+
+
+class database_rls_enabled_on_public_tables(Check):
+ """Supabase public tables exposed through the Data API have Row Level Security enabled.
+
+ Uses the Supabase security advisor lint(s) ``rls_disabled_in_public``. A project passes
+ when the advisor ran and reported no such lint; every reported table
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged table."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has Row Level Security enabled on every table exposed through the Data API."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Table {lint.qualified_name} in project {database.name} is exposed through the Data API without Row Level Security."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/__init__.py b/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.metadata.json b/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.metadata.json
new file mode 100644
index 0000000000..4bec19baf7
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_rls_enabled_on_tables_with_policies",
+ "CheckTitle": "Supabase tables that define policies have Row Level Security enabled",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase tables** are assessed for policies that exist while **Row Level Security is disabled**. Policies only take effect once RLS is enabled, so the table behaves as if it had none.",
+ "Risk": "A team that wrote policies believes the table is protected, yet every row stays **readable and writable with the anon key** until RLS is switched on, hiding the exposure behind a false sense of security.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "ALTER TABLE public. ENABLE ROW LEVEL SECURITY;",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Policies\n3. For every table that lists policies but shows RLS disabled, click Enable RLS",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable RLS wherever policies exist and review the policies once they start applying.",
+ "Url": "https://hub.prowler.com/check/database_rls_enabled_on_tables_with_policies"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.py b/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.py
new file mode 100644
index 0000000000..50df7a06b2
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["policy_exists_rls_disabled"]
+
+
+class database_rls_enabled_on_tables_with_policies(Check):
+ """Supabase tables that define policies have Row Level Security enabled.
+
+ Uses the Supabase security advisor lint(s) ``policy_exists_rls_disabled``. A project passes
+ when the advisor ran and reported no such lint; every reported table
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged table."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no table with policies defined while Row Level Security is disabled."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Table {lint.qualified_name} in project {database.name} defines policies but Row Level Security is disabled, so they are never applied."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/__init__.py b/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.metadata.json b/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.metadata.json
new file mode 100644
index 0000000000..d48248cb5d
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_rls_enabled_tables_have_policies",
+ "CheckTitle": "Supabase tables with Row Level Security enabled define at least one policy",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase tables** with **Row Level Security enabled but no policies** are reported. Such tables deny every request through the Data API, which usually means the table was secured by default and then forgotten, or that the service role is being used from clients to work around it.",
+ "Risk": "Tables that deny everything push developers to query them with the **service role key**, which bypasses RLS entirely; a key used that way tends to leak into client code or logs and exposes the whole database.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Authentication > Policies\n3. For tables with RLS enabled and no policies, add explicit policies or move the table to a private schema",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Give every RLS-enabled table deliberate policies and never use the service role key from clients to bypass them.",
+ "Url": "https://hub.prowler.com/check/database_rls_enabled_tables_have_policies"
+ }
+ },
+ "Categories": [
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.py b/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.py
new file mode 100644
index 0000000000..cce590ae38
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["rls_enabled_no_policy"]
+
+
+class database_rls_enabled_tables_have_policies(Check):
+ """Supabase tables with Row Level Security enabled define at least one policy.
+
+ Uses the Supabase security advisor lint(s) ``rls_enabled_no_policy``. A project passes
+ when the advisor ran and reported no such lint; every reported table
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged table."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no table with Row Level Security enabled but no policies."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Table {lint.qualified_name} in project {database.name} has Row Level Security enabled but no policies, so it is inaccessible through the Data API."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/__init__.py b/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.metadata.json b/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.metadata.json
new file mode 100644
index 0000000000..3fe8faf7d0
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_rls_policies_not_using_user_metadata",
+ "CheckTitle": "Supabase Row Level Security policies do not rely on user editable metadata",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase RLS policies** are assessed for references to **`raw_user_meta_data` / `user_metadata`**. That field is writable by the user through the Auth API, so any policy based on it can be satisfied by the user editing their own profile.",
+ "Risk": "A policy such as `auth.jwt() -> 'user_metadata' ->> 'role' = 'admin'` lets any user **grant themselves admin rights** with one `updateUser` call, bypassing the intended authorization entirely.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Identify the policy from the finding\n2. Rewrite it to use `app_metadata` (writable only with the service role), a roles table joined on auth.uid(), or custom claims from an auth hook\n3. Re-test the policy as a regular user",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Authorize with `app_metadata` or a server-controlled roles table, never with user editable metadata.",
+ "Url": "https://hub.prowler.com/check/database_rls_policies_not_using_user_metadata"
+ }
+ },
+ "Categories": [
+ "trust-boundaries",
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.py b/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.py
new file mode 100644
index 0000000000..6184da2803
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["rls_references_user_metadata"]
+
+
+class database_rls_policies_not_using_user_metadata(Check):
+ """Supabase Row Level Security policies do not rely on user editable metadata.
+
+ Uses the Supabase security advisor lint(s) ``rls_references_user_metadata``. A project passes
+ when the advisor ran and reported no such lint; every reported policy
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged policy."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no policy that references user_metadata."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"Policy {lint.qualified_name} in project {database.name} references user_metadata, which any user can edit to escalate access."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/database/database_service.py b/prowler/providers/supabase/services/database/database_service.py
new file mode 100644
index 0000000000..48e8348010
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_service.py
@@ -0,0 +1,89 @@
+from pydantic import BaseModel, Field
+
+from prowler.providers.supabase.lib.service.service import (
+ SupabaseProjectScope,
+ SupabaseService,
+)
+
+# Lints that describe the advisor itself rather than the database schema
+NON_SCHEMA_LINTS = {"project_not_active", "advisor_check_unavailable"}
+
+
+class Database(SupabaseService):
+ """Retrieve the Postgres security posture of every project.
+
+ The Supabase security advisor runs the same lints as the dashboard
+ (``splinter``) and exposes them through the Management API, so no SQL
+ is executed against customer databases.
+ """
+
+ def __init__(self, provider):
+ super().__init__("Database", provider)
+ self.databases: dict[str, SupabaseDatabase] = {}
+ self._threading_call(self._fetch_lints, self._list_projects())
+
+ def _fetch_lints(self, scope: SupabaseProjectScope) -> None:
+ """Store the security lints of a project; unavailable projects are skipped."""
+ raw = self._get_optional(
+ f"/v1/projects/{scope.project_ref}/advisors/security", "Security advisor"
+ )
+ if raw is None:
+ return
+ lints = []
+ advisor_available = True
+ for item in raw.get("lints") or []:
+ name = item.get("name", "")
+ if name in NON_SCHEMA_LINTS:
+ advisor_available = False
+ continue
+ metadata = item.get("metadata") or {}
+ lints.append(
+ SupabaseDatabaseLint(
+ name=name,
+ title=item.get("title", ""),
+ level=item.get("level", ""),
+ schema_name=metadata.get("schema") or "",
+ object_name=metadata.get("name") or metadata.get("entity") or "",
+ object_type=metadata.get("type") or "",
+ detail=item.get("detail") or "",
+ remediation=item.get("remediation") or "",
+ )
+ )
+ self.databases[scope.project_ref] = SupabaseDatabase(
+ **scope.model_dump(), advisor_available=advisor_available, lints=lints
+ )
+
+
+class SupabaseDatabaseLint(BaseModel):
+ """One security advisor finding on a database object."""
+
+ name: str
+ title: str = ""
+ level: str = ""
+ schema_name: str = ""
+ object_name: str = ""
+ object_type: str = ""
+ detail: str = ""
+ remediation: str = ""
+
+ @property
+ def qualified_name(self) -> str:
+ """``schema.object`` when both are known, otherwise whichever exists."""
+ return ".".join(part for part in (self.schema_name, self.object_name) if part)
+
+
+class SupabaseDatabase(SupabaseProjectScope):
+ """Postgres database of a project with its security advisor lints."""
+
+ advisor_available: bool = True
+ lints: list[SupabaseDatabaseLint] = Field(default_factory=list)
+
+ def lints_named(self, *names: str) -> list[SupabaseDatabaseLint]:
+ """Return the lints matching any of the given advisor names."""
+ return [lint for lint in self.lints if lint.name in names]
+
+
+class SupabaseDatabaseObject(SupabaseProjectScope):
+ """Database object a lint points at, used as the failing finding resource."""
+
+ lint: SupabaseDatabaseLint
diff --git a/prowler/providers/supabase/services/database/database_views_not_security_definer/__init__.py b/prowler/providers/supabase/services/database/database_views_not_security_definer/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.metadata.json b/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.metadata.json
new file mode 100644
index 0000000000..b4055a46e2
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "database_views_not_security_definer",
+ "CheckTitle": "Supabase views exposed through the Data API do not run with SECURITY DEFINER",
+ "CheckType": [],
+ "ServiceName": "database",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase views** in exposed schemas are assessed for **SECURITY DEFINER** semantics. Such views execute with the permissions of their owner (usually postgres) instead of the querying user, so RLS on the underlying tables is skipped.",
+ "Risk": "A SECURITY DEFINER view turns every policy on its base tables into a no-op for anyone with the anon key, allowing **unauthenticated reads of protected rows** through a single `select` on the view.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "ALTER VIEW public. SET (security_invoker = on);",
+ "NativeIaC": "",
+ "Other": "1. Identify the view from the finding\n2. Run `ALTER VIEW . SET (security_invoker = on);`\n3. Verify the view still returns the intended rows for `anon` and `authenticated`",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Create exposed views with `security_invoker = on` so the caller's RLS applies, or move them to a private schema.",
+ "Url": "https://hub.prowler.com/check/database_views_not_security_definer"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results come from the Supabase security advisor exposed by the Management API; projects where the advisor could not run are not reported."
+}
diff --git a/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.py b/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.py
new file mode 100644
index 0000000000..6f06b0302a
--- /dev/null
+++ b/prowler/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.database.database_client import (
+ database_client,
+)
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabaseObject,
+)
+
+ADVISOR_LINTS = ["security_definer_view"]
+
+
+class database_views_not_security_definer(Check):
+ """Supabase views exposed through the Data API do not run with SECURITY DEFINER.
+
+ Uses the Supabase security advisor lint(s) ``security_definer_view``. A project passes
+ when the advisor ran and reported no such lint; every reported view
+ produces its own failing finding.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one PASS per clean project or one FAIL per flagged view."""
+ findings = []
+ for database in database_client.databases.values():
+ if not database.advisor_available:
+ continue
+ lints = database.lints_named(*ADVISOR_LINTS)
+ if not lints:
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=database
+ )
+ report.status = "PASS"
+ report.status_extended = f"Project {database.name} has no exposed view running with SECURITY DEFINER."
+ findings.append(report)
+ continue
+ for lint in lints:
+ resource = SupabaseDatabaseObject(
+ **database.model_dump(exclude={"lints", "advisor_available"}),
+ lint=lint,
+ )
+ if lint.qualified_name:
+ resource.id = f"{database.project_ref}:{lint.qualified_name}"
+ resource.name = lint.qualified_name
+ report = CheckReportSupabase(
+ metadata=self.metadata(), resource=resource
+ )
+ report.status = "FAIL"
+ report.status_extended = f"View {lint.qualified_name} in project {database.name} runs with SECURITY DEFINER, bypassing the caller's Row Level Security."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/projects/__init__.py b/prowler/providers/supabase/services/projects/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_client.py b/prowler/providers/supabase/services/projects/projects_client.py
new file mode 100644
index 0000000000..904b50baf7
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.supabase.services.projects.projects_service import Projects
+
+projects_client = Projects(Provider.get_global_provider())
diff --git a/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/__init__.py b/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.metadata.json b/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.metadata.json
new file mode 100644
index 0000000000..a0fe8e6c73
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "projects_legacy_api_keys_disabled",
+ "CheckTitle": "Supabase project has legacy anon and service_role API keys disabled",
+ "CheckType": [],
+ "ServiceName": "projects",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Supabase projects** are assessed for **legacy JWT API keys** (`anon`, `service_role`). The newer publishable and secret keys can be rotated and revoked individually, while the legacy keys are long-lived tokens derived from the project JWT secret.",
+ "Risk": "Legacy keys cannot be revoked without rotating the whole JWT secret, so a leaked `service_role` key stays valid and **bypasses row level security** for every table until a disruptive full rotation is performed.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/api/api-keys"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > API Keys\n3. Create publishable and secret keys and migrate clients and servers to them\n4. Disable the legacy anon and service_role keys",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Migrate to publishable and secret API keys, then disable the legacy keys so a leak can be contained by revoking one key.",
+ "Url": "https://hub.prowler.com/check/projects_legacy_api_keys_disabled"
+ }
+ },
+ "Categories": [
+ "identity-access",
+ "secrets"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.py b/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.py
new file mode 100644
index 0000000000..50c47b15d3
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.projects.projects_client import projects_client
+
+
+class projects_legacy_api_keys_disabled(Check):
+ """Check that the legacy JWT based anon and service_role API keys are disabled.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for project in projects_client.projects.values():
+ if project.legacy_api_keys_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=project)
+ if not project.legacy_api_keys_enabled:
+ report.status = "PASS"
+ report.status_extended = f"Project {project.name} has the legacy anon and service_role API keys disabled."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {project.name} still accepts the legacy anon and service_role JWT API keys."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/__init__.py b/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.metadata.json b/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.metadata.json
new file mode 100644
index 0000000000..e543cbddf3
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "projects_network_restrictions_configured",
+ "CheckTitle": "Supabase project restricts direct database connections to allowed IP ranges",
+ "CheckType": [],
+ "ServiceName": "projects",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "**Supabase projects** are assessed for **network restrictions** on the Postgres and connection pooler endpoints. By default any IP address on the internet may attempt to connect with valid credentials.",
+ "Risk": "An unrestricted database endpoint turns every leaked connection string or weak password into a **direct path to all data** and lets attackers brute force credentials from anywhere, without passing through the application or its row level security.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/platform/network-restrictions"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "supabase network-restrictions update --project-ref --db-allow-cidr --experimental",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > Database > Network Restrictions\n3. Click Add restriction and allow only the IPv4/IPv6 ranges of your backends, bastions and office egress\n4. Remove 0.0.0.0/0 and ::/0 entries",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Allow only the CIDR ranges that must reach Postgres directly; route application traffic through the Data API or the pooler from known egress IPs.",
+ "Url": "https://hub.prowler.com/check/projects_network_restrictions_configured"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "trust-boundaries"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Network restrictions require a paid plan; projects whose organization is not entitled to the feature are not reported."
+}
diff --git a/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.py b/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.py
new file mode 100644
index 0000000000..9157d9c770
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.projects.projects_client import projects_client
+
+
+class projects_network_restrictions_configured(Check):
+ """Check that direct database access is restricted to known IP ranges.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for project in projects_client.projects.values():
+ if project.network_restrictions_entitled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=project)
+ if project.allowed_cidrs_restricted:
+ report.status = "PASS"
+ report.status_extended = f"Project {project.name} restricts database access to {len(project.allowed_cidrs) + len(project.allowed_cidrs_v6)} allowed CIDR range(s)."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {project.name} allows direct database connections from any IP address."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/projects/projects_pitr_enabled/__init__.py b/prowler/providers/supabase/services/projects/projects_pitr_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.metadata.json b/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.metadata.json
new file mode 100644
index 0000000000..3fc261accb
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "supabase",
+ "CheckID": "projects_pitr_enabled",
+ "CheckTitle": "Supabase project has Point-in-Time Recovery enabled",
+ "CheckType": [],
+ "ServiceName": "projects",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase projects** are assessed for **Point-in-Time Recovery (PITR)**, which archives WAL continuously so the database can be restored to any second instead of only to the last daily backup.",
+ "Risk": "Without PITR a destructive migration, ransomware style data wipe or compromised service key means **losing up to a day of data**, and recovery is limited to coarse daily snapshots, increasing the blast radius of any security incident.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/platform/backups#point-in-time-recovery"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > Add-ons (or Database > Backups)\n3. Enable the Point in Time Recovery add-on and choose a retention period\n4. Document the restore procedure and test it",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable PITR on every production project and rehearse restores; keep daily backups for non-production workloads.",
+ "Url": "https://hub.prowler.com/check/projects_pitr_enabled"
+ }
+ },
+ "Categories": [
+ "resilience"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "PITR is a paid add-on; the check reports FAIL on free projects so teams can decide whether the data warrants it."
+}
diff --git a/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.py b/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.py
new file mode 100644
index 0000000000..19ea06d23f
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled.py
@@ -0,0 +1,29 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.projects.projects_client import projects_client
+
+
+class projects_pitr_enabled(Check):
+ """Check that Point-in-Time Recovery is enabled for the project database.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for project in projects_client.projects.values():
+ if project.pitr_enabled is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=project)
+ if project.pitr_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Project {project.name} has Point-in-Time Recovery enabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {project.name} does not have Point-in-Time Recovery enabled and relies on daily backups only."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/__init__.py b/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.metadata.json b/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.metadata.json
new file mode 100644
index 0000000000..d34015cf21
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "supabase",
+ "CheckID": "projects_postgrest_max_rows_limited",
+ "CheckTitle": "Supabase project limits the maximum rows returned by the Data API",
+ "CheckType": [],
+ "ServiceName": "projects",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "api_gateway",
+ "Description": "**Supabase projects** are assessed for the PostgREST **max rows** setting of the Data API. The value bounds how many rows a single unauthenticated or authenticated REST request can pull from an exposed table.",
+ "Risk": "A high row cap lets a single request with the anon key **dump entire tables** that have weak or missing row level security and lets attackers exhaust database CPU and bandwidth, turning any policy mistake into a **mass data exfiltration** in one call.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/api/securing-your-api",
+ "https://supabase.com/docs/guides/database/postgres/configuration"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "supabase postgres-config update --project-ref --config max_rows=1000 --experimental",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > Data API\n3. Set Max rows to the smallest value your pages need (1000 or lower)\n4. Use range headers or pagination in clients",
+ "Terraform": "```hcl\nresource \"supabase_settings\" \"\" {\n project_ref = \"\"\n\n api = jsonencode({\n max_rows = 1000 # Critical: bound rows per request\n })\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Keep `max_rows` at or below 1000 and paginate in clients; the threshold is configurable with `postgrest_max_rows` in the audit configuration.",
+ "Url": "https://hub.prowler.com/check/projects_postgrest_max_rows_limited"
+ }
+ },
+ "Categories": [
+ "internet-exposed",
+ "resilience"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.py b/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.py
new file mode 100644
index 0000000000..8c82fd327d
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited.py
@@ -0,0 +1,32 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.projects.projects_client import projects_client
+
+DEFAULT_POSTGREST_MAX_ROWS = 1000
+
+
+class projects_postgrest_max_rows_limited(Check):
+ """Check that the Data API caps the number of rows a single request can return.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ max_rows = projects_client.audit_config.get(
+ "postgrest_max_rows", DEFAULT_POSTGREST_MAX_ROWS
+ )
+ for project in projects_client.projects.values():
+ if project.postgrest_max_rows is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=project)
+ if project.postgrest_max_rows <= max_rows:
+ report.status = "PASS"
+ report.status_extended = f"Project {project.name} limits Data API responses to {project.postgrest_max_rows} rows (threshold {max_rows})."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {project.name} allows Data API responses of {project.postgrest_max_rows} rows, above the threshold of {max_rows}."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/projects/projects_service.py b/prowler/providers/supabase/services/projects/projects_service.py
new file mode 100644
index 0000000000..2520d56b24
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_service.py
@@ -0,0 +1,84 @@
+from typing import Optional
+
+from pydantic import Field
+
+from prowler.providers.supabase.lib.service.service import (
+ SupabaseProjectScope,
+ SupabaseService,
+)
+
+
+class Projects(SupabaseService):
+ """Retrieve Supabase projects with their platform-level security settings."""
+
+ def __init__(self, provider):
+ super().__init__("Projects", provider)
+ self.projects: dict[str, SupabaseProject] = {}
+ for scope in self._list_projects():
+ self.projects[scope.project_ref] = SupabaseProject(**scope.model_dump())
+ self._threading_call(self._fetch_settings, list(self.projects.values()))
+
+ def _fetch_settings(self, project: "SupabaseProject") -> None:
+ """Collect SSL, network, Data API, backup and API key settings for a project."""
+ ref = project.project_ref
+
+ ssl = self._get_optional(
+ f"/v1/projects/{ref}/ssl-enforcement", "SSL enforcement"
+ )
+ if ssl is not None:
+ project.ssl_enforced = bool(
+ (ssl.get("currentConfig") or {}).get("database")
+ )
+
+ network = self._get_optional(
+ f"/v1/projects/{ref}/network-restrictions", "Network restrictions"
+ )
+ if network is not None:
+ config = network.get("config") or {}
+ project.network_restrictions_entitled = (
+ network.get("entitlement") == "allowed"
+ )
+ project.allowed_cidrs = list(config.get("dbAllowedCidrs") or [])
+ project.allowed_cidrs_v6 = list(config.get("dbAllowedCidrsV6") or [])
+
+ postgrest = self._get_optional(
+ f"/v1/projects/{ref}/postgrest", "PostgREST config"
+ )
+ if postgrest is not None:
+ project.postgrest_max_rows = postgrest.get("max_rows")
+ project.postgrest_db_schema = postgrest.get("db_schema")
+
+ backups = self._get_optional(f"/v1/projects/{ref}/database/backups", "Backups")
+ if backups is not None:
+ project.pitr_enabled = bool(backups.get("pitr_enabled"))
+
+ legacy = self._get_optional(
+ f"/v1/projects/{ref}/api-keys/legacy", "Legacy API keys"
+ )
+ if legacy is not None:
+ project.legacy_api_keys_enabled = bool(legacy.get("enabled"))
+
+
+class SupabaseProject(SupabaseProjectScope):
+ """Security-relevant Supabase project settings.
+
+ Attributes left as ``None`` could not be read from the Management API and
+ are skipped by the checks instead of being reported as failures.
+ """
+
+ ssl_enforced: Optional[bool] = None
+ network_restrictions_entitled: Optional[bool] = None
+ allowed_cidrs: list[str] = Field(default_factory=list)
+ allowed_cidrs_v6: list[str] = Field(default_factory=list)
+ postgrest_max_rows: Optional[int] = None
+ postgrest_db_schema: Optional[str] = None
+ pitr_enabled: Optional[bool] = None
+ legacy_api_keys_enabled: Optional[bool] = None
+
+ @property
+ def allowed_cidrs_restricted(self) -> bool:
+ """True when at least one range is allowed and none of them is the whole internet."""
+ ranges = [*self.allowed_cidrs, *self.allowed_cidrs_v6]
+ return bool(ranges) and not any(
+ cidr.strip() in ("0.0.0.0/0", "::/0") for cidr in ranges
+ )
diff --git a/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/__init__.py b/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.metadata.json b/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.metadata.json
new file mode 100644
index 0000000000..c85941a778
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "projects_ssl_enforcement_enabled",
+ "CheckTitle": "Supabase project enforces SSL on direct database connections",
+ "CheckType": [],
+ "ServiceName": "projects",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "**Supabase projects** are assessed for **SSL enforcement** on the Postgres endpoint. When enforcement is off, clients may open unencrypted connections to the database, including over the public internet.",
+ "Risk": "Without SSL enforcement, a misconfigured client or connection string without `sslmode=require` sends credentials and query results in **plaintext**, exposing them to **network interception** and credential theft that grants direct access to all data.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/platform/ssl-enforcement"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "supabase ssl-enforcement update --project-ref --enable-db-ssl-enforcement --experimental",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Project Settings > Database\n3. Under SSL Configuration, turn on Enforce SSL on incoming connections\n4. Update clients that fail to connect to use sslmode=require",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enforce SSL on every project and configure clients with `sslmode=require` or `verify-full` using the Supabase CA certificate.",
+ "Url": "https://hub.prowler.com/check/projects_ssl_enforcement_enabled"
+ }
+ },
+ "Categories": [
+ "encryption",
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.py b/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.py
new file mode 100644
index 0000000000..b6c890a745
--- /dev/null
+++ b/prowler/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled.py
@@ -0,0 +1,29 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.projects.projects_client import projects_client
+
+
+class projects_ssl_enforcement_enabled(Check):
+ """Check that SSL is enforced for direct Postgres connections of each project.
+
+ Resources whose setting could not be read are skipped instead of failed.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per project."""
+ findings = []
+ for project in projects_client.projects.values():
+ if project.ssl_enforced is None:
+ continue
+ report = CheckReportSupabase(metadata=self.metadata(), resource=project)
+ if project.ssl_enforced:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Project {project.name} enforces SSL on database connections."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Project {project.name} does not enforce SSL, so database clients can connect in plaintext."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/storage/__init__.py b/prowler/providers/supabase/services/storage/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/storage/storage_bucket_not_public/__init__.py b/prowler/providers/supabase/services/storage/storage_bucket_not_public/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.metadata.json b/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.metadata.json
new file mode 100644
index 0000000000..33e021e89a
--- /dev/null
+++ b/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "storage_bucket_not_public",
+ "CheckTitle": "Supabase Storage bucket is private",
+ "CheckType": [],
+ "ServiceName": "storage",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "storage",
+ "Description": "**Supabase Storage buckets** are assessed for the **public** flag. Objects in a public bucket are served to anyone who knows or guesses the path, without a JWT and without evaluating `storage.objects` policies.",
+ "Risk": "A public bucket makes every upload, including user documents, exports and backups placed there by mistake, **downloadable from the internet** by URL; paths are often predictable or leak through logs and referrers.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/guides/storage/security/access-control",
+ "https://supabase.com/docs/guides/storage/buckets/fundamentals#public-buckets"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Open the Supabase Dashboard and select the project\n2. Go to Storage and open the bucket settings\n3. Turn off Public bucket\n4. Serve files through signed URLs or policies on storage.objects",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Keep buckets private and use signed URLs or RLS policies on `storage.objects`; reserve public buckets for genuinely public assets with no sensitive content.",
+ "Url": "https://hub.prowler.com/check/storage_bucket_not_public"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Public buckets are legitimate for static assets; review the contents before muting the finding."
+}
diff --git a/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.py b/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.py
new file mode 100644
index 0000000000..abb70e3c15
--- /dev/null
+++ b/prowler/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public.py
@@ -0,0 +1,31 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.storage.storage_client import storage_client
+
+
+class storage_bucket_not_public(Check):
+ """Check that Supabase Storage buckets are private.
+
+ Objects in a public bucket are downloadable by anyone who knows the path,
+ without any JWT or Row Level Security policy being evaluated.
+ """
+
+ def execute(self) -> List[CheckReportSupabase]:
+ """Return one finding per bucket."""
+ findings = []
+ for bucket in storage_client.buckets.values():
+ report = CheckReportSupabase(metadata=self.metadata(), resource=bucket)
+ if bucket.public:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Bucket {bucket.name} in project {bucket.project_name} is public, "
+ f"so every object is downloadable without authentication."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Bucket {bucket.name} in project {bucket.project_name} is private."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/supabase/services/storage/storage_client.py b/prowler/providers/supabase/services/storage/storage_client.py
new file mode 100644
index 0000000000..01fdb54507
--- /dev/null
+++ b/prowler/providers/supabase/services/storage/storage_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.supabase.services.storage.storage_service import Storage
+
+storage_client = Storage(Provider.get_global_provider())
diff --git a/prowler/providers/supabase/services/storage/storage_service.py b/prowler/providers/supabase/services/storage/storage_service.py
new file mode 100644
index 0000000000..b05187ccf6
--- /dev/null
+++ b/prowler/providers/supabase/services/storage/storage_service.py
@@ -0,0 +1,38 @@
+from prowler.providers.supabase.lib.service.service import (
+ SupabaseProjectScope,
+ SupabaseService,
+)
+
+
+class Storage(SupabaseService):
+ """Retrieve Supabase Storage buckets of every project."""
+
+ def __init__(self, provider):
+ super().__init__("Storage", provider)
+ self.buckets: dict[str, SupabaseStorageBucket] = {}
+ self._threading_call(self._fetch_buckets, self._list_projects())
+
+ def _fetch_buckets(self, scope: SupabaseProjectScope) -> None:
+ """Collect the buckets of a project with their visibility."""
+ raw_buckets = self._get_optional(
+ f"/v1/projects/{scope.project_ref}/storage/buckets", "Storage buckets"
+ )
+ if raw_buckets is None:
+ return
+ for raw in raw_buckets:
+ bucket_id = raw.get("id") or raw.get("name", "")
+ if not bucket_id:
+ continue
+ key = f"{scope.project_ref}:{bucket_id}"
+ self.buckets[key] = SupabaseStorageBucket(
+ **{**scope.model_dump(), "id": key, "name": raw.get("name", bucket_id)},
+ bucket_id=bucket_id,
+ public=bool(raw.get("public")),
+ )
+
+
+class SupabaseStorageBucket(SupabaseProjectScope):
+ """Supabase Storage bucket and its visibility."""
+
+ bucket_id: str
+ public: bool = False
diff --git a/prowler/providers/supabase/supabase_provider.py b/prowler/providers/supabase/supabase_provider.py
index 37b3410de2..e6b94a6099 100644
--- a/prowler/providers/supabase/supabase_provider.py
+++ b/prowler/providers/supabase/supabase_provider.py
@@ -35,13 +35,14 @@ class SupabaseProvider(Provider):
"""Hosted Supabase Cloud Management API provider."""
_type = "supabase"
- _cli_help_text = "Supabase Provider (PoC)"
+ _cli_help_text = "Supabase Provider"
sdk_only = True
audit_metadata: Audit_Metadata
def __init__(
self,
access_token: str = None,
+ projects: list[str] = None,
config_path: str = None,
config_content: dict | None = None,
fixer_config: dict = {},
@@ -55,6 +56,7 @@ class SupabaseProvider(Provider):
self._identity = self.setup_identity(
self._session, self._audit_config.get("max_retries", 3)
)
+ self._filter_projects = [project for project in (projects or []) if project]
self._fixer_config = fixer_config
self._mutelist = (
SupabaseMutelist(mutelist_content=mutelist_content)
@@ -77,6 +79,11 @@ class SupabaseProvider(Provider):
def identity(self):
return self._identity
+ @property
+ def filter_projects(self) -> list[str]:
+ """Project refs or names the scan is limited to; empty scans everything."""
+ return self._filter_projects
+
@property
def audit_config(self):
return self._audit_config
@@ -147,6 +154,7 @@ class SupabaseProvider(Provider):
def from_cli_args(cls, arguments: Namespace, fixer_config: dict):
"""Create the provider from non-secret CLI configuration."""
return cls(
+ projects=getattr(arguments, "project", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
@@ -157,11 +165,16 @@ class SupabaseProvider(Provider):
", ".join(organization.slug for organization in self.identity.organizations)
or "none"
)
+ lines = [
+ f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}",
+ f"Organizations: {Fore.YELLOW}{organizations}{Style.RESET_ALL}",
+ ]
+ if self.filter_projects:
+ lines.append(
+ f"Projects: {Fore.YELLOW}{', '.join(self.filter_projects)}{Style.RESET_ALL}"
+ )
print_boxes(
- [
- f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}",
- f"Organizations: {Fore.YELLOW}{organizations}{Style.RESET_ALL}",
- ],
+ lines,
f"{Style.BRIGHT}Using the Supabase credentials below:{Style.RESET_ALL}",
)
@@ -199,8 +212,8 @@ class SupabaseProvider(Provider):
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_stdout_detail(self, finding) -> str:
+ return finding.region
def get_summary_entity(self) -> tuple[str, str]:
organizations = ", ".join(
@@ -216,7 +229,7 @@ class SupabaseProvider(Provider):
"account_name": check_output.organization_name,
"resource_name": check_output.resource_name,
"resource_uid": check_output.resource_id,
- "region": "global",
+ "region": check_output.region,
}
def get_html_assessment_summary(self) -> str:
diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py
index a811186b3b..09bb366c6d 100644
--- a/tests/lib/cli/parser_test.py
+++ b/tests/lib/cli/parser_test.py
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
-prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm} ..."
+prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,supabase,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm} ..."
def mock_get_available_providers():
diff --git a/tests/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/__init__.py b/tests/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled_test.py b/tests/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled_test.py
new file mode 100644
index 0000000000..8da5415af3
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_anonymous_sign_ins_disabled/auth_anonymous_sign_ins_disabled_test.py
@@ -0,0 +1,93 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_anonymous_sign_ins_disabled.auth_anonymous_sign_ins_disabled"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_anonymous_sign_ins_disabled.auth_anonymous_sign_ins_disabled import (
+ auth_anonymous_sign_ins_disabled,
+ )
+
+ return auth_anonymous_sign_ins_disabled().execute()
+
+
+class Test_auth_anonymous_sign_ins_disabled:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"external_anonymous_users_enabled": False})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has anonymous sign-ins disabled."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"external_anonymous_users_enabled": True})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} allows anonymous sign-ins, which receive the authenticated role."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"external_anonymous_users_enabled": None})
+ }
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/auth_captcha_enabled/__init__.py b/tests/providers/supabase/services/auth/auth_captcha_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled_test.py b/tests/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled_test.py
new file mode 100644
index 0000000000..7547a9cb8d
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_captcha_enabled/auth_captcha_enabled_test.py
@@ -0,0 +1,95 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = (
+ "prowler.providers.supabase.services.auth.auth_captcha_enabled.auth_captcha_enabled"
+)
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_captcha_enabled.auth_captcha_enabled import (
+ auth_captcha_enabled,
+ )
+
+ return auth_captcha_enabled().execute()
+
+
+class Test_auth_captcha_enabled:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"security_captcha_enabled": True})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} protects sign-up and sign-in with CAPTCHA."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"security_captcha_enabled": False})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} exposes sign-up and sign-in without CAPTCHA protection."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"security_captcha_enabled": None})
+ }
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/auth_email_confirmation_required/__init__.py b/tests/providers/supabase/services/auth/auth_email_confirmation_required/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required_test.py b/tests/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required_test.py
new file mode 100644
index 0000000000..84e92b1849
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_email_confirmation_required/auth_email_confirmation_required_test.py
@@ -0,0 +1,87 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_email_confirmation_required.auth_email_confirmation_required"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_email_confirmation_required.auth_email_confirmation_required import (
+ auth_email_confirmation_required,
+ )
+
+ return auth_email_confirmation_required().execute()
+
+
+class Test_auth_email_confirmation_required:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_autoconfirm": False})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} requires email confirmation before a sign-up is active."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_autoconfirm": True})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} auto-confirms email sign-ups without verifying the address."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_autoconfirm": None})}
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/auth_leaked_password_protection_enabled/__init__.py b/tests/providers/supabase/services/auth/auth_leaked_password_protection_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled_test.py b/tests/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled_test.py
new file mode 100644
index 0000000000..2167b2247f
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_leaked_password_protection_enabled/auth_leaked_password_protection_enabled_test.py
@@ -0,0 +1,87 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_leaked_password_protection_enabled.auth_leaked_password_protection_enabled"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_leaked_password_protection_enabled.auth_leaked_password_protection_enabled import (
+ auth_leaked_password_protection_enabled,
+ )
+
+ return auth_leaked_password_protection_enabled().execute()
+
+
+class Test_auth_leaked_password_protection_enabled:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_hibp_enabled": True})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} rejects leaked passwords through HaveIBeenPwned."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_hibp_enabled": False})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} accepts passwords that appear in known data breaches."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_hibp_enabled": None})}
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/auth_mfa_enrollment_enabled/__init__.py b/tests/providers/supabase/services/auth/auth_mfa_enrollment_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled_test.py b/tests/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled_test.py
new file mode 100644
index 0000000000..04eefe4aa5
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_mfa_enrollment_enabled/auth_mfa_enrollment_enabled_test.py
@@ -0,0 +1,99 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_mfa_enrollment_enabled.auth_mfa_enrollment_enabled"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_mfa_enrollment_enabled.auth_mfa_enrollment_enabled import (
+ auth_mfa_enrollment_enabled,
+ )
+
+ return auth_mfa_enrollment_enabled().execute()
+
+
+class Test_auth_mfa_enrollment_enabled:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"mfa_totp_enroll_enabled": True})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} lets users enroll multi-factor authentication."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(
+ **{
+ "mfa_totp_enroll_enabled": False,
+ "mfa_phone_enroll_enabled": False,
+ "mfa_web_authn_enroll_enabled": False,
+ }
+ )
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has every multi-factor enrollment method disabled."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"mfa_totp_enroll_enabled": None})
+ }
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/auth_otp_expiry_within_threshold/__init__.py b/tests/providers/supabase/services/auth/auth_otp_expiry_within_threshold/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold_test.py b/tests/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold_test.py
new file mode 100644
index 0000000000..6c2fe66e6f
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_otp_expiry_within_threshold/auth_otp_expiry_within_threshold_test.py
@@ -0,0 +1,96 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_otp_expiry_within_threshold.auth_otp_expiry_within_threshold"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_otp_expiry_within_threshold.auth_otp_expiry_within_threshold import (
+ auth_otp_expiry_within_threshold,
+ )
+
+ return auth_otp_expiry_within_threshold().execute()
+
+
+class Test_auth_otp_expiry_within_threshold:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_otp_exp": 1800})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} expires email OTPs after 1800 seconds (threshold 3600)."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_otp_exp": 86400})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} keeps email OTPs valid for 86400 seconds, above the threshold of 3600."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_otp_exp": None})}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_custom_threshold(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {"auth_otp_expiry_seconds": 60}
+ auth_client.configs = {PROJECT_REF: _config(**{"mailer_otp_exp": 1800})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/supabase/services/auth/auth_password_min_length/__init__.py b/tests/providers/supabase/services/auth/auth_password_min_length/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length_test.py b/tests/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length_test.py
new file mode 100644
index 0000000000..65674d8152
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_password_min_length/auth_password_min_length_test.py
@@ -0,0 +1,96 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_password_min_length.auth_password_min_length"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_password_min_length.auth_password_min_length import (
+ auth_password_min_length,
+ )
+
+ return auth_password_min_length().execute()
+
+
+class Test_auth_password_min_length:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_min_length": 12})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} requires passwords of at least 12 characters (threshold 8)."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_min_length": 6})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} accepts passwords of only 6 characters, below the threshold of 8."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_min_length": None})}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_custom_threshold(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {"auth_password_min_length": 16}
+ auth_client.configs = {PROJECT_REF: _config(**{"password_min_length": 12})}
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/__init__.py b/tests/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled_test.py b/tests/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled_test.py
new file mode 100644
index 0000000000..54b67395f2
--- /dev/null
+++ b/tests/providers/supabase/services/auth/auth_refresh_token_rotation_enabled/auth_refresh_token_rotation_enabled_test.py
@@ -0,0 +1,93 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.auth.auth_service import SupabaseAuthConfig
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.auth.auth_refresh_token_rotation_enabled.auth_refresh_token_rotation_enabled"
+
+
+def _config(**overrides):
+ return SupabaseAuthConfig(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(auth_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.auth_client", new=auth_client),
+ ):
+ from prowler.providers.supabase.services.auth.auth_refresh_token_rotation_enabled.auth_refresh_token_rotation_enabled import (
+ auth_refresh_token_rotation_enabled,
+ )
+
+ return auth_refresh_token_rotation_enabled().execute()
+
+
+class Test_auth_refresh_token_rotation_enabled:
+ def test_no_configs(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {}
+
+ assert len(_run(auth_client)) == 0
+
+ def test_pass(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"refresh_token_rotation_enabled": True})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} rotates refresh tokens on use."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"refresh_token_rotation_enabled": False})
+ }
+
+ result = _run(auth_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} reuses refresh tokens, so a stolen token stays valid indefinitely."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ auth_client = mock.MagicMock
+ auth_client.audit_config = {}
+ auth_client.configs = {
+ PROJECT_REF: _config(**{"refresh_token_rotation_enabled": None})
+ }
+
+ assert len(_run(auth_client)) == 0
diff --git a/tests/providers/supabase/services/auth/supabase_auth_service_test.py b/tests/providers/supabase/services/auth/supabase_auth_service_test.py
new file mode 100644
index 0000000000..73d55b172a
--- /dev/null
+++ b/tests/providers/supabase/services/auth/supabase_auth_service_test.py
@@ -0,0 +1,93 @@
+from unittest import mock
+
+import requests
+
+from prowler.providers.supabase.services.auth import auth_service
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ set_mocked_supabase_provider,
+)
+
+
+def _response(status_code=200, payload=None):
+ response = mock.MagicMock(status_code=status_code, headers={})
+ response.json.return_value = payload
+ if status_code >= 400:
+ response.raise_for_status.side_effect = requests.HTTPError(
+ f"{status_code} error"
+ )
+ return response
+
+
+class TestAuthService:
+ def test_keeps_only_non_secret_settings(self):
+ provider = set_mocked_supabase_provider()
+ routes = {
+ "/v1/projects": _response(
+ payload=[
+ {
+ "ref": PROJECT_REF,
+ "name": PROJECT_NAME,
+ "organization_slug": ORGANIZATION_SLUG,
+ }
+ ]
+ ),
+ f"/v1/projects/{PROJECT_REF}/config/auth": _response(
+ payload={
+ "disable_signup": False,
+ "external_anonymous_users_enabled": True,
+ "mailer_autoconfirm": False,
+ "mailer_otp_exp": 86400,
+ "password_min_length": 6,
+ "password_hibp_enabled": False,
+ "mfa_totp_enroll_enabled": False,
+ "mfa_phone_enroll_enabled": False,
+ "mfa_web_authn_enroll_enabled": True,
+ "refresh_token_rotation_enabled": True,
+ "security_captcha_enabled": False,
+ "security_captcha_secret": "do-not-store",
+ "smtp_pass": "do-not-store",
+ "external_github_secret": "do-not-store",
+ }
+ ),
+ }
+ provider.session.http_session.get.side_effect = lambda url, timeout=30: routes[
+ url.replace("https://api.supabase.com", "")
+ ]
+
+ service = auth_service.Auth(provider)
+
+ config = service.configs[PROJECT_REF]
+ assert config.external_anonymous_users_enabled is True
+ assert config.mailer_otp_exp == 86400
+ assert config.password_min_length == 6
+ assert config.mfa_enrollment_enabled is True
+ dumped = str(config.model_dump())
+ assert "do-not-store" not in dumped
+
+ def test_project_without_auth_config_is_skipped(self):
+ provider = set_mocked_supabase_provider()
+ routes = {
+ "/v1/projects": _response(
+ payload=[
+ {
+ "ref": PROJECT_REF,
+ "name": PROJECT_NAME,
+ "organization_slug": ORGANIZATION_SLUG,
+ }
+ ]
+ ),
+ f"/v1/projects/{PROJECT_REF}/config/auth": _response(
+ status_code=404, payload={}
+ ),
+ }
+ provider.session.http_session.get.side_effect = lambda url, timeout=30: routes[
+ url.replace("https://api.supabase.com", "")
+ ]
+
+ with mock.patch("prowler.providers.supabase.lib.service.service.time.sleep"):
+ service = auth_service.Auth(provider)
+
+ assert service.configs == {}
diff --git a/tests/providers/supabase/services/database/database_auth_users_not_exposed/__init__.py b/tests/providers/supabase/services/database/database_auth_users_not_exposed/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed_test.py b/tests/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed_test.py
new file mode 100644
index 0000000000..6d8a0104f0
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_auth_users_not_exposed/database_auth_users_not_exposed_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_auth_users_not_exposed.database_auth_users_not_exposed"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_auth_users_not_exposed.database_auth_users_not_exposed import (
+ database_auth_users_not_exposed,
+ )
+
+ return database_auth_users_not_exposed().execute()
+
+
+class Test_database_auth_users_not_exposed:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not expose auth.users through the Data API."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_object_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="auth_users_exposed",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="users_view",
+ object_type="view",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"View public.users_view in project {PROJECT_NAME} exposes auth.users data to the anon or authenticated roles."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.users_view"
+ assert result[0].resource_name == "public.users_view"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_extensions_not_in_public_schema/__init__.py b/tests/providers/supabase/services/database/database_extensions_not_in_public_schema/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema_test.py b/tests/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema_test.py
new file mode 100644
index 0000000000..7cb1bdc888
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_extensions_not_in_public_schema/database_extensions_not_in_public_schema_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_extensions_not_in_public_schema.database_extensions_not_in_public_schema"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_extensions_not_in_public_schema.database_extensions_not_in_public_schema import (
+ database_extensions_not_in_public_schema,
+ )
+
+ return database_extensions_not_in_public_schema().execute()
+
+
+class Test_database_extensions_not_in_public_schema:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no extension installed in the public schema."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_extension_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="extension_in_public",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="pg_net",
+ object_type="extension",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Extension public.pg_net in project {PROJECT_NAME} is installed in the public schema and its functions are exposed through the Data API."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.pg_net"
+ assert result[0].resource_name == "public.pg_net"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_functions_search_path_set/__init__.py b/tests/providers/supabase/services/database/database_functions_search_path_set/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set_test.py b/tests/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set_test.py
new file mode 100644
index 0000000000..547e76c7f2
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_functions_search_path_set/database_functions_search_path_set_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_functions_search_path_set.database_functions_search_path_set"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_functions_search_path_set.database_functions_search_path_set import (
+ database_functions_search_path_set,
+ )
+
+ return database_functions_search_path_set().execute()
+
+
+class Test_database_functions_search_path_set:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no function with a mutable search_path."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_function_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="function_search_path_mutable",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="handle_new_user",
+ object_type="function",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Function public.handle_new_user in project {PROJECT_NAME} has a mutable search_path and can be hijacked through schema shadowing."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.handle_new_user"
+ assert result[0].resource_name == "public.handle_new_user"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_no_leaked_service_key/__init__.py b/tests/providers/supabase/services/database/database_no_leaked_service_key/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key_test.py b/tests/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key_test.py
new file mode 100644
index 0000000000..c341be2a1d
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_no_leaked_service_key/database_no_leaked_service_key_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_no_leaked_service_key.database_no_leaked_service_key"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_no_leaked_service_key.database_no_leaked_service_key import (
+ database_no_leaked_service_key,
+ )
+
+ return database_no_leaked_service_key().execute()
+
+
+class Test_database_no_leaked_service_key:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no service role key reported as leaked."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_project_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="leaked_service_key",
+ title="Lint title",
+ level="ERROR",
+ schema_name="",
+ object_name="",
+ object_type="",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has a service role key detected in a public location and must be rotated immediately."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_postgres_version_not_vulnerable/__init__.py b/tests/providers/supabase/services/database/database_postgres_version_not_vulnerable/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable_test.py b/tests/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable_test.py
new file mode 100644
index 0000000000..4325880117
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_postgres_version_not_vulnerable/database_postgres_version_not_vulnerable_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_postgres_version_not_vulnerable.database_postgres_version_not_vulnerable"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_postgres_version_not_vulnerable.database_postgres_version_not_vulnerable import (
+ database_postgres_version_not_vulnerable,
+ )
+
+ return database_postgres_version_not_vulnerable().execute()
+
+
+class Test_database_postgres_version_not_vulnerable:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} runs a Postgres version without known security vulnerabilities."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_project_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="vulnerable_postgres_version",
+ title="Lint title",
+ level="ERROR",
+ schema_name="",
+ object_name="",
+ object_type="",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} runs a Postgres version with known security vulnerabilities and should be upgraded."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_on_public_tables/__init__.py b/tests/providers/supabase/services/database/database_rls_enabled_on_public_tables/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables_test.py b/tests/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables_test.py
new file mode 100644
index 0000000000..83d8226be4
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_rls_enabled_on_public_tables/database_rls_enabled_on_public_tables_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_rls_enabled_on_public_tables.database_rls_enabled_on_public_tables"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_rls_enabled_on_public_tables.database_rls_enabled_on_public_tables import (
+ database_rls_enabled_on_public_tables,
+ )
+
+ return database_rls_enabled_on_public_tables().execute()
+
+
+class Test_database_rls_enabled_on_public_tables:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has Row Level Security enabled on every table exposed through the Data API."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_table_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="rls_disabled_in_public",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="orders",
+ object_type="table",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Table public.orders in project {PROJECT_NAME} is exposed through the Data API without Row Level Security."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.orders"
+ assert result[0].resource_name == "public.orders"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/__init__.py b/tests/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies_test.py b/tests/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies_test.py
new file mode 100644
index 0000000000..22f7ab43fc
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_rls_enabled_on_tables_with_policies/database_rls_enabled_on_tables_with_policies_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_rls_enabled_on_tables_with_policies.database_rls_enabled_on_tables_with_policies"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_rls_enabled_on_tables_with_policies.database_rls_enabled_on_tables_with_policies import (
+ database_rls_enabled_on_tables_with_policies,
+ )
+
+ return database_rls_enabled_on_tables_with_policies().execute()
+
+
+class Test_database_rls_enabled_on_tables_with_policies:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no table with policies defined while Row Level Security is disabled."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_table_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="policy_exists_rls_disabled",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="orders",
+ object_type="table",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Table public.orders in project {PROJECT_NAME} defines policies but Row Level Security is disabled, so they are never applied."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.orders"
+ assert result[0].resource_name == "public.orders"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_tables_have_policies/__init__.py b/tests/providers/supabase/services/database/database_rls_enabled_tables_have_policies/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies_test.py b/tests/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies_test.py
new file mode 100644
index 0000000000..c031e623bc
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_rls_enabled_tables_have_policies/database_rls_enabled_tables_have_policies_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_rls_enabled_tables_have_policies.database_rls_enabled_tables_have_policies"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_rls_enabled_tables_have_policies.database_rls_enabled_tables_have_policies import (
+ database_rls_enabled_tables_have_policies,
+ )
+
+ return database_rls_enabled_tables_have_policies().execute()
+
+
+class Test_database_rls_enabled_tables_have_policies:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no table with Row Level Security enabled but no policies."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_table_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="rls_enabled_no_policy",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="orders",
+ object_type="table",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Table public.orders in project {PROJECT_NAME} has Row Level Security enabled but no policies, so it is inaccessible through the Data API."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.orders"
+ assert result[0].resource_name == "public.orders"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/__init__.py b/tests/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata_test.py b/tests/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata_test.py
new file mode 100644
index 0000000000..a3934d1e94
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_rls_policies_not_using_user_metadata/database_rls_policies_not_using_user_metadata_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_rls_policies_not_using_user_metadata.database_rls_policies_not_using_user_metadata"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_rls_policies_not_using_user_metadata.database_rls_policies_not_using_user_metadata import (
+ database_rls_policies_not_using_user_metadata,
+ )
+
+ return database_rls_policies_not_using_user_metadata().execute()
+
+
+class Test_database_rls_policies_not_using_user_metadata:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no policy that references user_metadata."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_policy_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="rls_references_user_metadata",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="orders_select",
+ object_type="table",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Policy public.orders_select in project {PROJECT_NAME} references user_metadata, which any user can edit to escalate access."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.orders_select"
+ assert result[0].resource_name == "public.orders_select"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/database_views_not_security_definer/__init__.py b/tests/providers/supabase/services/database/database_views_not_security_definer/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer_test.py b/tests/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer_test.py
new file mode 100644
index 0000000000..617dafad03
--- /dev/null
+++ b/tests/providers/supabase/services/database/database_views_not_security_definer/database_views_not_security_definer_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.database.database_service import (
+ SupabaseDatabase,
+ SupabaseDatabaseLint,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.database.database_views_not_security_definer.database_views_not_security_definer"
+
+
+def _database(lints=(), advisor_available=True):
+ return SupabaseDatabase(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ advisor_available=advisor_available,
+ lints=list(lints),
+ )
+
+
+def _run(database_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.database_client", new=database_client),
+ ):
+ from prowler.providers.supabase.services.database.database_views_not_security_definer.database_views_not_security_definer import (
+ database_views_not_security_definer,
+ )
+
+ return database_views_not_security_definer().execute()
+
+
+class Test_database_views_not_security_definer:
+ def test_no_databases(self):
+ database_client = mock.MagicMock
+ database_client.databases = {}
+
+ assert len(_run(database_client)) == 0
+
+ def test_project_without_lint_passes(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[SupabaseDatabaseLint(name="unrelated_lint", title="Unrelated")]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has no exposed view running with SECURITY DEFINER."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].region == PROJECT_REGION
+
+ def test_flagged_view_fails(self):
+ database_client = mock.MagicMock
+ database_client.databases = {
+ PROJECT_REF: _database(
+ lints=[
+ SupabaseDatabaseLint(
+ name="security_definer_view",
+ title="Lint title",
+ level="ERROR",
+ schema_name="public",
+ object_name="orders",
+ object_type="view",
+ )
+ ]
+ )
+ }
+
+ result = _run(database_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"View public.orders in project {PROJECT_NAME} runs with SECURITY DEFINER, bypassing the caller's Row Level Security."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:public.orders"
+ assert result[0].resource_name == "public.orders"
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+
+ def test_unavailable_advisor_is_skipped(self):
+ database_client = mock.MagicMock
+ database_client.databases = {PROJECT_REF: _database(advisor_available=False)}
+
+ assert len(_run(database_client)) == 0
diff --git a/tests/providers/supabase/services/database/supabase_database_service_test.py b/tests/providers/supabase/services/database/supabase_database_service_test.py
new file mode 100644
index 0000000000..d68b02bdf5
--- /dev/null
+++ b/tests/providers/supabase/services/database/supabase_database_service_test.py
@@ -0,0 +1,91 @@
+from unittest import mock
+
+import requests
+
+from prowler.providers.supabase.services.database import database_service
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ set_mocked_supabase_provider,
+)
+
+
+def _response(status_code=200, payload=None):
+ response = mock.MagicMock(status_code=status_code, headers={})
+ response.json.return_value = payload
+ if status_code >= 400:
+ response.raise_for_status.side_effect = requests.HTTPError(
+ f"{status_code} error"
+ )
+ return response
+
+
+def _provider_with_lints(lints):
+ provider = set_mocked_supabase_provider()
+ routes = {
+ "/v1/projects": _response(
+ payload=[
+ {
+ "ref": PROJECT_REF,
+ "name": PROJECT_NAME,
+ "organization_slug": ORGANIZATION_SLUG,
+ }
+ ]
+ ),
+ f"/v1/projects/{PROJECT_REF}/advisors/security": _response(
+ payload={"lints": lints}
+ ),
+ }
+ provider.session.http_session.get.side_effect = lambda url, timeout=30: routes[
+ url.replace("https://api.supabase.com", "")
+ ]
+ return provider
+
+
+class TestDatabaseService:
+ def test_collects_security_advisor_lints(self):
+ provider = _provider_with_lints(
+ [
+ {
+ "name": "rls_disabled_in_public",
+ "title": "RLS Disabled in Public",
+ "level": "ERROR",
+ "metadata": {"schema": "public", "name": "orders", "type": "table"},
+ "detail": "Table public.orders is public, but RLS has not been enabled.",
+ "remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public",
+ },
+ {
+ "name": "vulnerable_postgres_version",
+ "title": "Vulnerable Postgres version",
+ "level": "WARN",
+ "metadata": {"type": "compliance"},
+ },
+ ]
+ )
+
+ service = database_service.Database(provider)
+
+ database = service.databases[PROJECT_REF]
+ assert database.advisor_available is True
+ assert [lint.name for lint in database.lints] == [
+ "rls_disabled_in_public",
+ "vulnerable_postgres_version",
+ ]
+ rls = database.lints_named("rls_disabled_in_public")[0]
+ assert rls.qualified_name == "public.orders"
+ assert rls.object_type == "table"
+ assert (
+ database.lints_named("vulnerable_postgres_version")[0].qualified_name == ""
+ )
+
+ def test_advisor_unavailable_marks_database(self):
+ provider = _provider_with_lints(
+ [{"name": "project_not_active", "title": "Project not active"}]
+ )
+
+ service = database_service.Database(provider)
+
+ database = service.databases[PROJECT_REF]
+ assert database.advisor_available is False
+ assert database.lints == []
diff --git a/tests/providers/supabase/services/projects/projects_legacy_api_keys_disabled/__init__.py b/tests/providers/supabase/services/projects/projects_legacy_api_keys_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled_test.py b/tests/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled_test.py
new file mode 100644
index 0000000000..35d3593824
--- /dev/null
+++ b/tests/providers/supabase/services/projects/projects_legacy_api_keys_disabled/projects_legacy_api_keys_disabled_test.py
@@ -0,0 +1,95 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.projects.projects_service import (
+ SupabaseProject,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.projects.projects_legacy_api_keys_disabled.projects_legacy_api_keys_disabled"
+
+
+def _project(**overrides):
+ return SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(projects_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.projects_client", new=projects_client),
+ ):
+ from prowler.providers.supabase.services.projects.projects_legacy_api_keys_disabled.projects_legacy_api_keys_disabled import (
+ projects_legacy_api_keys_disabled,
+ )
+
+ return projects_legacy_api_keys_disabled().execute()
+
+
+class Test_projects_legacy_api_keys_disabled:
+ def test_no_projects(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {}
+
+ assert len(_run(projects_client)) == 0
+
+ def test_pass(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"legacy_api_keys_enabled": False})
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has the legacy anon and service_role API keys disabled."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"legacy_api_keys_enabled": True})
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} still accepts the legacy anon and service_role JWT API keys."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"legacy_api_keys_enabled": None})
+ }
+
+ assert len(_run(projects_client)) == 0
diff --git a/tests/providers/supabase/services/projects/projects_network_restrictions_configured/__init__.py b/tests/providers/supabase/services/projects/projects_network_restrictions_configured/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured_test.py b/tests/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured_test.py
new file mode 100644
index 0000000000..b890315b4d
--- /dev/null
+++ b/tests/providers/supabase/services/projects/projects_network_restrictions_configured/projects_network_restrictions_configured_test.py
@@ -0,0 +1,105 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.projects.projects_service import (
+ SupabaseProject,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.projects.projects_network_restrictions_configured.projects_network_restrictions_configured"
+
+
+def _project(**overrides):
+ return SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(projects_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.projects_client", new=projects_client),
+ ):
+ from prowler.providers.supabase.services.projects.projects_network_restrictions_configured.projects_network_restrictions_configured import (
+ projects_network_restrictions_configured,
+ )
+
+ return projects_network_restrictions_configured().execute()
+
+
+class Test_projects_network_restrictions_configured:
+ def test_no_projects(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {}
+
+ assert len(_run(projects_client)) == 0
+
+ def test_pass(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(
+ **{
+ "network_restrictions_entitled": True,
+ "allowed_cidrs": ["203.0.113.0/24"],
+ }
+ )
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} restricts database access to 1 allowed CIDR range(s)."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(
+ **{
+ "network_restrictions_entitled": True,
+ "allowed_cidrs": ["0.0.0.0/0"],
+ }
+ )
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} allows direct database connections from any IP address."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"network_restrictions_entitled": None})
+ }
+
+ assert len(_run(projects_client)) == 0
diff --git a/tests/providers/supabase/services/projects/projects_pitr_enabled/__init__.py b/tests/providers/supabase/services/projects/projects_pitr_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled_test.py b/tests/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled_test.py
new file mode 100644
index 0000000000..4c2cccbb0f
--- /dev/null
+++ b/tests/providers/supabase/services/projects/projects_pitr_enabled/projects_pitr_enabled_test.py
@@ -0,0 +1,89 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.projects.projects_service import (
+ SupabaseProject,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.projects.projects_pitr_enabled.projects_pitr_enabled"
+
+
+def _project(**overrides):
+ return SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(projects_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.projects_client", new=projects_client),
+ ):
+ from prowler.providers.supabase.services.projects.projects_pitr_enabled.projects_pitr_enabled import (
+ projects_pitr_enabled,
+ )
+
+ return projects_pitr_enabled().execute()
+
+
+class Test_projects_pitr_enabled:
+ def test_no_projects(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {}
+
+ assert len(_run(projects_client)) == 0
+
+ def test_pass(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"pitr_enabled": True})}
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} has Point-in-Time Recovery enabled."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"pitr_enabled": False})}
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not have Point-in-Time Recovery enabled and relies on daily backups only."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"pitr_enabled": None})}
+
+ assert len(_run(projects_client)) == 0
diff --git a/tests/providers/supabase/services/projects/projects_postgrest_max_rows_limited/__init__.py b/tests/providers/supabase/services/projects/projects_postgrest_max_rows_limited/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited_test.py b/tests/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited_test.py
new file mode 100644
index 0000000000..72265cea34
--- /dev/null
+++ b/tests/providers/supabase/services/projects/projects_postgrest_max_rows_limited/projects_postgrest_max_rows_limited_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.projects.projects_service import (
+ SupabaseProject,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.projects.projects_postgrest_max_rows_limited.projects_postgrest_max_rows_limited"
+
+
+def _project(**overrides):
+ return SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(projects_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.projects_client", new=projects_client),
+ ):
+ from prowler.providers.supabase.services.projects.projects_postgrest_max_rows_limited.projects_postgrest_max_rows_limited import (
+ projects_postgrest_max_rows_limited,
+ )
+
+ return projects_postgrest_max_rows_limited().execute()
+
+
+class Test_projects_postgrest_max_rows_limited:
+ def test_no_projects(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {}
+
+ assert len(_run(projects_client)) == 0
+
+ def test_pass(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"postgrest_max_rows": 500})
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} limits Data API responses to 500 rows (threshold 1000)."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"postgrest_max_rows": 100000})
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} allows Data API responses of 100000 rows, above the threshold of 1000."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"postgrest_max_rows": None})
+ }
+
+ assert len(_run(projects_client)) == 0
+
+ def test_custom_threshold(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {"postgrest_max_rows": 100}
+ projects_client.projects = {
+ PROJECT_REF: _project(**{"postgrest_max_rows": 500})
+ }
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/supabase/services/projects/projects_ssl_enforcement_enabled/__init__.py b/tests/providers/supabase/services/projects/projects_ssl_enforcement_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled_test.py b/tests/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled_test.py
new file mode 100644
index 0000000000..251527050e
--- /dev/null
+++ b/tests/providers/supabase/services/projects/projects_ssl_enforcement_enabled/projects_ssl_enforcement_enabled_test.py
@@ -0,0 +1,89 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.projects.projects_service import (
+ SupabaseProject,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.projects.projects_ssl_enforcement_enabled.projects_ssl_enforcement_enabled"
+
+
+def _project(**overrides):
+ return SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ **overrides,
+ )
+
+
+def _run(projects_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.projects_client", new=projects_client),
+ ):
+ from prowler.providers.supabase.services.projects.projects_ssl_enforcement_enabled.projects_ssl_enforcement_enabled import (
+ projects_ssl_enforcement_enabled,
+ )
+
+ return projects_ssl_enforcement_enabled().execute()
+
+
+class Test_projects_ssl_enforcement_enabled:
+ def test_no_projects(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {}
+
+ assert len(_run(projects_client)) == 0
+
+ def test_pass(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"ssl_enforced": True})}
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} enforces SSL on database connections."
+ )
+ assert result[0].resource_id == PROJECT_REF
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].region == PROJECT_REGION
+
+ def test_fail(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"ssl_enforced": False})}
+
+ result = _run(projects_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not enforce SSL, so database clients can connect in plaintext."
+ )
+
+ def test_unknown_setting_is_skipped(self):
+ projects_client = mock.MagicMock
+ projects_client.audit_config = {}
+ projects_client.projects = {PROJECT_REF: _project(**{"ssl_enforced": None})}
+
+ assert len(_run(projects_client)) == 0
diff --git a/tests/providers/supabase/services/projects/supabase_projects_service_test.py b/tests/providers/supabase/services/projects/supabase_projects_service_test.py
new file mode 100644
index 0000000000..6fff45a4f2
--- /dev/null
+++ b/tests/providers/supabase/services/projects/supabase_projects_service_test.py
@@ -0,0 +1,161 @@
+from unittest import mock
+
+import pytest
+import requests
+
+from prowler.providers.supabase.exceptions.exceptions import (
+ SupabaseAuthenticationError,
+ SupabaseInsufficientPermissionsError,
+ SupabaseRateLimitError,
+)
+from prowler.providers.supabase.services.projects import projects_service
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+PROJECT_LIST = [
+ {
+ "ref": PROJECT_REF,
+ "name": PROJECT_NAME,
+ "organization_slug": ORGANIZATION_SLUG,
+ "region": PROJECT_REGION,
+ "status": "ACTIVE_HEALTHY",
+ },
+ {
+ "ref": "zzzzzzzzzzzzzzzzzzzz",
+ "name": "sandbox",
+ "organization_slug": ORGANIZATION_SLUG,
+ "region": "us-east-1",
+ "status": "INACTIVE",
+ },
+]
+
+
+def _response(status_code=200, payload=None):
+ response = mock.MagicMock(status_code=status_code, headers={})
+ response.json.return_value = payload
+ if status_code >= 400:
+ response.raise_for_status.side_effect = requests.HTTPError(
+ f"{status_code} error"
+ )
+ return response
+
+
+def _routes(overrides=None):
+ """Return a side effect answering each Management API path."""
+ routes = {
+ "/v1/projects": _response(payload=PROJECT_LIST),
+ f"/v1/projects/{PROJECT_REF}/ssl-enforcement": _response(
+ payload={"currentConfig": {"database": True}, "appliedSuccessfully": True}
+ ),
+ f"/v1/projects/{PROJECT_REF}/network-restrictions": _response(
+ payload={
+ "entitlement": "allowed",
+ "config": {
+ "dbAllowedCidrs": ["203.0.113.0/24"],
+ "dbAllowedCidrsV6": [],
+ },
+ "status": "applied",
+ }
+ ),
+ f"/v1/projects/{PROJECT_REF}/postgrest": _response(
+ payload={"db_schema": "public", "max_rows": 1000, "jwt_secret": "x"}
+ ),
+ f"/v1/projects/{PROJECT_REF}/database/backups": _response(
+ payload={"pitr_enabled": False, "walg_enabled": True, "backups": []}
+ ),
+ f"/v1/projects/{PROJECT_REF}/api-keys/legacy": _response(
+ payload={"enabled": True}
+ ),
+ }
+ routes.update(overrides or {})
+
+ def side_effect(url, timeout=30):
+ path = url.replace("https://api.supabase.com", "")
+ return routes.get(path, _response(status_code=500, payload={}))
+
+ return side_effect
+
+
+class TestProjectsService:
+ def test_collects_project_settings(self):
+ provider = set_mocked_supabase_provider()
+ provider.session.http_session.get.side_effect = _routes()
+
+ service = projects_service.Projects(provider)
+
+ assert set(service.projects) == {PROJECT_REF, "zzzzzzzzzzzzzzzzzzzz"}
+ project = service.projects[PROJECT_REF]
+ assert project.name == PROJECT_NAME
+ assert project.organization_name == ORGANIZATION_NAME
+ assert project.region == PROJECT_REGION
+ assert project.ssl_enforced is True
+ assert project.network_restrictions_entitled is True
+ assert project.allowed_cidrs == ["203.0.113.0/24"]
+ assert project.allowed_cidrs_restricted is True
+ assert project.postgrest_max_rows == 1000
+ assert project.pitr_enabled is False
+ assert project.legacy_api_keys_enabled is True
+ assert "jwt_secret" not in project.model_dump()
+
+ def test_unavailable_endpoints_leave_settings_unknown(self):
+ provider = set_mocked_supabase_provider()
+ provider.session.http_session.get.side_effect = _routes(
+ {
+ f"/v1/projects/{PROJECT_REF}/ssl-enforcement": _response(
+ status_code=500, payload={}
+ )
+ }
+ )
+
+ with mock.patch("prowler.providers.supabase.lib.service.service.time.sleep"):
+ service = projects_service.Projects(provider)
+
+ project = service.projects[PROJECT_REF]
+ assert project.ssl_enforced is None
+ assert project.pitr_enabled is False
+
+ def test_project_filter_by_ref_or_name(self):
+ provider = set_mocked_supabase_provider()
+ provider.filter_projects = ["sandbox"]
+ provider.session.http_session.get.side_effect = _routes()
+
+ service = projects_service.Projects(provider)
+
+ assert list(service.projects) == ["zzzzzzzzzzzzzzzzzzzz"]
+
+ def test_whole_internet_cidr_is_not_restricted(self):
+ project = projects_service.SupabaseProject(
+ id=PROJECT_REF,
+ name=PROJECT_NAME,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ allowed_cidrs=["0.0.0.0/0"],
+ allowed_cidrs_v6=["::/0"],
+ )
+
+ assert project.allowed_cidrs_restricted is False
+
+ @pytest.mark.parametrize(
+ ("status_code", "exception"),
+ [
+ (401, SupabaseAuthenticationError),
+ (403, SupabaseInsufficientPermissionsError),
+ (429, SupabaseRateLimitError),
+ ],
+ )
+ def test_authorization_errors_propagate(self, status_code, exception):
+ provider = set_mocked_supabase_provider()
+ provider.session.http_session.get.return_value = mock.MagicMock(
+ status_code=status_code, headers={"X-RateLimit-Reset": "0"}
+ )
+
+ with pytest.raises(exception):
+ projects_service.Projects(provider)
diff --git a/tests/providers/supabase/services/storage/storage_bucket_not_public/__init__.py b/tests/providers/supabase/services/storage/storage_bucket_not_public/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public_test.py b/tests/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public_test.py
new file mode 100644
index 0000000000..d2cac45da5
--- /dev/null
+++ b/tests/providers/supabase/services/storage/storage_bucket_not_public/storage_bucket_not_public_test.py
@@ -0,0 +1,79 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.storage.storage_service import (
+ SupabaseStorageBucket,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ PROJECT_REGION,
+ set_mocked_supabase_provider,
+)
+
+CHECK_PATH = "prowler.providers.supabase.services.storage.storage_bucket_not_public.storage_bucket_not_public"
+
+
+def _bucket(name, public):
+ return SupabaseStorageBucket(
+ id=f"{PROJECT_REF}:{name}",
+ name=name,
+ bucket_id=name,
+ public=public,
+ project_ref=PROJECT_REF,
+ project_name=PROJECT_NAME,
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ region=PROJECT_REGION,
+ )
+
+
+def _run(storage_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.storage_client", new=storage_client),
+ ):
+ from prowler.providers.supabase.services.storage.storage_bucket_not_public.storage_bucket_not_public import (
+ storage_bucket_not_public,
+ )
+
+ return storage_bucket_not_public().execute()
+
+
+class Test_storage_bucket_not_public:
+ def test_no_buckets(self):
+ storage_client = mock.MagicMock
+ storage_client.buckets = {}
+
+ assert len(_run(storage_client)) == 0
+
+ def test_private_bucket_passes(self):
+ storage_client = mock.MagicMock
+ storage_client.buckets = {"k": _bucket("invoices", public=False)}
+
+ result = _run(storage_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Bucket invoices in project {PROJECT_NAME} is private."
+ )
+ assert result[0].resource_id == f"{PROJECT_REF}:invoices"
+ assert result[0].resource_name == "invoices"
+ assert result[0].region == PROJECT_REGION
+
+ def test_public_bucket_fails(self):
+ storage_client = mock.MagicMock
+ storage_client.buckets = {"k": _bucket("avatars", public=True)}
+
+ result = _run(storage_client)
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Bucket avatars in project {PROJECT_NAME} is public, so every object is downloadable without authentication."
+ )
diff --git a/tests/providers/supabase/services/storage/supabase_storage_service_test.py b/tests/providers/supabase/services/storage/supabase_storage_service_test.py
new file mode 100644
index 0000000000..ccbc3e75ad
--- /dev/null
+++ b/tests/providers/supabase/services/storage/supabase_storage_service_test.py
@@ -0,0 +1,58 @@
+from unittest import mock
+
+import requests
+
+from prowler.providers.supabase.services.storage import storage_service
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_SLUG,
+ PROJECT_NAME,
+ PROJECT_REF,
+ set_mocked_supabase_provider,
+)
+
+
+def _response(status_code=200, payload=None):
+ response = mock.MagicMock(status_code=status_code, headers={})
+ response.json.return_value = payload
+ if status_code >= 400:
+ response.raise_for_status.side_effect = requests.HTTPError(
+ f"{status_code} error"
+ )
+ return response
+
+
+class TestStorageService:
+ def test_collects_buckets_with_visibility(self):
+ provider = set_mocked_supabase_provider()
+ routes = {
+ "/v1/projects": _response(
+ payload=[
+ {
+ "ref": PROJECT_REF,
+ "name": PROJECT_NAME,
+ "organization_slug": ORGANIZATION_SLUG,
+ }
+ ]
+ ),
+ f"/v1/projects/{PROJECT_REF}/storage/buckets": _response(
+ payload=[
+ {"id": "avatars", "name": "avatars", "public": True},
+ {"id": "invoices", "name": "invoices", "public": False},
+ ]
+ ),
+ }
+ provider.session.http_session.get.side_effect = lambda url, timeout=30: routes[
+ url.replace("https://api.supabase.com", "")
+ ]
+
+ service = storage_service.Storage(provider)
+
+ assert set(service.buckets) == {
+ f"{PROJECT_REF}:avatars",
+ f"{PROJECT_REF}:invoices",
+ }
+ avatars = service.buckets[f"{PROJECT_REF}:avatars"]
+ assert avatars.public is True
+ assert avatars.name == "avatars"
+ assert avatars.project_name == PROJECT_NAME
+ assert service.buckets[f"{PROJECT_REF}:invoices"].public is False
diff --git a/tests/providers/supabase/supabase_fixtures.py b/tests/providers/supabase/supabase_fixtures.py
index a1fe0685f1..98a975238c 100644
--- a/tests/providers/supabase/supabase_fixtures.py
+++ b/tests/providers/supabase/supabase_fixtures.py
@@ -11,6 +11,9 @@ ORGANIZATION_ID = "org-id"
ORGANIZATION_NAME = "Test Organization"
ORGANIZATION_SLUG = "test-organization"
USER_ID = "user-id"
+PROJECT_REF = "abcdefghijklmnopqrst"
+PROJECT_NAME = "storefront"
+PROJECT_REGION = "eu-west-1"
def set_mocked_supabase_provider():
diff --git a/tests/providers/supabase/supabase_mutelist_test.py b/tests/providers/supabase/supabase_mutelist_test.py
index d352edc3ac..c1c1f5a268 100644
--- a/tests/providers/supabase/supabase_mutelist_test.py
+++ b/tests/providers/supabase/supabase_mutelist_test.py
@@ -34,6 +34,7 @@ class TestSupabaseMutelist:
resource_id=resource_id,
resource_name=f"member {resource_id}",
resource_tags=[],
+ region="global",
)
finding.check_metadata.CheckID = "organizations_member_mfa_enabled"
diff --git a/tests/providers/supabase/supabase_provider_test.py b/tests/providers/supabase/supabase_provider_test.py
index 36d1cad896..112e097ea7 100644
--- a/tests/providers/supabase/supabase_provider_test.py
+++ b/tests/providers/supabase/supabase_provider_test.py
@@ -128,6 +128,25 @@ class TestSupabaseProvider:
assert arguments.provider == "supabase"
assert not hasattr(arguments, "supabase_access_token")
+ def test_cli_project_filter_is_passed_to_provider(self):
+ arguments = ProwlerArgumentParser().parse(
+ ["prowler", "supabase", "--project", "storefront", "abcdefghijklmnopqrst"]
+ )
+
+ assert arguments.project == ["storefront", "abcdefghijklmnopqrst"]
+
+ with (
+ mock.patch.object(SupabaseProvider, "setup_session"),
+ mock.patch.object(SupabaseProvider, "setup_identity"),
+ mock.patch(
+ "prowler.providers.supabase.supabase_provider.load_and_validate_config_file",
+ return_value={},
+ ),
+ ):
+ provider = SupabaseProvider.from_cli_args(arguments, fixer_config={})
+
+ assert provider.filter_projects == ["storefront", "abcdefghijklmnopqrst"]
+
def test_provider_registry_and_class_resolution(self):
assert ProviderName.SUPABASE.value == "supabase"
assert Provider.get_class("supabase") is SupabaseProvider
@@ -143,6 +162,7 @@ class TestSupabaseProviderOutputHooks:
organization_name=ORGANIZATION_NAME,
resource_name="member user-id",
resource_id="user-id",
+ region="global",
)
output = provider.get_finding_output_data(check_output)
@@ -251,4 +271,4 @@ class TestSupabaseProviderOutputHooks:
"Organization",
f"{ORGANIZATION_NAME} ({ORGANIZATION_SLUG})",
)
- assert provider.get_stdout_detail(mock.MagicMock()) == "global"
+ assert provider.get_stdout_detail(mock.MagicMock(region="global")) == "global"