feat(supabase): add project, auth, storage and database checks

- Read project SSL, network, Data API, backup and API key settings
- Evaluate Supabase Auth hardening from the project auth config
- Report public Storage buckets
- Surface Postgres posture from the Supabase security advisor
- Add project filtering, region-aware findings and configurable thresholds
This commit is contained in:
Toni de la Fuente committed 2026-10-23 11:03:31 -07:00
1 parent 4ab20d4489
commit 4b4175c495
157 files changed
+5156 -59

No files matched your search

+25
View File
@@ -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'
+1
View File
@@ -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 |
+1
View File
@@ -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
@@ -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.
@@ -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
@@ -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.
<Warning>
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.
</Warning>
## 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:
<Tip>
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.
</Tip>
- 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).
@@ -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.
<Warning>
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.
</Warning>
<Note>
Supabase is supported in Prowler SDK and Prowler CLI. Self-hosted Supabase and direct PostgreSQL access are not covered.
</Note>
## 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.
@@ -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
+9
View File
@@ -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:
+18
View File
@@ -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.",
)
+9 -3
View File
@@ -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
+4 -2
View File
@@ -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}
@@ -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.",
)
@@ -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)),
)
@@ -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()
+7
View File
@@ -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."""
Whitespace-only changes.
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
Whitespace-only changes.
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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())
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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
)
Whitespace-only changes.
@@ -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.<VIEW> 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."
}
@@ -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
@@ -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())
@@ -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 <EXTENSION>; CREATE EXTENSION <EXTENSION> WITH SCHEMA extensions;",
"NativeIaC": "",
"Other": "1. Identify the extension from the finding\n2. Reinstall it with `CREATE EXTENSION <name> 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."
}
@@ -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
@@ -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.<FUNCTION>() SET search_path = '';",
"NativeIaC": "",
"Other": "1. Identify the function from the finding\n2. Run `ALTER FUNCTION <schema>.<function>(<args>) 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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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.<TABLE> 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."
}
@@ -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
@@ -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.<TABLE> 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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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
@@ -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.<VIEW> SET (security_invoker = on);",
"NativeIaC": "",
"Other": "1. Identify the view from the finding\n2. Run `ALTER VIEW <schema>.<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."
}
@@ -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
Whitespace-only changes.
@@ -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())
@@ -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": ""
}
@@ -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
@@ -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 <PROJECT_REF> --db-allow-cidr <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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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 <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\" \"<example_resource_name>\" {\n project_ref = \"<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": ""
}
@@ -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
@@ -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
)
@@ -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 <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": ""
}
@@ -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
Whitespace-only changes.
@@ -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."
}
@@ -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
@@ -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())
@@ -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
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more