feat(api): add Cloudflare provider support (#9907)

This commit is contained in:
Hugo Pereira Brito
2026-02-02 14:08:33 +01:00
committed by GitHub
parent d01f399cb2
commit b1f9971617
13 changed files with 508 additions and 68 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🚀 Added
- Cloudflare provider support [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907)
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
@@ -0,0 +1,38 @@
# Generated by Django migration for Cloudflare provider support
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0074_findings_fail_new_index_parent"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("mongodbatlas", "MongoDB Atlas"),
("iac", "IaC"),
("oraclecloud", "Oracle Cloud Infrastructure"),
("alibabacloud", "Alibaba Cloud"),
("cloudflare", "Cloudflare"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'cloudflare';",
reverse_sql=migrations.RunSQL.noop,
),
]
+10
View File
@@ -287,6 +287,7 @@ class Provider(RowLevelSecurityProtectedModel):
IAC = "iac", _("IaC")
ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure")
ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud")
CLOUDFLARE = "cloudflare", _("Cloudflare")
@staticmethod
def validate_aws_uid(value):
@@ -400,6 +401,15 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
@staticmethod
def validate_cloudflare_uid(value):
if not re.match(r"^[a-f0-9]{32}$", value):
raise ModelValidationError(
detail="Cloudflare Account ID must be a 32-character hexadecimal string.",
code="cloudflare-uid",
pointer="/data/attributes/uid",
)
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
File diff suppressed because it is too large Load Diff
+6
View File
@@ -20,6 +20,7 @@ from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudPro
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.iac.iac_provider import IacProvider
@@ -118,6 +119,7 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.ORACLECLOUD.value, OraclecloudProvider),
(Provider.ProviderChoices.IAC.value, IacProvider),
(Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider),
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -221,6 +223,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.MONGODBATLAS.value,
{"atlas_organization_id": "provider_uid"},
),
(
Provider.ProviderChoices.CLOUDFLARE.value,
{"filter_accounts": ["provider_uid"]},
),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
+66 -4
View File
@@ -1174,6 +1174,11 @@ class TestProviderViewSet:
"uid": "1234567890123456",
"alias": "Alibaba Cloud Account",
},
{
"provider": "cloudflare",
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"alias": "Cloudflare Account",
},
]
),
)
@@ -1553,6 +1558,46 @@ class TestProviderViewSet:
"alibabacloud-uid",
"uid",
),
# Cloudflare UID validation - too short (not 32 hex chars)
(
{
"provider": "cloudflare",
"uid": "abc123",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - uppercase hex (must be lowercase)
(
{
"provider": "cloudflare",
"uid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - non-hex characters
(
{
"provider": "cloudflare",
"uid": "g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - too long (33 chars)
(
{
"provider": "cloudflare",
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
]
),
)
@@ -1726,21 +1771,21 @@ class TestProviderViewSet:
(
"uid.icontains",
"1",
8,
9,
),
("alias", "aws_testing_1", 1),
("alias.icontains", "aws", 2),
("inserted_at", TODAY, 9),
("inserted_at", TODAY, 10),
(
"inserted_at.gte",
"2024-01-01",
9,
10,
),
("inserted_at.lte", "2024-01-01", 0),
(
"updated_at.gte",
"2024-01-01",
9,
10,
),
("updated_at.lte", "2024-01-01", 0),
]
@@ -2330,6 +2375,23 @@ class TestProviderSecretViewSet:
"role_session_name": "ProwlerAuditSession",
},
),
# Cloudflare with API Token
(
Provider.ProviderChoices.CLOUDFLARE.value,
ProviderSecret.TypeChoices.STATIC,
{
"api_token": "fake-cloudflare-api-token-for-testing",
},
),
# Cloudflare with API Key + Email
(
Provider.ProviderChoices.CLOUDFLARE.value,
ProviderSecret.TypeChoices.STATIC,
{
"api_key": "fake-cloudflare-api-key-for-testing",
"api_email": "user@example.com",
},
),
],
)
def test_provider_secrets_create_valid(
+15 -2
View File
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
)
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.iac.iac_provider import IacProvider
@@ -91,7 +92,7 @@ def return_prowler_provider(
provider (Provider): The provider object containing the provider type and associated secrets.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
Raises:
ValueError: If the provider type specified in `provider.provider` is not supported.
@@ -145,6 +146,12 @@ def return_prowler_provider(
)
prowler_provider = AlibabacloudProvider
case Provider.ProviderChoices.CLOUDFLARE.value:
from prowler.providers.cloudflare.cloudflare_provider import (
CloudflareProvider,
)
prowler_provider = CloudflareProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -196,6 +203,11 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"atlas_organization_id": provider.uid,
}
elif provider.provider == Provider.ProviderChoices.CLOUDFLARE.value:
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"filter_accounts": [provider.uid],
}
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
@@ -213,6 +225,7 @@ def initialize_prowler_provider(
AlibabacloudProvider
| AwsProvider
| AzureProvider
| CloudflareProvider
| GcpProvider
| GithubProvider
| IacProvider
@@ -228,7 +241,7 @@ def initialize_prowler_provider(
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
initialized with the provider's secrets.
"""
prowler_provider = return_prowler_provider(provider)
@@ -346,6 +346,33 @@ from rest_framework_json_api import serializers
},
"required": ["role_arn", "access_key_id", "access_key_secret"],
},
{
"type": "object",
"title": "Cloudflare API Token",
"properties": {
"api_token": {
"type": "string",
"description": "Cloudflare API Token for authentication (recommended).",
},
},
"required": ["api_token"],
},
{
"type": "object",
"title": "Cloudflare API Key + Email",
"properties": {
"api_key": {
"type": "string",
"description": "Cloudflare Global API Key for authentication (legacy).",
},
"api_email": {
"type": "string",
"format": "email",
"description": "Email address associated with the Cloudflare account.",
},
},
"required": ["api_key", "api_email"],
},
]
}
)
+27
View File
@@ -1503,6 +1503,18 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = MongoDBAtlasProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.ALIBABACLOUD.value:
serializer = AlibabaCloudProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.CLOUDFLARE.value:
if "api_token" in secret:
serializer = CloudflareTokenProviderSecret(data=secret)
elif "api_key" in secret and "api_email" in secret:
serializer = CloudflareApiKeyProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{
"secret": "Cloudflare credentials must include either 'api_token' "
"or both 'api_key' and 'api_email'."
}
)
else:
raise serializers.ValidationError(
{"provider": f"Provider type not supported {provider_type}"}
@@ -1654,6 +1666,21 @@ class OracleCloudProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class CloudflareTokenProviderSecret(serializers.Serializer):
api_token = serializers.CharField()
class Meta:
resource_name = "provider-secrets"
class CloudflareApiKeyProviderSecret(serializers.Serializer):
api_key = serializers.CharField()
api_email = serializers.EmailField()
class Meta:
resource_name = "provider-secrets"
class AlibabaCloudProviderSecret(serializers.Serializer):
access_key_id = serializers.CharField()
access_key_secret = serializers.CharField()
+13 -8
View File
@@ -1,11 +1,9 @@
import logging
from types import SimpleNamespace
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from allauth.socialaccount.models import SocialLogin
from django.conf import settings
from django.db import connection as django_connection
@@ -14,6 +12,11 @@ from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.test import APIClient
from tasks.jobs.backfill import (
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
)
from api.attack_paths import (
AttackPathsQueryDefinition,
@@ -59,11 +62,6 @@ from api.rls import Tenant
from api.v1.serializers import TokenSerializer
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.backfill import (
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
)
TODAY = str(datetime.today().date())
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
@@ -533,6 +531,12 @@ def providers_fixture(tenants_fixture):
alias="alibabacloud_testing",
tenant_id=tenant.id,
)
provider10 = Provider.objects.create(
provider="cloudflare",
uid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
alias="cloudflare_testing",
tenant_id=tenant.id,
)
return (
provider1,
@@ -544,6 +548,7 @@ def providers_fixture(tenants_fixture):
provider7,
provider8,
provider9,
provider10,
)
+1
View File
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `rds_instance_extended_support` check for AWS provider [(#9865)](https://github.com/prowler-cloud/prowler/pull/9865)
- `OpenStack` provider support with Compute service including 1 security check [(#9811)](https://github.com/prowler-cloud/prowler/pull/9811)
- `OpenStack` documentation for the support in the CLI [(#9848)](https://github.com/prowler-cloud/prowler/pull/9848)
- Cloudflare provider credentials as constructor parameters (`api_token`, `api_key`, `api_email`) [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907)
### Changed
@@ -49,6 +49,9 @@ class CloudflareProvider(Provider):
fixer_config: dict = {},
mutelist_path: str = None,
mutelist_content: dict = None,
api_token: str = None,
api_key: str = None,
api_email: str = None,
):
logger.info("Instantiating Cloudflare provider...")
@@ -61,7 +64,12 @@ class CloudflareProvider(Provider):
max_retries = self._audit_config.get("max_retries", 2)
self._session = CloudflareProvider.setup_session(max_retries=max_retries)
self._session = CloudflareProvider.setup_session(
max_retries=max_retries,
api_token=api_token,
api_key=api_key,
api_email=api_email,
)
self._identity = CloudflareProvider.setup_identity(self._session)
@@ -135,19 +143,28 @@ class CloudflareProvider(Provider):
return self._identity.accounts
@staticmethod
def setup_session(max_retries: int = 2) -> CloudflareSession:
def setup_session(
max_retries: int = 2,
api_token: str = None,
api_key: str = None,
api_email: str = None,
) -> CloudflareSession:
"""Initialize Cloudflare SDK client.
Credentials are read from environment variables:
Credentials can be provided as arguments or read from environment variables:
- CLOUDFLARE_API_TOKEN (recommended)
- CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL (legacy)
Args:
max_retries: Maximum number of retries for API requests (default is 2).
api_token: Cloudflare API token (optional, falls back to env var).
api_key: Cloudflare API key (optional, falls back to env var).
api_email: Cloudflare API email (optional, falls back to env var).
"""
token = os.environ.get("CLOUDFLARE_API_TOKEN", "")
key = os.environ.get("CLOUDFLARE_API_KEY", "")
email = os.environ.get("CLOUDFLARE_API_EMAIL", "")
# Use provided credentials or fall back to environment variables
token = api_token or os.environ.get("CLOUDFLARE_API_TOKEN", "")
key = api_key or os.environ.get("CLOUDFLARE_API_KEY", "")
email = api_email or os.environ.get("CLOUDFLARE_API_EMAIL", "")
# Warn if both auth methods are set, use API Token (recommended)
if token and key and email:
@@ -293,14 +310,42 @@ class CloudflareProvider(Provider):
print_boxes(report_lines, report_title)
def test_connection(self) -> Connection:
@staticmethod
def test_connection(
api_token: str = None,
api_key: str = None,
api_email: str = None,
raise_on_exception: bool = True,
provider_id: str = None,
) -> Connection:
"""Test connection to Cloudflare.
Test the connection to Cloudflare using the provided credentials.
Args:
api_token: Cloudflare API token (optional, falls back to env var).
api_key: Cloudflare API key (optional, falls back to env var).
api_email: Cloudflare API email (optional, falls back to env var).
raise_on_exception: Flag indicating whether to raise an exception if the connection fails.
provider_id: The provider ID (Cloudflare account ID).
Returns:
Connection: Connection object with is_connected status.
"""
try:
_ = self._session.client.user.get()
session = CloudflareProvider.setup_session(
api_token=api_token,
api_key=api_key,
api_email=api_email,
)
_ = session.client.user.get()
return Connection(is_connected=True)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if raise_on_exception:
raise error
return Connection(is_connected=False, error=error)
def validate_arguments(self) -> None:
@@ -149,7 +149,7 @@ class Zone(CloudflareService):
def _get_zones_universal_ssl(self) -> None:
"""Get Universal SSL settings for all zones."""
logger.info("Zones - Getting Universal SSL settings...")
logger.info("Zone - Getting Universal SSL settings...")
for zone in self.zones.values():
try:
universal_ssl = self.client.ssl.universal.settings.get(zone_id=zone.id)
@@ -220,7 +220,7 @@ class Zone(CloudflareService):
def _get_zones_firewall_rules(self) -> None:
"""Get firewall rules for all zones."""
logger.info("Zones - Getting firewall rules...")
logger.info("Zone - Getting firewall rules...")
for zone in self.zones.values():
try:
self._get_zone_firewall_rules(zone)
@@ -284,7 +284,7 @@ class Zone(CloudflareService):
def _get_zones_waf_rulesets(self) -> None:
"""Get WAF rulesets for all zones."""
logger.info("Zones - Getting WAF rulesets...")
logger.info("Zone - Getting WAF rulesets...")
for zone in self.zones.values():
try:
self._get_zone_waf_rulesets(zone)