mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-16 00:14:23 +00:00
feat(sdk): check Supabase organization member MFA
This commit is contained in:
11 files changed
+301
No files matched your search
@@ -465,6 +465,13 @@
|
||||
"user-guide/providers/stackit/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Supabase",
|
||||
"pages": [
|
||||
"user-guide/providers/supabase/getting-started-supabase",
|
||||
"user-guide/providers/supabase/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Vercel",
|
||||
"pages": [
|
||||
|
||||
@@ -72,6 +72,7 @@ Prowler supports a wide range of providers organized by category:
|
||||
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI |
|
||||
| [Supabase](/user-guide/providers/supabase/getting-started-supabase) | PoC | Organizations / Members | CLI |
|
||||
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI |
|
||||
|
||||
### Kubernetes
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## Personal Access Token Security
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
|
||||
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.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
This proof of concept does not support:
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<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>
|
||||
|
||||
## 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.
|
||||
|
||||
See [Supabase Authentication in Prowler](./authentication) for PAT setup and security guidance.
|
||||
|
||||
## Run a Scan
|
||||
|
||||
Set the PAT through the environment and run Prowler:
|
||||
|
||||
```bash
|
||||
export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
|
||||
prowler supabase
|
||||
```
|
||||
|
||||
To run only the organization member MFA check:
|
||||
|
||||
```bash
|
||||
prowler supabase --check organizations_member_mfa_enabled
|
||||
```
|
||||
|
||||
## Checks Shipped
|
||||
|
||||
| Check ID | Severity | Description |
|
||||
|---|---|---|
|
||||
| `organizations_member_mfa_enabled` | High | Creates one finding per organization member and fails members whose Supabase account does not have MFA enabled. |
|
||||
|
||||
The finding resource UID is the Management API `user_id`. Prowler does not include member email addresses in findings or normal logs.
|
||||
@@ -0,0 +1 @@
|
||||
Supabase provider with the `organizations_member_mfa_enabled` check
|
||||
@@ -0,0 +1,6 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.supabase.services.organizations.organizations_service import (
|
||||
Organizations,
|
||||
)
|
||||
|
||||
organizations_client = Organizations(Provider.get_global_provider())
|
||||
Whitespace-only changes.
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "supabase",
|
||||
"CheckID": "organizations_member_mfa_enabled",
|
||||
"CheckTitle": "Supabase organization members have multi-factor authentication enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "organizations",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "This check verifies whether each Supabase organization member has multi-factor authentication enabled for their Supabase account.",
|
||||
"Risk": "Without multi-factor authentication, compromised credentials can allow unauthorized access to Supabase organizations, projects, and management operations available to the member.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://supabase.com/docs/reference/api/v1-list-organization-members",
|
||||
"https://supabase.com/docs/guides/platform/multi-factor-authentication"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "Ask the organization member to open Supabase Dashboard > Account Settings > Security and enroll a TOTP factor. Organization owners on eligible plans can also enforce MFA for all members.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Require every organization member to enroll multi-factor authentication and periodically review membership for accounts that do not meet this requirement.",
|
||||
"Url": "https://hub.prowler.com/check/organizations_member_mfa_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
from prowler.lib.check.models import Check, CheckReportSupabase
|
||||
from prowler.providers.supabase.services.organizations.organizations_client import (
|
||||
organizations_client,
|
||||
)
|
||||
|
||||
|
||||
class organizations_member_mfa_enabled(Check):
|
||||
"""Check whether each Supabase organization member has MFA enabled."""
|
||||
|
||||
def execute(self) -> list[CheckReportSupabase]:
|
||||
"""Return one MFA finding per organization member."""
|
||||
findings = []
|
||||
for member in organizations_client.members.values():
|
||||
report = CheckReportSupabase(metadata=self.metadata(), resource=member)
|
||||
if member.mfa_enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Supabase organization {member.organization_slug} member "
|
||||
f"{member.id} has MFA enabled."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Supabase organization {member.organization_slug} member "
|
||||
f"{member.id} does not have MFA enabled."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.supabase.services.organizations.organizations_service import (
|
||||
SupabaseOrganizationMember,
|
||||
)
|
||||
from tests.providers.supabase.supabase_fixtures import (
|
||||
ORGANIZATION_NAME,
|
||||
ORGANIZATION_SLUG,
|
||||
USER_ID,
|
||||
set_mocked_supabase_provider,
|
||||
)
|
||||
|
||||
|
||||
class Test_organizations_member_mfa_enabled:
|
||||
def _execute(self, members):
|
||||
organizations_client = mock.MagicMock()
|
||||
organizations_client.members = members
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_supabase_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled.organizations_client",
|
||||
new=organizations_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled import (
|
||||
organizations_member_mfa_enabled,
|
||||
)
|
||||
|
||||
return organizations_member_mfa_enabled().execute()
|
||||
|
||||
def test_no_members(self):
|
||||
assert self._execute({}) == []
|
||||
|
||||
def test_member_with_mfa_passes_without_email(self):
|
||||
member = SupabaseOrganizationMember(
|
||||
id=USER_ID,
|
||||
name=f"member {USER_ID}",
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
mfa_enabled=True,
|
||||
)
|
||||
|
||||
result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member})
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == USER_ID
|
||||
assert result[0].organization_slug == ORGANIZATION_SLUG
|
||||
assert result[0].status_extended == (
|
||||
f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} has MFA enabled."
|
||||
)
|
||||
|
||||
def test_member_without_mfa_fails(self):
|
||||
member = SupabaseOrganizationMember(
|
||||
id=USER_ID,
|
||||
name=f"member {USER_ID}",
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
mfa_enabled=False,
|
||||
)
|
||||
|
||||
result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member})
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == USER_ID
|
||||
assert result[0].status_extended == (
|
||||
f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} does not have MFA enabled."
|
||||
)
|
||||
|
||||
def test_multiple_members_return_independent_pass_and_fail_findings(self):
|
||||
enabled_member = SupabaseOrganizationMember(
|
||||
id="enabled-user",
|
||||
name="member enabled-user",
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
mfa_enabled=True,
|
||||
)
|
||||
disabled_member = SupabaseOrganizationMember(
|
||||
id="disabled-user",
|
||||
name="member disabled-user",
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
mfa_enabled=False,
|
||||
)
|
||||
|
||||
result = self._execute(
|
||||
{
|
||||
f"{ORGANIZATION_SLUG}:enabled-user": enabled_member,
|
||||
f"{ORGANIZATION_SLUG}:disabled-user": disabled_member,
|
||||
}
|
||||
)
|
||||
|
||||
assert [(finding.resource_id, finding.status) for finding in result] == [
|
||||
("enabled-user", "PASS"),
|
||||
("disabled-user", "FAIL"),
|
||||
]
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.config.config import Provider as ProviderName
|
||||
from prowler.lib.check.models import CheckReportSupabase
|
||||
from prowler.lib.cli.parser import ProwlerArgumentParser
|
||||
from prowler.lib.outputs.finding import Finding
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.supabase.exceptions.exceptions import (
|
||||
@@ -15,12 +18,16 @@ from prowler.providers.supabase.exceptions.exceptions import (
|
||||
SupabaseRateLimitError,
|
||||
)
|
||||
from prowler.providers.supabase.models import SupabaseOrganization, SupabaseSession
|
||||
from prowler.providers.supabase.services.organizations.organizations_service import (
|
||||
SupabaseOrganizationMember,
|
||||
)
|
||||
from prowler.providers.supabase.supabase_provider import SupabaseProvider
|
||||
from tests.providers.supabase.supabase_fixtures import (
|
||||
ACCESS_TOKEN,
|
||||
ORGANIZATION_ID,
|
||||
ORGANIZATION_NAME,
|
||||
ORGANIZATION_SLUG,
|
||||
USER_ID,
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +156,37 @@ class TestSupabaseProviderOutputHooks:
|
||||
"region": "global",
|
||||
}
|
||||
|
||||
def test_finding_output_pipeline_uses_supabase_fields(self):
|
||||
provider = SupabaseProvider.__new__(SupabaseProvider)
|
||||
provider._identity = mock.MagicMock(organizations=[])
|
||||
member = SupabaseOrganizationMember(
|
||||
id=USER_ID,
|
||||
name=f"member {USER_ID}",
|
||||
organization_slug=ORGANIZATION_SLUG,
|
||||
organization_name=ORGANIZATION_NAME,
|
||||
mfa_enabled=False,
|
||||
)
|
||||
metadata = Path(
|
||||
"prowler/providers/supabase/services/organizations/"
|
||||
"organizations_member_mfa_enabled/"
|
||||
"organizations_member_mfa_enabled.metadata.json"
|
||||
).read_text()
|
||||
check_output = CheckReportSupabase(metadata=metadata, resource=member)
|
||||
check_output.status = "FAIL"
|
||||
check_output.status_extended = "Member does not have MFA enabled."
|
||||
|
||||
finding = Finding.generate_output(
|
||||
provider, check_output, Namespace(unix_timestamp=False)
|
||||
)
|
||||
|
||||
assert finding.provider == "supabase"
|
||||
assert finding.account_uid == ORGANIZATION_SLUG
|
||||
assert finding.account_name == ORGANIZATION_NAME
|
||||
assert finding.resource_name == f"member {USER_ID}"
|
||||
assert finding.resource_uid == USER_ID
|
||||
assert finding.region == "global"
|
||||
assert finding.auth_method == "personal_access_token"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output_filename", "expected"),
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user