diff --git a/docs/docs.json b/docs/docs.json
index f8c20a258a..1aa7c96fa6 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -248,6 +248,13 @@
"user-guide/providers/mongodbatlas/authentication"
]
},
+ {
+ "group": "Cloudflare",
+ "pages": [
+ "user-guide/providers/cloudflare/getting-started-cloudflare",
+ "user-guide/providers/cloudflare/authentication"
+ ]
+ },
{
"group": "LLM",
"pages": [
diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx
new file mode 100644
index 0000000000..0f4bbb8de6
--- /dev/null
+++ b/docs/user-guide/providers/cloudflare/authentication.mdx
@@ -0,0 +1,146 @@
+---
+title: 'Cloudflare Authentication'
+---
+
+Prowler for Cloudflare supports the following authentication methods:
+
+- [**API Token**](#api-token-recommended) (**Recommended**)
+- [**API Key and Email (Legacy)**](#api-key-and-email-legacy)
+
+## Required Permissions
+
+Prowler requires read-only access to your Cloudflare zones and their settings. The following permissions are needed:
+
+| Permission | Description |
+|------------|-------------|
+| `Zone:Read` | Read access to zone settings and configurations |
+| `Zone Settings:Read` | Read access to zone security settings (SSL/TLS, HSTS, etc.) |
+| `DNS:Read` | Read access to DNS records (for DNSSEC checks) |
+
+
+Ensure your API Token or API Key has access to all zones you want to scan. If permissions are missing, some checks may fail or return incomplete results.
+
+
+## API Token (Recommended)
+
+API Tokens are the recommended authentication method because they:
+- Can be scoped to specific permissions and zones
+- Are more secure than global API keys
+- Can be easily rotated without affecting other integrations
+
+### Step 1: Create an API Token
+
+1. **Log into Cloudflare Dashboard**
+ - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in
+
+2. **Navigate to API Tokens**
+ - Click on your profile icon in the top right corner
+ - Select **My Profile**
+ - Click on the **API Tokens** tab
+
+3. **Create a Custom Token**
+ - Click **Create Token**
+ - Select **Create Custom Token** (at the bottom)
+
+4. **Configure Token Permissions**
+
+ Give your token a descriptive name (e.g., "Prowler Security Scanner") and add the [required permissions](#required-permissions) listed above.
+
+5. **Set Zone Resources**
+ - Under **Zone Resources**, select either:
+ - **Include → All zones** (to scan all zones in your account)
+ - **Include → Specific zone** (to limit access to specific zones)
+
+6. **Create and Copy Token**
+ - Click **Continue to summary**
+ - Review the permissions and click **Create Token**
+ - **Copy the token immediately** - Cloudflare will only show it once
+
+### Step 2: Store the Token Securely
+
+Store your API token as an environment variable:
+
+```bash
+export CLOUDFLARE_API_TOKEN="your-api-token-here"
+```
+
+
+Never commit API tokens to version control or share them in plain text. Use environment variables or a secrets manager.
+
+
+## API Key and Email (Legacy)
+
+API Keys provide full access to your Cloudflare account. While supported, this method is less secure than API Tokens because it grants broader permissions.
+
+### Step 1: Get Your API Key
+
+1. **Log into Cloudflare Dashboard**
+ - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in
+
+2. **Navigate to API Tokens**
+ - Click on your profile icon in the top right corner
+ - Select **My Profile**
+ - Click on the **API Tokens** tab
+
+3. **View Global API Key**
+ - Scroll down to the **API Keys** section
+ - Click **View** next to **Global API Key**
+ - Enter your password to reveal the key
+ - Copy the API key
+
+### Step 2: Store Credentials Securely
+
+Store both your API key and email as environment variables:
+
+```bash
+export CLOUDFLARE_API_KEY="your-api-key-here"
+export CLOUDFLARE_API_EMAIL="your-email@example.com"
+```
+
+
+The email must be the same email address used to log into your Cloudflare account.
+
+
+## Best Practices
+
+### Security Recommendations
+
+- **Use API Tokens instead of API Keys** - Tokens can be scoped to specific permissions
+- **Use environment variables** - Never hardcode credentials in scripts or commands
+- **Rotate credentials regularly** - Create new tokens periodically and revoke old ones
+- **Use least privilege** - Only grant the minimum permissions needed
+- **Monitor token usage** - Review the Cloudflare audit log for suspicious activity
+
+
+**Use only one authentication method at a time.** If both API Token and API Key + Email are set, Prowler will use the API Token and log an error message.
+
+
+## Troubleshooting
+
+### "Missing X-Auth-Email header" Error
+
+This error occurs when using API Key authentication without providing the email address. Ensure both `CLOUDFLARE_API_KEY` and `CLOUDFLARE_API_EMAIL` are set.
+
+### "Authentication error" or "Permission denied"
+
+- Verify your API Token or API Key is correct and not expired
+- Check that your token has the [required permissions](#required-permissions)
+- Ensure your token has access to the zones you're trying to scan
+
+### "Both API Token and API Key and Email credentials are set"
+
+This warning appears when all three environment variables are set:
+- `CLOUDFLARE_API_TOKEN`
+- `CLOUDFLARE_API_KEY`
+- `CLOUDFLARE_API_EMAIL`
+
+To resolve, unset the credentials you don't want to use:
+
+```bash
+# To use API Token only (recommended)
+unset CLOUDFLARE_API_KEY
+unset CLOUDFLARE_API_EMAIL
+
+# Or to use API Key and Email only
+unset CLOUDFLARE_API_TOKEN
+```
diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
new file mode 100644
index 0000000000..e32258eda3
--- /dev/null
+++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
@@ -0,0 +1,104 @@
+---
+title: 'Getting Started with Cloudflare'
+---
+
+Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more.
+
+## Prerequisites
+
+Before running Prowler with the Cloudflare provider, ensure you have:
+
+1. A Cloudflare account with at least one zone
+2. One of the following authentication methods configured (see [Authentication](/user-guide/providers/cloudflare/authentication)):
+ - An **API Token** (recommended)
+ - An **API Key + Email** (legacy)
+
+## Quick Start
+
+### Step 1: Set Up Authentication
+
+The recommended method is using an API Token via environment variable:
+
+```bash
+export CLOUDFLARE_API_TOKEN="your-api-token-here"
+```
+
+Alternatively, use API Key + Email:
+
+```bash
+export CLOUDFLARE_API_KEY="your-api-key-here"
+export CLOUDFLARE_API_EMAIL="your-email@example.com"
+```
+
+### Step 2: Run Prowler
+
+Run a scan across all your Cloudflare zones:
+
+```bash
+prowler cloudflare
+```
+
+That's it! Prowler will automatically discover all zones in your account and run security checks against them.
+
+## Authentication
+
+Prowler reads Cloudflare credentials from environment variables. Set your credentials before running Prowler:
+
+**API Token (Recommended):**
+```bash
+export CLOUDFLARE_API_TOKEN="your-api-token-here"
+prowler cloudflare
+```
+
+**API Key + Email (Legacy):**
+```bash
+export CLOUDFLARE_API_KEY="your-api-key-here"
+export CLOUDFLARE_API_EMAIL="your-email@example.com"
+prowler cloudflare
+```
+
+## Filtering Zones
+
+By default, Prowler scans all zones accessible with your credentials:
+
+```bash
+prowler cloudflare
+```
+
+To scan only specific zones, use the `-f`, `--region`, or `--filter-region` argument:
+
+```bash
+prowler cloudflare -f example.com
+```
+
+You can specify multiple zones:
+
+```bash
+prowler cloudflare -f example.com example.org
+```
+
+You can also use zone IDs instead of domain names:
+
+```bash
+prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353
+```
+
+## Configuration
+
+Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes:
+
+```yaml
+cloudflare:
+ # Maximum number of retries for API requests (default is 2)
+ max_retries: 2
+```
+
+To use a custom configuration:
+
+```bash
+prowler cloudflare --config-file /path/to/config.yaml
+```
+
+## Next Steps
+
+- [Authentication](/user-guide/providers/cloudflare/authentication) - Detailed guide on creating API tokens and keys
diff --git a/poetry.lock b/poetry.lock
index 3189aa7b0f..a04d0f80a9 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -1878,6 +1878,26 @@ click = ">=4.0"
[package.extras]
dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"]
+[[package]]
+name = "cloudflare"
+version = "4.3.1"
+description = "The official Python library for the cloudflare API"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"},
+ {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"},
+]
+
+[package.dependencies]
+anyio = ">=3.5.0,<5"
+distro = ">=1.7.0,<2"
+httpx = ">=0.23.0,<1"
+pydantic = ">=1.9.0,<3"
+sniffio = "*"
+typing-extensions = ">=4.10,<5"
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -2168,6 +2188,18 @@ files = [
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
+[[package]]
+name = "distro"
+version = "1.9.0"
+description = "Distro - an OS platform information API"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
+ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
+]
+
[[package]]
name = "dnspython"
version = "2.7.0"
@@ -5581,7 +5613,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
@@ -5590,7 +5621,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
@@ -5599,7 +5629,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
@@ -5608,7 +5637,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
@@ -5617,7 +5645,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
@@ -6526,4 +6553,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
-content-hash = "1559a8799915bf0372eef07396e1dc40802911ef07ae92997cd260d9fe596ba3"
+content-hash = "841d93c4db73d37dbc83d2b252bb022917974dc44fc4cca6eb60c41288ad49d9"
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 765e895bfa..cff4d258e7 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `ResourceGroup` field to all check metadata for resource classification [(#9656)](https://github.com/prowler-cloud/prowler/pull/9656)
- `compute_configuration_changes` check for GCP provider to detect Compute Engine configuration changes in Cloud Audit Logs [(#9698)](https://github.com/prowler-cloud/prowler/pull/9698)
- `compute_instance_group_load_balancer_attached` check for GCP provider [(#9695)](https://github.com/prowler-cloud/prowler/pull/9695)
+- `Cloudflare` provider with critical security checks [(#9423)](https://github.com/prowler-cloud/prowler/pull/9423)
- `compute_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702)
- `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718)
- CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778)
diff --git a/prowler/__main__.py b/prowler/__main__.py
index a6be311335..47db06d21f 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -113,6 +113,7 @@ from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
from prowler.providers.aws.models import AWSOutputOptions
from prowler.providers.azure.models import AzureOutputOptions
+from prowler.providers.cloudflare.models import CloudflareOutputOptions
from prowler.providers.common.provider import Provider
from prowler.providers.common.quick_inventory import run_provider_quick_inventory
from prowler.providers.gcp.models import GCPOutputOptions
@@ -332,6 +333,10 @@ def prowler():
output_options = GithubOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "cloudflare":
+ output_options = CloudflareOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
elif provider == "m365":
output_options = M365OutputOptions(
args, bulk_checks_metadata, global_provider.identity
diff --git a/prowler/compliance/cloudflare/__init__.py b/prowler/compliance/cloudflare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/config/cloudflare_mutelist_example.yaml b/prowler/config/cloudflare_mutelist_example.yaml
new file mode 100644
index 0000000000..dc7bb4c91e
--- /dev/null
+++ b/prowler/config/cloudflare_mutelist_example.yaml
@@ -0,0 +1,18 @@
+### Account, Check and/or Region can be * to apply for all the cases.
+### Account ==
+### Region == (use * for all zones)
+### Resources and tags are lists that can have either Regex or Keywords.
+### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
+### Use an alternation Regex to match one of multiple tags with "ORed" logic.
+### For each check you can except Accounts, Regions, Resources and/or Tags.
+########################### MUTELIST EXAMPLE ###########################
+Mutelist:
+ Accounts:
+ "example-account-id":
+ Checks:
+ "zones_dnssec_enabled":
+ Regions:
+ - "*"
+ Resources:
+ - "example-zone-id"
+ - "another-zone-id"
diff --git a/prowler/config/config.py b/prowler/config/config.py
index e7c54c4a9d..a8ce986457 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -53,6 +53,7 @@ class Provider(str, Enum):
AWS = "aws"
GCP = "gcp"
AZURE = "azure"
+ CLOUDFLARE = "cloudflare"
KUBERNETES = "kubernetes"
M365 = "m365"
GITHUB = "github"
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index 43421cf57d..ab5cce8ba9 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -592,3 +592,9 @@ github:
mongodbatlas:
# mongodbatlas.organizations_service_account_secrets_expiration --> Maximum hours for service account secrets validity
max_service_account_secret_validity_hours: 8
+
+# Cloudflare Configuration
+cloudflare:
+ # Maximum number of retries for API requests (default is 2)
+ # Set to 0 to disable retries
+ max_retries: 3
diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py
index 42c812c1e1..cb04e3d69d 100644
--- a/prowler/lib/check/check.py
+++ b/prowler/lib/check/check.py
@@ -688,6 +688,8 @@ def execute(
global_provider.identity.account_id
)
for finding in check_findings:
+ if global_provider.type == "cloudflare":
+ is_finding_muted_args["account_id"] = finding.account_id
if global_provider.type == "azure":
is_finding_muted_args["subscription_id"] = (
global_provider.identity.subscriptions.get(finding.subscription)
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index 8185ca1031..44e0078754 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -728,6 +728,74 @@ class CheckReportGithub(Check_Report):
)
+@dataclass
+class CheckReportCloudflare(Check_Report):
+ """Contains the Cloudflare Check's finding information.
+
+ Cloudflare is a global service - zones are resources, not regional contexts.
+ All zone-related attributes are derived from the zone object passed as resource.
+ """
+
+ resource_name: str
+ resource_id: str
+ _zone: Any # CloudflareZone object
+
+ def __init__(
+ self,
+ metadata: Dict,
+ resource: Any,
+ resource_name: str = None,
+ resource_id: str = None,
+ ) -> None:
+ """Initialize the Cloudflare Check's finding information.
+
+ Args:
+ metadata: Check metadata dictionary
+ resource: The CloudflareZone resource being checked
+ resource_name: Override for resource name
+ resource_id: Override for resource ID
+ """
+ super().__init__(metadata, resource)
+
+ # Zone is the resource being checked
+ self._zone = resource
+
+ self.resource_name = resource_name or getattr(
+ resource, "name", getattr(resource, "resource_name", "")
+ )
+ self.resource_id = resource_id or getattr(
+ resource, "id", getattr(resource, "resource_id", "")
+ )
+
+ @property
+ def zone(self) -> Any:
+ """The CloudflareZone object."""
+ return self._zone
+
+ @property
+ def zone_id(self) -> str:
+ """Zone ID."""
+ return getattr(self._zone, "id", "")
+
+ @property
+ def zone_name(self) -> str:
+ """Zone name."""
+ return getattr(self._zone, "name", "")
+
+ @property
+ def account_id(self) -> str:
+ """Account ID derived from zone's account."""
+ zone_account = getattr(self._zone, "account", None)
+ if zone_account:
+ return getattr(zone_account, "id", "")
+ return ""
+
+ @property
+ def region(self) -> str:
+ """Cloudflare is a global service."""
+ return "global"
+
+
@dataclass
class CheckReportM365(Check_Report):
"""Contains the M365 Check's finding information."""
diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py
index 10b8c12abb..e23540ea1d 100644
--- a/prowler/lib/cli/parser.py
+++ b/prowler/lib/cli/parser.py
@@ -27,16 +27,17 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
- usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,dashboard,iac} ...",
+ usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,dashboard,iac} ...",
epilog="""
Available Cloud Providers:
- {aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud}
+ {aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
kubernetes Kubernetes Provider
m365 Microsoft 365 Provider
github GitHub Provider
+ cloudflare Cloudflare Provider
oraclecloud Oracle Cloud Infrastructure Provider
alibabacloud Alibaba Cloud Provider
iac IaC Provider (Beta)
diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py
index 4242116160..bbf5d248d1 100644
--- a/prowler/lib/outputs/finding.py
+++ b/prowler/lib/outputs/finding.py
@@ -342,6 +342,14 @@ class Finding(BaseModel):
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.region
+ elif provider.type == "cloudflare":
+ output_data["auth_method"] = "api_token"
+ output_data["account_uid"] = check_output.account_id
+ output_data["account_name"] = check_output.account_id
+ output_data["resource_name"] = check_output.resource_name
+ output_data["resource_uid"] = check_output.resource_id
+ output_data["region"] = check_output.zone_name
+
elif provider.type == "alibabacloud":
output_data["auth_method"] = get_nested_attribute(
provider, "identity.identity_arn"
@@ -434,6 +442,9 @@ class Finding(BaseModel):
finding.resource_line_range = "" # Set empty for compatibility
elif provider.type == "oraclecloud":
finding.compartment_id = getattr(finding, "compartment_id", "")
+ elif provider.type == "cloudflare":
+ finding.zone_name = getattr(resource, "zone_name", resource.name)
+ finding.account_id = getattr(finding, "account_id", "")
finding.check_metadata = CheckMetadata(
Provider=finding.check_metadata["provider"],
diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py
index 63a99b7491..23350974d5 100644
--- a/prowler/lib/outputs/html/html.py
+++ b/prowler/lib/outputs/html/html.py
@@ -1022,6 +1022,77 @@ class HTML(Output):
)
return ""
+ @staticmethod
+ def get_cloudflare_assessment_summary(provider: Provider) -> str:
+ """
+ get_cloudflare_assessment_summary gets the HTML assessment summary for the Cloudflare provider
+
+ Args:
+ provider (Provider): the Cloudflare provider object
+
+ Returns:
+ str: HTML assessment summary for the Cloudflare provider
+ """
+ try:
+ # Build assessment summary items (only non-None values)
+ assessment_items = ""
+ if provider.accounts:
+ accounts = ", ".join([acc.id for acc in provider.accounts])
+ assessment_items += f"""
+
+ Accounts: {accounts}
+ """
+
+ # Build credentials items (only non-None values)
+ credentials_items = ""
+
+ # Authentication method
+ if provider.session.api_token:
+ credentials_items += """
+
+ Authentication: API Token
+ """
+ elif provider.session.api_key and provider.session.api_email:
+ credentials_items += """
+
+ Authentication: API Key + Email
+ """
+
+ # Email (from identity or session)
+ email = getattr(provider.identity, "email", None) or getattr(
+ provider.session, "api_email", None
+ )
+ if email:
+ credentials_items += f"""
+
+ Email: {email}
+ """
+
+ return f"""
+
+ """
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+ )
+ return ""
+
@staticmethod
def get_alibabacloud_assessment_summary(provider: Provider) -> str:
"""
diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py
index 8ba7c3e614..fdea1cbc69 100644
--- a/prowler/lib/outputs/outputs.py
+++ b/prowler/lib/outputs/outputs.py
@@ -32,6 +32,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.region
if finding.check_metadata.Provider == "alibabacloud":
details = finding.region
+ if finding.check_metadata.Provider == "cloudflare":
+ details = finding.zone_name
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py
index daf65192bb..7bf2409af1 100644
--- a/prowler/lib/outputs/summary_table.py
+++ b/prowler/lib/outputs/summary_table.py
@@ -54,6 +54,15 @@ def display_summary_table(
elif provider.type == "mongodbatlas":
entity_type = "Organization"
audited_entities = provider.identity.organization_name
+ elif provider.type == "cloudflare":
+ entity_type = "Account"
+ audited_accounts = getattr(provider.identity, "audited_accounts", []) or []
+ if audited_accounts:
+ audited_entities = ", ".join(audited_accounts)
+ else:
+ audited_entities = (
+ getattr(provider.identity, "email", None) or "Cloudflare"
+ )
elif provider.type == "nhn":
entity_type = "Tenant Domain"
audited_entities = provider.identity.tenant_domain
diff --git a/prowler/providers/cloudflare/__init__.py b/prowler/providers/cloudflare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py
new file mode 100644
index 0000000000..0dddcdb8d4
--- /dev/null
+++ b/prowler/providers/cloudflare/cloudflare_provider.py
@@ -0,0 +1,269 @@
+import os
+from typing import Iterable
+
+from cloudflare import Cloudflare
+from colorama import Fore, Style
+
+from prowler.config.config import (
+ default_config_file_path,
+ get_default_mute_file_path,
+ load_and_validate_config_file,
+)
+from prowler.lib.logger import logger
+from prowler.lib.utils.utils import print_boxes
+from prowler.providers.cloudflare.exceptions.exceptions import (
+ CloudflareCredentialsError,
+ CloudflareIdentityError,
+ CloudflareSessionError,
+)
+from prowler.providers.cloudflare.lib.mutelist.mutelist import CloudflareMutelist
+from prowler.providers.cloudflare.models import (
+ CloudflareAccount,
+ CloudflareIdentityInfo,
+ CloudflareSession,
+)
+from prowler.providers.common.models import Audit_Metadata, Connection
+from prowler.providers.common.provider import Provider
+
+
+class CloudflareProvider(Provider):
+ """Cloudflare provider."""
+
+ _type: str = "cloudflare"
+ _session: CloudflareSession
+ _identity: CloudflareIdentityInfo
+ _audit_config: dict
+ _fixer_config: dict
+ _mutelist: CloudflareMutelist
+ _filter_zones: set[str] | None
+ audit_metadata: Audit_Metadata
+
+ def __init__(
+ self,
+ filter_zones: Iterable[str] | None = None,
+ config_path: str = None,
+ config_content: dict | None = None,
+ fixer_config: dict = {},
+ mutelist_path: str = None,
+ mutelist_content: dict = None,
+ ):
+ logger.info("Instantiating Cloudflare provider...")
+
+ if config_content:
+ self._audit_config = config_content
+ else:
+ if not config_path:
+ config_path = default_config_file_path
+ self._audit_config = load_and_validate_config_file(self._type, config_path)
+
+ max_retries = self._audit_config.get("max_retries", 2)
+
+ self._session = CloudflareProvider.setup_session(max_retries=max_retries)
+
+ self._identity = CloudflareProvider.setup_identity(self._session)
+
+ self._fixer_config = fixer_config
+
+ if mutelist_content:
+ self._mutelist = CloudflareMutelist(mutelist_content=mutelist_content)
+ else:
+ if not mutelist_path:
+ mutelist_path = get_default_mute_file_path(self.type)
+ self._mutelist = CloudflareMutelist(mutelist_path=mutelist_path)
+
+ # Store zone filter for filtering resources across services
+ self._filter_zones = set(filter_zones) if filter_zones else None
+
+ Provider.set_global_provider(self)
+
+ @property
+ def type(self):
+ return self._type
+
+ @property
+ def session(self):
+ return self._session
+
+ @property
+ def identity(self):
+ return self._identity
+
+ @property
+ def audit_config(self):
+ return self._audit_config
+
+ @property
+ def fixer_config(self):
+ return self._fixer_config
+
+ @property
+ def mutelist(self) -> CloudflareMutelist:
+ return self._mutelist
+
+ @property
+ def filter_zones(self) -> set[str] | None:
+ """Zone filter from --region argument to filter resources."""
+ return self._filter_zones
+
+ @property
+ def accounts(self) -> list[CloudflareAccount]:
+ return self._identity.accounts
+
+ @staticmethod
+ def setup_session(max_retries: int = 2) -> CloudflareSession:
+ """Initialize Cloudflare SDK client.
+
+ Credentials are 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).
+ """
+ token = os.environ.get("CLOUDFLARE_API_TOKEN", "")
+ key = os.environ.get("CLOUDFLARE_API_KEY", "")
+ email = os.environ.get("CLOUDFLARE_API_EMAIL", "")
+
+ # Warn if both auth methods are set, use API Token (recommended)
+ if token and key and email:
+ logger.error(
+ "Both API Token and API Key + Email credentials are set. "
+ "Using API Token (recommended). "
+ "To avoid this error, unset CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL, or CLOUDFLARE_API_TOKEN."
+ )
+
+ # The Cloudflare SDK reads credentials from environment variables automatically.
+ # To ensure we use only the selected auth method, temporarily unset env vars.
+ env_token = os.environ.pop("CLOUDFLARE_API_TOKEN", None)
+ env_key = os.environ.pop("CLOUDFLARE_API_KEY", None)
+ env_email = os.environ.pop("CLOUDFLARE_API_EMAIL", None)
+
+ try:
+ if token:
+ client = Cloudflare(api_token=token, max_retries=max_retries)
+ elif key and email:
+ client = Cloudflare(
+ api_key=key, api_email=email, max_retries=max_retries
+ )
+ else:
+ raise CloudflareCredentialsError(
+ file=os.path.basename(__file__),
+ message="Cloudflare credentials not found. Set CLOUDFLARE_API_TOKEN or both CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL environment variables.",
+ )
+
+ return CloudflareSession(
+ client=client,
+ api_token=client.api_token,
+ api_key=key or None,
+ api_email=email or None,
+ )
+ except Exception as error:
+ logger.critical(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+ )
+ raise CloudflareSessionError(
+ file=os.path.basename(__file__),
+ original_exception=error,
+ )
+ finally:
+ # Restore environment variables
+ if env_token:
+ os.environ["CLOUDFLARE_API_TOKEN"] = env_token
+ if env_key:
+ os.environ["CLOUDFLARE_API_KEY"] = env_key
+ if env_email:
+ os.environ["CLOUDFLARE_API_EMAIL"] = env_email
+
+ @staticmethod
+ def setup_identity(session: CloudflareSession) -> CloudflareIdentityInfo:
+ """Fetch user and account metadata for Cloudflare."""
+ try:
+ client = session.client
+ user_id = None
+ email = None
+ try:
+ user_info = client.user.get()
+ user_id = getattr(user_info, "id", None)
+ email = getattr(user_info, "email", None)
+ except Exception as error:
+ logger.warning(
+ f"Unable to retrieve Cloudflare user info: {error}. Continuing with limited identity details."
+ )
+
+ accounts: list[CloudflareAccount] = []
+ seen_account_ids: set[str] = set()
+
+ for account in client.accounts.list():
+ account_id = getattr(account, "id", None)
+ # Prevent infinite loop - skip if we've seen this account
+ if account_id in seen_account_ids:
+ break
+ seen_account_ids.add(account_id)
+
+ account_name = getattr(account, "name", None)
+ account_type = getattr(account, "type", None)
+ accounts.append(
+ CloudflareAccount(
+ id=account_id,
+ name=account_name,
+ type=account_type,
+ )
+ )
+
+ audited_accounts = [account.id for account in accounts]
+
+ return CloudflareIdentityInfo(
+ user_id=user_id,
+ email=email,
+ accounts=accounts,
+ audited_accounts=audited_accounts,
+ )
+ except Exception as error:
+ logger.critical(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+ )
+ raise CloudflareIdentityError(
+ file=os.path.basename(__file__),
+ original_exception=error,
+ )
+
+ def print_credentials(self) -> None:
+ report_title = (
+ f"{Style.BRIGHT}Using the Cloudflare credentials below:{Style.RESET_ALL}"
+ )
+ report_lines = []
+
+ # Authentication method
+ if self._session.api_token:
+ report_lines.append(
+ f"Authentication: {Fore.YELLOW}API Token{Style.RESET_ALL}"
+ )
+ elif self._session.api_key and self._session.api_email:
+ report_lines.append(
+ f"Authentication: {Fore.YELLOW}API Key + Email{Style.RESET_ALL}"
+ )
+
+ # Email (from identity or session)
+ email = self.identity.email or self._session.api_email
+ if email:
+ report_lines.append(f"Email: {Fore.YELLOW}{email}{Style.RESET_ALL}")
+
+ # Accounts
+ if self.accounts:
+ accounts = ", ".join([account.id for account in self.accounts])
+ report_lines.append(f"Accounts: {Fore.YELLOW}{accounts}{Style.RESET_ALL}")
+
+ print_boxes(report_lines, report_title)
+
+ def test_connection(self) -> Connection:
+ try:
+ _ = self._session.client.user.get()
+ return Connection(is_connected=True)
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return Connection(is_connected=False, error=error)
+
+ def validate_arguments(self) -> None:
+ return None
diff --git a/prowler/providers/cloudflare/exceptions/__init__.py b/prowler/providers/cloudflare/exceptions/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/exceptions/exceptions.py b/prowler/providers/cloudflare/exceptions/exceptions.py
new file mode 100644
index 0000000000..b0ad4106be
--- /dev/null
+++ b/prowler/providers/cloudflare/exceptions/exceptions.py
@@ -0,0 +1,126 @@
+from prowler.exceptions.exceptions import ProwlerException
+
+
+# Exceptions codes from 9000 to 9999 are reserved for Cloudflare exceptions
+class CloudflareBaseException(ProwlerException):
+ """Base class for Cloudflare errors."""
+
+ CLOUDFLARE_ERROR_CODES = {
+ (9000, "CloudflareCredentialsError"): {
+ "message": "Cloudflare credentials not found or invalid",
+ "remediation": "Provide a valid API token or API key and email for Cloudflare.",
+ },
+ (9001, "CloudflareAuthenticationError"): {
+ "message": "Cloudflare authentication failed",
+ "remediation": "Verify the Cloudflare credentials and ensure the token has the required permissions.",
+ },
+ (9002, "CloudflareSessionError"): {
+ "message": "Cloudflare session setup failed",
+ "remediation": "Review the Cloudflare SDK initialization parameters and credentials.",
+ },
+ (9003, "CloudflareIdentityError"): {
+ "message": "Unable to retrieve Cloudflare identity or account information",
+ "remediation": "Ensure the credentials allow access to the Cloudflare user and account APIs.",
+ },
+ (9004, "CloudflareInvalidAccountError"): {
+ "message": "The provided Cloudflare account is not accessible with these credentials",
+ "remediation": "Check the account identifier and token scopes to confirm access.",
+ },
+ (9005, "CloudflareInvalidProviderIdError"): {
+ "message": "The requested Cloudflare provider identifier is not valid",
+ "remediation": "Verify the supplied account or zone identifiers and retry.",
+ },
+ (9006, "CloudflareAPIError"): {
+ "message": "Cloudflare API call failed",
+ "remediation": "Inspect the API response details and permissions for the failing request.",
+ },
+ (9007, "CloudflareCredentialsConflictError"): {
+ "message": "Conflicting Cloudflare credentials provided",
+ "remediation": "Use either API Token or API Key + Email, not both. Unset CLOUDFLARE_API_TOKEN or unset both CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL.",
+ },
+ }
+
+ def __init__(self, code, file=None, original_exception=None, message=None):
+ provider = "Cloudflare"
+ error_info = self.CLOUDFLARE_ERROR_CODES.get((code, self.__class__.__name__))
+ if message:
+ error_info["message"] = message
+ super().__init__(
+ code=code,
+ source=provider,
+ file=file,
+ original_exception=original_exception,
+ error_info=error_info,
+ )
+
+
+class CloudflareCredentialsError(CloudflareBaseException):
+ """Exception for Cloudflare credential errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9000, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareAuthenticationError(CloudflareBaseException):
+ """Exception for Cloudflare authentication errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9001, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareSessionError(CloudflareBaseException):
+ """Exception for Cloudflare session setup errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9002, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareIdentityError(CloudflareBaseException):
+ """Exception for Cloudflare identity setup errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9003, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareInvalidAccountError(CloudflareBaseException):
+ """Exception for inaccessible Cloudflare account identifiers."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9004, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareInvalidProviderIdError(CloudflareBaseException):
+ """Exception for invalid Cloudflare provider identifiers."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9005, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareAPIError(CloudflareBaseException):
+ """Exception for Cloudflare API errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9006, file=file, original_exception=original_exception, message=message
+ )
+
+
+class CloudflareCredentialsConflictError(CloudflareBaseException):
+ """Exception for conflicting Cloudflare credentials."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 9007, file=file, original_exception=original_exception, message=message
+ )
diff --git a/prowler/providers/cloudflare/lib/__init__.py b/prowler/providers/cloudflare/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/lib/arguments/__init__.py b/prowler/providers/cloudflare/lib/arguments/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/lib/arguments/arguments.py b/prowler/providers/cloudflare/lib/arguments/arguments.py
new file mode 100644
index 0000000000..4e26282023
--- /dev/null
+++ b/prowler/providers/cloudflare/lib/arguments/arguments.py
@@ -0,0 +1,16 @@
+def init_parser(self):
+ """Init the Cloudflare provider CLI parser."""
+ cloudflare_parser = self.subparsers.add_parser(
+ "cloudflare", parents=[self.common_providers_parser], help="Cloudflare Provider"
+ )
+
+ scope_group = cloudflare_parser.add_argument_group("Scope")
+ scope_group.add_argument(
+ "--region",
+ "--filter-region",
+ "-f",
+ nargs="+",
+ default=None,
+ metavar="ZONE",
+ help="Filter scan to specific Cloudflare zones (name or ID).",
+ )
diff --git a/prowler/providers/cloudflare/lib/mutelist/__init__.py b/prowler/providers/cloudflare/lib/mutelist/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/lib/mutelist/mutelist.py b/prowler/providers/cloudflare/lib/mutelist/mutelist.py
new file mode 100644
index 0000000000..79934ddace
--- /dev/null
+++ b/prowler/providers/cloudflare/lib/mutelist/mutelist.py
@@ -0,0 +1,20 @@
+from prowler.lib.check.models import CheckReportCloudflare
+from prowler.lib.mutelist.mutelist import Mutelist
+from prowler.lib.outputs.utils import unroll_dict, unroll_tags
+
+
+class CloudflareMutelist(Mutelist):
+ """Cloudflare-specific mutelist helper."""
+
+ def is_finding_muted(
+ self,
+ finding: CheckReportCloudflare,
+ account_id: str,
+ ) -> bool:
+ return self.is_muted(
+ account_id,
+ finding.check_metadata.CheckID,
+ "global", # Cloudflare is a global service
+ finding.resource_id or finding.resource_name,
+ unroll_dict(unroll_tags(finding.resource_tags)),
+ )
diff --git a/prowler/providers/cloudflare/lib/service/__init__.py b/prowler/providers/cloudflare/lib/service/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/lib/service/service.py b/prowler/providers/cloudflare/lib/service/service.py
new file mode 100644
index 0000000000..8a1ea7484e
--- /dev/null
+++ b/prowler/providers/cloudflare/lib/service/service.py
@@ -0,0 +1,12 @@
+from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
+
+
+class CloudflareService:
+ """Base class for Cloudflare services to share provider context."""
+
+ def __init__(self, service: str, provider: CloudflareProvider):
+ self.provider = provider
+ self.client = provider.session.client
+ self.audit_config = provider.audit_config
+ self.fixer_config = provider.fixer_config
+ self.service = service.lower() if not service.islower() else service
diff --git a/prowler/providers/cloudflare/models.py b/prowler/providers/cloudflare/models.py
new file mode 100644
index 0000000000..c4eba74521
--- /dev/null
+++ b/prowler/providers/cloudflare/models.py
@@ -0,0 +1,56 @@
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+from prowler.config.config import output_file_timestamp
+from prowler.providers.common.models import ProviderOutputOptions
+
+
+class CloudflareSession(BaseModel):
+ """Cloudflare session information."""
+
+ client: Any
+ api_token: Optional[str] = None
+ api_key: Optional[str] = None
+ api_email: Optional[str] = None
+
+
+class CloudflareAccount(BaseModel):
+ """Cloudflare account metadata."""
+
+ id: str
+ name: str
+ type: Optional[str] = None
+
+
+class CloudflareIdentityInfo(BaseModel):
+ """Cloudflare identity and scoping information."""
+
+ user_id: Optional[str] = None
+ email: Optional[str] = None
+ accounts: list[CloudflareAccount] = Field(default_factory=list)
+ audited_accounts: list[str] = Field(default_factory=list)
+ audited_zones: list[str] = Field(default_factory=list)
+
+
+class CloudflareOutputOptions(ProviderOutputOptions):
+ """Customize output filenames for Cloudflare scans."""
+
+ def __init__(
+ self, arguments, bulk_checks_metadata, identity: CloudflareIdentityInfo
+ ):
+ super().__init__(arguments, bulk_checks_metadata)
+ if (
+ not hasattr(arguments, "output_filename")
+ or arguments.output_filename is None
+ ):
+ account_fragment = (
+ identity.audited_accounts[0]
+ if identity.audited_accounts
+ else identity.email or "cloudflare"
+ )
+ self.output_filename = (
+ f"prowler-output-{account_fragment}-{output_file_timestamp}"
+ )
+ else:
+ self.output_filename = arguments.output_filename
diff --git a/prowler/providers/cloudflare/services/__init__.py b/prowler/providers/cloudflare/services/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/__init__.py b/prowler/providers/cloudflare/services/zones/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_client.py b/prowler/providers/cloudflare/services/zones/zones_client.py
new file mode 100644
index 0000000000..344f2285df
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.cloudflare.services.zones.zones_service import Zones
+from prowler.providers.common.provider import Provider
+
+zones_client = Zones(Provider.get_global_provider())
diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py b/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json b/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json
new file mode 100644
index 0000000000..0c3056dde6
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json
@@ -0,0 +1,35 @@
+{
+ "Provider": "cloudflare",
+ "CheckID": "zones_dnssec_enabled",
+ "CheckTitle": "DNSSEC is enabled",
+ "CheckType": [],
+ "ServiceName": "zones",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Zone",
+ "Description": "**Cloudflare zones** are assessed for **DNSSEC** configuration by checking if it is enabled to **cryptographically sign DNS responses** and protect against DNS spoofing and cache poisoning attacks.",
+ "Risk": "Without **DNSSEC**, DNS responses can be spoofed or modified by attackers.\n- **Confidentiality**: users can be redirected to malicious sites that harvest credentials\n- **Integrity**: DNS hijacking enables man-in-the-middle attacks and content modification\n- **Availability**: cache poisoning can cause denial of service by directing traffic to non-existent servers",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developers.cloudflare.com/dns/dnssec/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Settings\n3. Scroll to DNSSEC and click Enable DNSSEC\n4. Copy the DS record details provided by Cloudflare\n5. Add the DS record at your domain registrar\n6. Wait for propagation (can take up to 24 hours)",
+ "Terraform": "```hcl\n# Enable DNSSEC for the zone\nresource \"cloudflare_zone_dnssec\" \"\" {\n zone_id = \"\" # Critical: enables cryptographic signing of DNS responses\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable **DNSSEC** and ensure **DS records** are properly configured at your domain registrar.\n- DNSSEC provides cryptographic authenticity for DNS responses\n- After enabling in Cloudflare, you must add the DS record at your registrar\n- Use online DNSSEC validators to verify correct configuration",
+ "Url": "https://hub.prowler.com/checks/cloudflare/zones_dnssec_enabled"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py b/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py
new file mode 100644
index 0000000000..9dcf32f14e
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py
@@ -0,0 +1,38 @@
+from prowler.lib.check.models import Check, CheckReportCloudflare
+from prowler.providers.cloudflare.services.zones.zones_client import zones_client
+
+
+class zones_dnssec_enabled(Check):
+ """Ensure that DNSSEC is enabled for Cloudflare zones.
+
+ DNSSEC (Domain Name System Security Extensions) adds cryptographic signatures
+ to DNS records, protecting against DNS spoofing and cache poisoning attacks.
+ When enabled, it ensures that DNS responses are authentic and have not been
+ tampered with during transit.
+ """
+
+ def execute(self) -> list[CheckReportCloudflare]:
+ """Execute the DNSSEC enabled check.
+
+ Iterates through all Cloudflare zones and verifies that DNSSEC status
+ is set to 'active'. A zone passes the check if DNSSEC is actively
+ protecting its DNS records; otherwise, it fails.
+
+ Returns:
+ A list of CheckReportCloudflare objects with PASS status if DNSSEC
+ is active, or FAIL status if DNSSEC is not enabled for the zone.
+ """
+ findings = []
+ for zone in zones_client.zones.values():
+ report = CheckReportCloudflare(
+ metadata=self.metadata(),
+ resource=zone,
+ )
+ if zone.dnssec_status == "active":
+ report.status = "PASS"
+ report.status_extended = f"DNSSEC is enabled for zone {zone.name}."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"DNSSEC is not enabled for zone {zone.name}."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py b/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json b/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json
new file mode 100644
index 0000000000..80d466e408
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "cloudflare",
+ "CheckID": "zones_hsts_enabled",
+ "CheckTitle": "HSTS is enabled with recommended max-age and includes subdomains",
+ "CheckType": [],
+ "ServiceName": "zones",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Zone",
+ "Description": "**Cloudflare zones** are assessed for **HTTP Strict Transport Security (HSTS)** by checking if it is enabled with a `max-age` of at least **6 months** (15768000 seconds) and **includes subdomains** to instruct browsers to always use HTTPS across the entire domain.",
+ "Risk": "Without **HSTS**, browsers may initially connect over HTTP before redirecting to HTTPS.\n- **Confidentiality**: creates a window for SSL stripping attacks where attackers downgrade connections to unencrypted HTTP\n- **Integrity**: first request can be intercepted and modified before HTTPS redirect\n- **Session hijacking**: cookies and credentials may be captured during initial HTTP request",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to HTTP Strict Transport Security (HSTS)\n4. Click Enable HSTS\n5. Set Max Age Header to at least 6 months\n6. Enable Include subdomains and Preload if appropriate\n7. Acknowledge the warning and click Save",
+ "Terraform": "```hcl\n# Enable HSTS with recommended settings\nresource \"cloudflare_zone_setting\" \"security_header\" {\n zone_id = \"\"\n setting_id = \"security_header\"\n value = {\n strict_transport_security = {\n enabled = true\n max_age = 31536000 # Critical: 1 year in seconds\n include_subdomains = true # Recommended: apply to all subdomains\n preload = true # Recommended: submit to browser preload lists\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable **HSTS** with at least a **6-month max-age** (12 months recommended).\n- Verify all resources work over HTTPS before enabling\n- Enable **include_subdomains** to protect all subdomains\n- Consider **HSTS preloading** for maximum protection against SSL stripping attacks\n- Test thoroughly as HSTS cannot be easily disabled once deployed",
+ "Url": "https://hub.prowler.com/checks/cloudflare/zones_hsts_enabled"
+ }
+ },
+ "Categories": [
+ "encryption",
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "HSTS requires HTTPS to be properly configured. Ensure all resources are accessible via HTTPS before enabling HSTS with a long max-age."
+}
diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py b/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py
new file mode 100644
index 0000000000..cfe98e76ac
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py
@@ -0,0 +1,58 @@
+from prowler.lib.check.models import Check, CheckReportCloudflare
+from prowler.providers.cloudflare.services.zones.zones_client import zones_client
+
+
+class zones_hsts_enabled(Check):
+ """Ensure that HSTS is enabled with secure settings for Cloudflare zones.
+
+ HTTP Strict Transport Security (HSTS) forces browsers to only connect via
+ HTTPS, preventing protocol downgrade attacks and cookie hijacking. This check
+ verifies that HSTS is enabled with a minimum max-age of 6 months (15768000
+ seconds) and includes subdomains for complete protection.
+ """
+
+ def execute(self) -> list[CheckReportCloudflare]:
+ """Execute the HSTS enabled check.
+
+ Iterates through all Cloudflare zones and validates HSTS configuration
+ against security best practices. The check verifies three conditions:
+ 1. HSTS is enabled for the zone
+ 2. The includeSubdomains directive is set to protect all subdomains
+ 3. The max-age is at least 6 months (15768000 seconds)
+
+ Returns:
+ A list of CheckReportCloudflare objects with PASS status if all
+ HSTS requirements are met, or FAIL status if HSTS is disabled,
+ missing subdomain inclusion, or has insufficient max-age.
+ """
+ findings = []
+ # Recommended minimum max-age is 6 months (15768000 seconds)
+ recommended_max_age = 15768000
+
+ for zone in zones_client.zones.values():
+ report = CheckReportCloudflare(
+ metadata=self.metadata(),
+ resource=zone,
+ )
+ hsts = zone.settings.strict_transport_security
+ if hsts.enabled:
+ if not hsts.include_subdomains:
+ report.status = "FAIL"
+ report.status_extended = f"HSTS is enabled for zone {zone.name} but does not include subdomains."
+ elif hsts.max_age < recommended_max_age:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"HSTS is enabled for zone {zone.name} but max-age is "
+ f"{hsts.max_age} seconds (recommended: 6 months)."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"HSTS is enabled for zone {zone.name} with max-age of "
+ f"{hsts.max_age} seconds and includes subdomains."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"HSTS is not enabled for zone {zone.name}."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py b/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json b/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json
new file mode 100644
index 0000000000..11263719a3
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "cloudflare",
+ "CheckID": "zones_https_redirect_enabled",
+ "CheckTitle": "Always Use HTTPS is enabled",
+ "CheckType": [],
+ "ServiceName": "zones",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "Zone",
+ "Description": "**Cloudflare zones** are assessed for **Always Use HTTPS** setting by checking if it is enabled to automatically redirect all **HTTP requests to HTTPS**, enforcing encrypted transport for all visitors.",
+ "Risk": "Without **automatic HTTPS redirects**, users may access resources over unencrypted HTTP.\n- **Confidentiality**: traffic can be intercepted and read by attackers on the network path\n- **Integrity**: HTTP responses can be modified in transit (content injection, malware insertion)\n- **Authentication**: session cookies and credentials may be transmitted in plaintext",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/",
+ "https://developers.cloudflare.com/terraform/tutorial/configure-https-settings/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Always Use HTTPS\n4. Toggle the setting to On\n5. Verify that your site works correctly over HTTPS",
+ "Terraform": "```hcl\n# Enable Always Use HTTPS to redirect HTTP to HTTPS\nresource \"cloudflare_zone_setting\" \"always_use_https\" {\n zone_id = \"\"\n setting_id = \"always_use_https\"\n value = \"on\" # Critical: forces all traffic to use encrypted HTTPS\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable **Always Use HTTPS** to enforce encrypted connections for all visitors.\n- Combine with **HSTS** to prevent SSL stripping attacks\n- Ensure all resources (images, scripts, stylesheets) are served over HTTPS\n- Test for mixed content warnings before enabling",
+ "Url": "https://hub.prowler.com/checks/cloudflare/zones_https_redirect_enabled"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py b/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py
new file mode 100644
index 0000000000..8bae20941b
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py
@@ -0,0 +1,43 @@
+from prowler.lib.check.models import Check, CheckReportCloudflare
+from prowler.providers.cloudflare.services.zones.zones_client import zones_client
+
+
+class zones_https_redirect_enabled(Check):
+ """Ensure that Always Use HTTPS redirect is enabled for Cloudflare zones.
+
+ The Always Use HTTPS setting automatically redirects all HTTP requests to
+ HTTPS, ensuring that all traffic to the zone is encrypted. This prevents
+ man-in-the-middle attacks and protects sensitive data transmitted between
+ clients and the origin server.
+ """
+
+ def execute(self) -> list[CheckReportCloudflare]:
+ """Execute the HTTPS redirect enabled check.
+
+ Iterates through all Cloudflare zones and verifies that the
+ always_use_https setting is turned on. When enabled, Cloudflare
+ automatically redirects all HTTP requests to their HTTPS equivalents.
+
+ Returns:
+ A list of CheckReportCloudflare objects with PASS status if
+ Always Use HTTPS is enabled ('on'), or FAIL status if the
+ setting is disabled for the zone.
+ """
+ findings = []
+ for zone in zones_client.zones.values():
+ report = CheckReportCloudflare(
+ metadata=self.metadata(),
+ resource=zone,
+ )
+ if zone.settings.always_use_https == "on":
+ report.status = "PASS"
+ report.status_extended = (
+ f"Always Use HTTPS is enabled for zone {zone.name}."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Always Use HTTPS is not enabled for zone {zone.name}."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/__init__.py b/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json b/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json
new file mode 100644
index 0000000000..26ca76e0c8
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "cloudflare",
+ "CheckID": "zones_min_tls_version_secure",
+ "CheckTitle": "Minimum TLS version is set to 1.2 or higher",
+ "CheckType": [],
+ "ServiceName": "zones",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Zone",
+ "Description": "**Cloudflare zones** are assessed for **minimum TLS version** configuration by checking if the version is set to at least `TLS 1.2` to ensure connections use **secure, modern cryptographic protocols**.",
+ "Risk": "Allowing **legacy TLS versions** (1.0, 1.1) exposes connections to known protocol vulnerabilities.\n- **Confidentiality**: BEAST, POODLE, and weak cipher suites can be exploited for traffic decryption\n- **Compliance**: TLS 1.0/1.1 are deprecated by PCI-DSS, NIST, and major browsers\n- **Integrity**: downgrade attacks can force weaker encryption that is susceptible to tampering",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/",
+ "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/tls-13/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Minimum TLS Version\n4. Select TLS 1.2 or TLS 1.3 from the dropdown\n5. Verify that your clients support the selected TLS version",
+ "Terraform": "```hcl\n# Set minimum TLS version to 1.2 for secure connections\nresource \"cloudflare_zone_setting\" \"min_tls_version\" {\n zone_id = \"\"\n setting_id = \"min_tls_version\"\n value = \"1.2\" # Critical: blocks legacy TLS 1.0/1.1 connections\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Set **minimum TLS version** to `1.2` or higher.\n- **TLS 1.0 and 1.1** are deprecated by all major browsers and contain known vulnerabilities\n- Consider setting to `TLS 1.3` for environments with modern client requirements\n- Test client compatibility before upgrading minimum version",
+ "Url": "https://hub.prowler.com/checks/cloudflare/zones_min_tls_version_secure"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py b/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py
new file mode 100644
index 0000000000..a3220376c7
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py
@@ -0,0 +1,47 @@
+from prowler.lib.check.models import Check, CheckReportCloudflare
+from prowler.providers.cloudflare.services.zones.zones_client import zones_client
+
+
+class zones_min_tls_version_secure(Check):
+ """Ensure that minimum TLS version is set to 1.2 or higher for Cloudflare zones.
+
+ TLS 1.0 and 1.1 have known vulnerabilities (BEAST, POODLE) and are deprecated.
+ Setting the minimum TLS version to 1.2 or higher ensures that only secure
+ cipher suites are used for encrypted connections, protecting against
+ downgrade attacks and known cryptographic weaknesses.
+ """
+
+ def execute(self) -> list[CheckReportCloudflare]:
+ """Execute the minimum TLS version check.
+
+ Iterates through all Cloudflare zones and verifies that the minimum
+ TLS version is configured to 1.2 or higher. The check parses the
+ min_tls_version setting as a float for comparison, defaulting to 0
+ if the value cannot be parsed.
+
+ Returns:
+ A list of CheckReportCloudflare objects with PASS status if the
+ minimum TLS version is 1.2 or higher, or FAIL status if older
+ TLS versions (1.0, 1.1) are still allowed.
+ """
+ findings = []
+
+ for zone in zones_client.zones.values():
+ report = CheckReportCloudflare(
+ metadata=self.metadata(),
+ resource=zone,
+ )
+ current_version = zone.settings.min_tls_version or "0"
+ try:
+ current = float(current_version)
+ except ValueError:
+ current = 0
+
+ if current >= 1.2:
+ report.status = "PASS"
+ report.status_extended = f"Minimum TLS version for zone {zone.name} is set to {current_version}."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Minimum TLS version for zone {zone.name} is {current_version}, below the recommended 1.2."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/cloudflare/services/zones/zones_service.py b/prowler/providers/cloudflare/services/zones/zones_service.py
new file mode 100644
index 0000000000..7a3693b2fe
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_service.py
@@ -0,0 +1,243 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+from prowler.lib.logger import logger
+from prowler.providers.cloudflare.lib.service.service import CloudflareService
+from prowler.providers.cloudflare.models import CloudflareAccount
+
+
+class Zones(CloudflareService):
+ """Retrieve Cloudflare zones with security-relevant settings."""
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ self.zones: dict[str, "CloudflareZone"] = {}
+ self._list_zones()
+ self._get_zones_settings()
+ self._get_zones_dnssec()
+
+ def _list_zones(self) -> None:
+ """List all Cloudflare zones with their basic information."""
+ logger.info("Zones - Listing zones...")
+ audited_accounts = self.provider.identity.audited_accounts
+ filter_zones = self.provider.filter_zones
+ seen_zone_ids: set[str] = set()
+
+ try:
+ for zone in self.client.zones.list():
+ zone_id = getattr(zone, "id", None)
+ # Prevent infinite loop - skip if we've seen this zone
+ if zone_id in seen_zone_ids:
+ break
+ seen_zone_ids.add(zone_id)
+
+ zone_account = getattr(zone, "account", None)
+ account_id = getattr(zone_account, "id", None) if zone_account else None
+
+ # Filter by audited accounts
+ if audited_accounts and account_id not in audited_accounts:
+ continue
+
+ zone_name = getattr(zone, "name", None)
+
+ # Apply zone filter if specified via --region
+ if (
+ filter_zones
+ and zone_id not in filter_zones
+ and zone_name not in filter_zones
+ ):
+ continue
+
+ zone_plan = getattr(zone, "plan", None)
+ self.zones[zone_id] = CloudflareZone(
+ id=zone_id,
+ name=zone_name,
+ status=getattr(zone, "status", None),
+ paused=getattr(zone, "paused", False),
+ account=(
+ CloudflareAccount(
+ id=account_id,
+ name=(
+ getattr(zone_account, "name", "")
+ if zone_account
+ else ""
+ ),
+ type=(
+ getattr(zone_account, "type", None)
+ if zone_account
+ else None
+ ),
+ )
+ if zone_account
+ else None
+ ),
+ plan=getattr(zone_plan, "name", None) if zone_plan else None,
+ )
+
+ if not self.zones:
+ logger.warning(
+ "No Cloudflare zones discovered with current credentials."
+ )
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _get_zones_settings(self) -> None:
+ """Get settings for all zones."""
+ logger.info("Zones - Getting zone settings...")
+ for zone in self.zones.values():
+ try:
+ zone.settings = self._get_zone_settings(zone.id)
+ except Exception as error:
+ logger.error(
+ f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _get_zones_dnssec(self) -> None:
+ """Get DNSSEC status for all zones."""
+ logger.info("Zones - Getting DNSSEC status...")
+ for zone in self.zones.values():
+ try:
+ dnssec = self.client.dns.dnssec.get(zone_id=zone.id)
+ zone.dnssec_status = getattr(dnssec, "status", None)
+ except Exception as error:
+ logger.error(
+ f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _get_zone_setting(self, zone_id: str, setting_id: str):
+ """Get a single zone setting by ID."""
+ try:
+ result = self.client.zones.settings.get(
+ setting_id=setting_id, zone_id=zone_id
+ )
+ return getattr(result, "value", None)
+ except Exception:
+ return None
+
+ def _get_zone_settings(self, zone_id: str) -> "CloudflareZoneSettings":
+ """Get all settings for a zone."""
+ settings = {
+ setting_id: self._get_zone_setting(zone_id, setting_id)
+ for setting_id in [
+ "always_use_https",
+ "min_tls_version",
+ "ssl",
+ "tls_1_3",
+ "automatic_https_rewrites",
+ "universal_ssl",
+ "security_header",
+ "waf",
+ "security_level",
+ "browser_check",
+ "challenge_ttl",
+ "ip_geolocation",
+ "email_obfuscation",
+ "server_side_exclude",
+ "hotlink_protection",
+ "development_mode",
+ "always_online",
+ ]
+ }
+
+ return CloudflareZoneSettings(
+ always_use_https=settings.get("always_use_https"),
+ min_tls_version=str(settings.get("min_tls_version") or ""),
+ ssl_encryption_mode=settings.get("ssl"),
+ tls_1_3=settings.get("tls_1_3"),
+ automatic_https_rewrites=settings.get("automatic_https_rewrites"),
+ universal_ssl=settings.get("universal_ssl"),
+ strict_transport_security=self._get_strict_transport_security(
+ settings.get("security_header")
+ ),
+ waf=settings.get("waf"),
+ security_level=settings.get("security_level"),
+ browser_check=settings.get("browser_check"),
+ challenge_ttl=settings.get("challenge_ttl"),
+ ip_geolocation=settings.get("ip_geolocation"),
+ email_obfuscation=settings.get("email_obfuscation"),
+ server_side_exclude=settings.get("server_side_exclude"),
+ hotlink_protection=settings.get("hotlink_protection"),
+ development_mode=settings.get("development_mode"),
+ always_online=settings.get("always_online"),
+ )
+
+ def _get_strict_transport_security(
+ self, security_header
+ ) -> "StrictTransportSecurity":
+ """Parse HSTS settings from security_header."""
+ if hasattr(security_header, "strict_transport_security"):
+ sts = security_header.strict_transport_security
+ sts_data = {
+ "enabled": getattr(sts, "enabled", False),
+ "max_age": getattr(sts, "max_age", 0),
+ "include_subdomains": getattr(sts, "include_subdomains", False),
+ "preload": getattr(sts, "preload", False),
+ "nosniff": getattr(sts, "nosniff", False),
+ }
+ elif isinstance(security_header, dict):
+ sts_data = security_header.get("strict_transport_security", {})
+ else:
+ sts_data = {}
+
+ return StrictTransportSecurity(
+ enabled=sts_data.get("enabled", False),
+ max_age=sts_data.get("max_age", 0),
+ include_subdomains=sts_data.get("include_subdomains", False),
+ preload=sts_data.get("preload", False),
+ nosniff=sts_data.get("nosniff", False),
+ )
+
+
+class StrictTransportSecurity(BaseModel):
+ """HTTP Strict Transport Security (HSTS) settings."""
+
+ enabled: bool = False
+ max_age: int = 0
+ include_subdomains: bool = False
+ preload: bool = False
+ nosniff: bool = False
+
+
+class CloudflareZoneSettings(BaseModel):
+ """Selected Cloudflare zone security settings."""
+
+ # TLS/SSL settings
+ always_use_https: Optional[str] = None
+ min_tls_version: Optional[str] = None
+ ssl_encryption_mode: Optional[str] = None
+ tls_1_3: Optional[str] = None
+ automatic_https_rewrites: Optional[str] = None
+ universal_ssl: Optional[str] = None
+ # HSTS settings
+ strict_transport_security: StrictTransportSecurity = Field(
+ default_factory=StrictTransportSecurity
+ )
+ # Security settings
+ waf: Optional[str] = None
+ security_level: Optional[str] = None
+ browser_check: Optional[str] = None
+ challenge_ttl: Optional[int] = None
+ ip_geolocation: Optional[str] = None
+ # Scrape Shield settings
+ email_obfuscation: Optional[str] = None
+ server_side_exclude: Optional[str] = None
+ hotlink_protection: Optional[str] = None
+ # Zone state
+ development_mode: Optional[str] = None
+ always_online: Optional[str] = None
+
+
+class CloudflareZone(BaseModel):
+ """Cloudflare zone representation used across services."""
+
+ id: str
+ name: str
+ status: Optional[str] = None
+ paused: bool = False
+ account: Optional[CloudflareAccount] = None
+ plan: Optional[str] = None
+ settings: CloudflareZoneSettings = Field(default_factory=CloudflareZoneSettings)
+ dnssec_status: Optional[str] = None
diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/__init__.py b/prowler/providers/cloudflare/services/zones/zones_ssl_strict/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json b/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json
new file mode 100644
index 0000000000..1b56458b7f
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json
@@ -0,0 +1,35 @@
+{
+ "Provider": "cloudflare",
+ "CheckID": "zones_ssl_strict",
+ "CheckTitle": "SSL/TLS encryption mode is set to Full (Strict)",
+ "CheckType": [],
+ "ServiceName": "zones",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Zone",
+ "Description": "**Cloudflare zones** are assessed for **SSL/TLS encryption mode** by checking if the mode is set to `Full (Strict)` to ensure **end-to-end encryption** with certificate validation.",
+ "Risk": "Without **strict SSL mode**, traffic between Cloudflare and origin may use unvalidated or unencrypted connections.\n- **Confidentiality**: sensitive data can be intercepted in transit via man-in-the-middle attacks\n- **Integrity**: responses can be modified without detection between Cloudflare and origin\n- **Compliance**: may violate PCI-DSS, HIPAA, and other regulatory requirements for encrypted transport",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Overview\n3. Under SSL/TLS encryption mode, select Full (strict)\n4. Ensure your origin server has a valid SSL certificate installed (Cloudflare Origin CA or publicly trusted CA)",
+ "Terraform": "```hcl\n# Set SSL/TLS mode to Full (Strict) for end-to-end encryption\nresource \"cloudflare_zone_setting\" \"ssl\" {\n zone_id = \"\"\n setting_id = \"ssl\"\n value = \"strict\" # Critical: ensures certificate validation between Cloudflare and origin\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure **SSL/TLS mode** to `Full (Strict)` and install a valid certificate on your origin server.\n- Use **Cloudflare Origin CA certificates** for seamless integration\n- Ensure origin server presents a valid certificate matching your domain\n- Enable **Authenticated Origin Pulls** for additional security",
+ "Url": "https://hub.prowler.com/checks/cloudflare/zones_ssl_strict"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py b/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py
new file mode 100644
index 0000000000..f51b35e065
--- /dev/null
+++ b/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py
@@ -0,0 +1,42 @@
+from prowler.lib.check.models import Check, CheckReportCloudflare
+from prowler.providers.cloudflare.services.zones.zones_client import zones_client
+
+
+class zones_ssl_strict(Check):
+ """Ensure that SSL/TLS encryption mode is set to Full (Strict) for Cloudflare zones.
+
+ The SSL/TLS encryption mode determines how Cloudflare connects to the origin
+ server. In 'strict' mode, Cloudflare validates the origin
+ server's SSL certificate, ensuring end-to-end encryption with certificate
+ verification. Lower modes (off, flexible, full) are vulnerable to
+ man-in-the-middle attacks between Cloudflare and the origin.
+ """
+
+ def execute(self) -> list[CheckReportCloudflare]:
+ """Execute the SSL strict mode check.
+
+ Iterates through all Cloudflare zones and verifies that the SSL/TLS
+ encryption mode is set to 'strict'. This mode
+ requires a valid SSL certificate on the origin server and provides
+ full end-to-end encryption with certificate validation.
+
+ Returns:
+ A list of CheckReportCloudflare objects with PASS status if
+ SSL mode is 'strict', or FAIL status if using
+ less secure modes like 'off', 'flexible', or 'full'.
+ """
+ findings = []
+ for zone in zones_client.zones.values():
+ report = CheckReportCloudflare(
+ metadata=self.metadata(),
+ resource=zone,
+ )
+ ssl_mode = (zone.settings.ssl_encryption_mode or "").lower()
+ if ssl_mode == "strict":
+ report.status = "PASS"
+ report.status_extended = f"SSL/TLS encryption mode is set to Full (Strict) for zone {zone.name}."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"SSL/TLS encryption mode is set to {ssl_mode.capitalize()} for zone {zone.name}, which is not Full (Strict)."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py
index 16758ed940..6455a21b69 100644
--- a/prowler/providers/common/provider.py
+++ b/prowler/providers/common/provider.py
@@ -248,6 +248,13 @@ class Provider(ABC):
repositories=arguments.repository,
organizations=arguments.organization,
)
+ elif "cloudflare" in provider_class_name.lower():
+ provider_class(
+ filter_zones=arguments.region,
+ config_path=arguments.config_file,
+ mutelist_path=arguments.mutelist_file,
+ fixer_config=fixer_config,
+ )
elif "iac" in provider_class_name.lower():
provider_class(
scan_path=arguments.scan_path,
diff --git a/pyproject.toml b/pyproject.toml
index f56f7cfdb9..b9bbbd800e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ dependencies = [
"azure-mgmt-loganalytics==12.0.0",
"azure-monitor-query==2.0.0",
"azure-storage-blob==12.24.1",
+ "cloudflare==4.3.1",
"boto3==1.39.15",
"botocore==1.39.15",
"colorama==0.4.6",
diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py
index dee0f8caab..1a378ebfc5 100644
--- a/tests/lib/cli/parser_test.py
+++ b/tests/lib/cli/parser_test.py
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
-prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,dashboard,iac} ..."
+prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,dashboard,iac} ..."
def mock_get_available_providers():
@@ -33,6 +33,7 @@ def mock_get_available_providers():
"mongodbatlas",
"oraclecloud",
"alibabacloud",
+ "cloudflare",
]
diff --git a/tests/providers/cloudflare/cloudflare_fixtures.py b/tests/providers/cloudflare/cloudflare_fixtures.py
new file mode 100644
index 0000000000..9bf9414ed0
--- /dev/null
+++ b/tests/providers/cloudflare/cloudflare_fixtures.py
@@ -0,0 +1,56 @@
+from unittest.mock import MagicMock
+
+from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
+from prowler.providers.cloudflare.models import (
+ CloudflareAccount,
+ CloudflareIdentityInfo,
+ CloudflareSession,
+)
+
+# Cloudflare Identity
+ACCOUNT_ID = "test-account-id"
+ACCOUNT_NAME = "Test Account"
+USER_ID = "test-user-id"
+USER_EMAIL = "test@example.com"
+
+# Cloudflare Credentials
+API_TOKEN = "test-api-token"
+API_KEY = "test-api-key"
+API_EMAIL = "test@example.com"
+
+# Zone Constants
+ZONE_ID = "test-zone-id"
+ZONE_NAME = "example.com"
+
+
+def set_mocked_cloudflare_provider(
+ api_token: str = API_TOKEN,
+ identity: CloudflareIdentityInfo = None,
+ audit_config: dict = None,
+) -> CloudflareProvider:
+ """Create a mocked CloudflareProvider for testing."""
+ provider = MagicMock()
+ provider.type = "cloudflare"
+ provider.session = CloudflareSession(
+ client=MagicMock(),
+ api_token=api_token,
+ api_key=None,
+ api_email=None,
+ )
+ provider.identity = identity or CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ )
+ provider.audit_config = audit_config or {"max_retries": 3, "min_tls_version": "1.2"}
+ provider.fixer_config = {}
+ provider.filter_zones = None
+
+ return provider
diff --git a/tests/providers/cloudflare/cloudflare_provider_test.py b/tests/providers/cloudflare/cloudflare_provider_test.py
new file mode 100644
index 0000000000..57bdade5fb
--- /dev/null
+++ b/tests/providers/cloudflare/cloudflare_provider_test.py
@@ -0,0 +1,238 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
+from prowler.providers.cloudflare.exceptions.exceptions import (
+ CloudflareCredentialsError,
+)
+from prowler.providers.cloudflare.models import (
+ CloudflareAccount,
+ CloudflareIdentityInfo,
+ CloudflareSession,
+)
+from prowler.providers.common.models import Connection
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ACCOUNT_ID,
+ ACCOUNT_NAME,
+ API_EMAIL,
+ API_KEY,
+ API_TOKEN,
+ USER_EMAIL,
+ USER_ID,
+)
+
+
+class TestCloudflareProvider:
+ def test_cloudflare_provider_with_api_token(self):
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=MagicMock(),
+ api_token=API_TOKEN,
+ api_key=None,
+ api_email=None,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ ),
+ ),
+ ):
+ provider = CloudflareProvider()
+
+ assert provider._type == "cloudflare"
+ assert provider.session.api_token == API_TOKEN
+ assert provider.identity.user_id == USER_ID
+ assert provider.identity.email == USER_EMAIL
+ assert len(provider.accounts) == 1
+ assert provider.accounts[0].id == ACCOUNT_ID
+
+ def test_cloudflare_provider_with_api_key_and_email(self):
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=MagicMock(),
+ api_token=None,
+ api_key=API_KEY,
+ api_email=API_EMAIL,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ ),
+ ),
+ ):
+ provider = CloudflareProvider()
+
+ assert provider._type == "cloudflare"
+ assert provider.session.api_key == API_KEY
+ assert provider.session.api_email == API_EMAIL
+
+ def test_cloudflare_provider_test_connection_success(self):
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=MagicMock(),
+ api_token=API_TOKEN,
+ api_key=None,
+ api_email=None,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ ),
+ ),
+ ):
+ provider = CloudflareProvider()
+ connection = provider.test_connection()
+
+ assert isinstance(connection, Connection)
+ assert connection.is_connected is True
+ assert connection.error is None
+
+ def test_cloudflare_provider_test_connection_failure(self):
+ mock_client = MagicMock()
+ mock_client.user.get.side_effect = Exception("Connection failed")
+
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=mock_client,
+ api_token=API_TOKEN,
+ api_key=None,
+ api_email=None,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[],
+ audited_accounts=[],
+ ),
+ ),
+ ):
+ provider = CloudflareProvider()
+ connection = provider.test_connection()
+
+ assert isinstance(connection, Connection)
+ assert connection.is_connected is False
+ assert connection.error is not None
+
+ def test_cloudflare_provider_no_credentials_raises_error(self):
+ with patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ side_effect=CloudflareCredentialsError(
+ file="cloudflare_provider.py",
+ message="Cloudflare credentials not found.",
+ ),
+ ):
+ with pytest.raises(CloudflareCredentialsError):
+ CloudflareProvider()
+
+ def test_cloudflare_provider_with_filter_zones(self):
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=MagicMock(),
+ api_token=API_TOKEN,
+ api_key=None,
+ api_email=None,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ ),
+ ),
+ ):
+ filter_zones = ["zone1", "zone2"]
+ provider = CloudflareProvider(filter_zones=filter_zones)
+
+ assert provider.filter_zones == set(filter_zones)
+
+ def test_cloudflare_provider_properties(self):
+ with (
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
+ return_value=CloudflareSession(
+ client=MagicMock(),
+ api_token=API_TOKEN,
+ api_key=None,
+ api_email=None,
+ ),
+ ),
+ patch(
+ "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
+ return_value=CloudflareIdentityInfo(
+ user_id=USER_ID,
+ email=USER_EMAIL,
+ accounts=[
+ CloudflareAccount(
+ id=ACCOUNT_ID,
+ name=ACCOUNT_NAME,
+ type="standard",
+ )
+ ],
+ audited_accounts=[ACCOUNT_ID],
+ ),
+ ),
+ ):
+ provider = CloudflareProvider()
+
+ assert provider.type == "cloudflare"
+ assert provider.session is not None
+ assert provider.identity is not None
+ assert provider.audit_config is not None
+ assert provider.fixer_config is not None
+ assert provider.mutelist is not None
diff --git a/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py b/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py
new file mode 100644
index 0000000000..394ec38bb7
--- /dev/null
+++ b/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py
@@ -0,0 +1,93 @@
+from unittest.mock import MagicMock
+
+import yaml
+
+from prowler.providers.cloudflare.lib.mutelist.mutelist import CloudflareMutelist
+
+MUTELIST_FIXTURE_PATH = (
+ "tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml"
+)
+
+
+class TestCloudflareMutelist:
+ def test_get_mutelist_file_from_local_file(self):
+ mutelist = CloudflareMutelist(mutelist_path=MUTELIST_FIXTURE_PATH)
+
+ with open(MUTELIST_FIXTURE_PATH) as f:
+ mutelist_fixture = yaml.safe_load(f)["Mutelist"]
+
+ assert mutelist.mutelist == mutelist_fixture
+ assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH
+
+ def test_get_mutelist_file_from_local_file_non_existent(self):
+ mutelist_path = "tests/providers/cloudflare/lib/mutelist/fixtures/not_present"
+ mutelist = CloudflareMutelist(mutelist_path=mutelist_path)
+
+ assert mutelist.mutelist == {}
+ assert mutelist.mutelist_file_path == mutelist_path
+
+ def test_validate_mutelist_not_valid_key(self):
+ mutelist_path = MUTELIST_FIXTURE_PATH
+ with open(mutelist_path) as f:
+ mutelist_fixture = yaml.safe_load(f)["Mutelist"]
+
+ mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"]
+ del mutelist_fixture["Accounts"]
+
+ mutelist = CloudflareMutelist(mutelist_content=mutelist_fixture)
+
+ assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0
+ assert mutelist.mutelist == {}
+ assert mutelist.mutelist_file_path is None
+
+ def test_is_finding_muted(self):
+ mutelist_content = {
+ "Accounts": {
+ "test-account-id": {
+ "Checks": {
+ "zones_dnssec_enabled": {
+ "Regions": ["*"],
+ "Resources": ["test-zone-id"],
+ }
+ }
+ }
+ }
+ }
+
+ mutelist = CloudflareMutelist(mutelist_content=mutelist_content)
+
+ finding = MagicMock()
+ finding.check_metadata = MagicMock()
+ finding.check_metadata.CheckID = "zones_dnssec_enabled"
+ finding.status = "FAIL"
+ finding.resource_id = "test-zone-id"
+ finding.resource_name = "example.com"
+ finding.resource_tags = []
+
+ assert mutelist.is_finding_muted(finding, "test-account-id")
+
+ def test_is_finding_not_muted(self):
+ mutelist_content = {
+ "Accounts": {
+ "test-account-id": {
+ "Checks": {
+ "zones_dnssec_enabled": {
+ "Regions": ["*"],
+ "Resources": ["other-zone-id"],
+ }
+ }
+ }
+ }
+ }
+
+ mutelist = CloudflareMutelist(mutelist_content=mutelist_content)
+
+ finding = MagicMock()
+ finding.check_metadata = MagicMock()
+ finding.check_metadata.CheckID = "zones_dnssec_enabled"
+ finding.status = "FAIL"
+ finding.resource_id = "test-zone-id"
+ finding.resource_name = "example.com"
+ finding.resource_tags = []
+
+ assert not mutelist.is_finding_muted(finding, "test-account-id")
diff --git a/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml b/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml
new file mode 100644
index 0000000000..9bbe4159cf
--- /dev/null
+++ b/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml
@@ -0,0 +1,9 @@
+Mutelist:
+ Accounts:
+ "test-account-id":
+ Checks:
+ "zones_dnssec_enabled":
+ Regions:
+ - "*"
+ Resources:
+ - "test-zone-id"
diff --git a/tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py b/tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py
new file mode 100644
index 0000000000..90d745932c
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py
@@ -0,0 +1,142 @@
+from unittest import mock
+
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ZONE_ID,
+ ZONE_NAME,
+ set_mocked_cloudflare_provider,
+)
+
+
+class Test_zones_dnssec_enabled:
+ def test_no_zones(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import (
+ zones_dnssec_enabled,
+ )
+
+ check = zones_dnssec_enabled()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_zone_dnssec_enabled(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(),
+ dnssec_status="active",
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import (
+ zones_dnssec_enabled,
+ )
+
+ check = zones_dnssec_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "PASS"
+ assert (
+ f"DNSSEC is enabled for zone {ZONE_NAME}" in result[0].status_extended
+ )
+
+ def test_zone_dnssec_disabled(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(),
+ dnssec_status="disabled",
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import (
+ zones_dnssec_enabled,
+ )
+
+ check = zones_dnssec_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ f"DNSSEC is not enabled for zone {ZONE_NAME}"
+ in result[0].status_extended
+ )
+
+ def test_zone_dnssec_pending(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(),
+ dnssec_status="pending",
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import (
+ zones_dnssec_enabled,
+ )
+
+ check = zones_dnssec_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py b/tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py
new file mode 100644
index 0000000000..847a74ee76
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py
@@ -0,0 +1,189 @@
+from unittest import mock
+
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+ StrictTransportSecurity,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ZONE_ID,
+ ZONE_NAME,
+ set_mocked_cloudflare_provider,
+)
+
+
+class Test_zones_hsts_enabled:
+ def test_no_zones(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import (
+ zones_hsts_enabled,
+ )
+
+ check = zones_hsts_enabled()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_zone_hsts_enabled_properly_configured(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ strict_transport_security=StrictTransportSecurity(
+ enabled=True,
+ max_age=31536000, # 1 year
+ include_subdomains=True,
+ preload=True,
+ )
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import (
+ zones_hsts_enabled,
+ )
+
+ check = zones_hsts_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "PASS"
+ assert "HSTS is enabled" in result[0].status_extended
+
+ def test_zone_hsts_disabled(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ strict_transport_security=StrictTransportSecurity(
+ enabled=False,
+ )
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import (
+ zones_hsts_enabled,
+ )
+
+ check = zones_hsts_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "HSTS is not enabled" in result[0].status_extended
+
+ def test_zone_hsts_enabled_no_subdomains(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ strict_transport_security=StrictTransportSecurity(
+ enabled=True,
+ max_age=31536000,
+ include_subdomains=False,
+ )
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import (
+ zones_hsts_enabled,
+ )
+
+ check = zones_hsts_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not include subdomains" in result[0].status_extended
+
+ def test_zone_hsts_enabled_low_max_age(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ strict_transport_security=StrictTransportSecurity(
+ enabled=True,
+ max_age=3600, # Only 1 hour
+ include_subdomains=True,
+ )
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import (
+ zones_hsts_enabled,
+ )
+
+ check = zones_hsts_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "max-age" in result[0].status_extended
diff --git a/tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py b/tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py
new file mode 100644
index 0000000000..10d0671cb1
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py
@@ -0,0 +1,140 @@
+from unittest import mock
+
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ZONE_ID,
+ ZONE_NAME,
+ set_mocked_cloudflare_provider,
+)
+
+
+class Test_zones_https_redirect_enabled:
+ def test_no_zones(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import (
+ zones_https_redirect_enabled,
+ )
+
+ check = zones_https_redirect_enabled()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_zone_https_redirect_enabled(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ always_use_https="on",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import (
+ zones_https_redirect_enabled,
+ )
+
+ check = zones_https_redirect_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "PASS"
+ assert "Always Use HTTPS is enabled" in result[0].status_extended
+
+ def test_zone_https_redirect_disabled(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ always_use_https="off",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import (
+ zones_https_redirect_enabled,
+ )
+
+ check = zones_https_redirect_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "FAIL"
+ assert "Always Use HTTPS is not enabled" in result[0].status_extended
+
+ def test_zone_https_redirect_none(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ always_use_https=None,
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import (
+ zones_https_redirect_enabled,
+ )
+
+ check = zones_https_redirect_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py b/tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py
new file mode 100644
index 0000000000..91dc78a2f7
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py
@@ -0,0 +1,178 @@
+from unittest import mock
+
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ZONE_ID,
+ ZONE_NAME,
+ set_mocked_cloudflare_provider,
+)
+
+
+class Test_zones_min_tls_version_secure:
+ def test_no_zones(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {}
+ zones_client.audit_config = {"min_tls_version": "1.2"}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import (
+ zones_min_tls_version_secure,
+ )
+
+ check = zones_min_tls_version_secure()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_zone_tls_version_secure(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ min_tls_version="1.2",
+ ),
+ )
+ }
+ zones_client.audit_config = {"min_tls_version": "1.2"}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import (
+ zones_min_tls_version_secure,
+ )
+
+ check = zones_min_tls_version_secure()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "PASS"
+ assert "1.2" in result[0].status_extended
+
+ def test_zone_tls_version_1_3(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ min_tls_version="1.3",
+ ),
+ )
+ }
+ zones_client.audit_config = {"min_tls_version": "1.2"}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import (
+ zones_min_tls_version_secure,
+ )
+
+ check = zones_min_tls_version_secure()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_zone_tls_version_insecure(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ min_tls_version="1.0",
+ ),
+ )
+ }
+ zones_client.audit_config = {"min_tls_version": "1.2"}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import (
+ zones_min_tls_version_secure,
+ )
+
+ check = zones_min_tls_version_secure()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].status == "FAIL"
+ assert "1.0" in result[0].status_extended
+ assert "below the recommended" in result[0].status_extended
+
+ def test_zone_tls_version_1_1(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ min_tls_version="1.1",
+ ),
+ )
+ }
+ zones_client.audit_config = {"min_tls_version": "1.2"}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import (
+ zones_min_tls_version_secure,
+ )
+
+ check = zones_min_tls_version_secure()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/cloudflare/services/zones/zones_service_test.py b/tests/providers/cloudflare/services/zones/zones_service_test.py
new file mode 100644
index 0000000000..d26629aedd
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_service_test.py
@@ -0,0 +1,64 @@
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+ StrictTransportSecurity,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME
+
+
+class TestZonesService:
+ def test_cloudflare_zone_model(self):
+ zone = CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ plan="Free",
+ )
+
+ assert zone.id == ZONE_ID
+ assert zone.name == ZONE_NAME
+ assert zone.status == "active"
+ assert zone.paused is False
+ assert zone.plan == "Free"
+
+ def test_cloudflare_zone_settings_model(self):
+ settings = CloudflareZoneSettings(
+ always_use_https="on",
+ min_tls_version="1.2",
+ ssl_encryption_mode="full",
+ tls_1_3="on",
+ automatic_https_rewrites="on",
+ universal_ssl="on",
+ waf="on",
+ security_level="high",
+ )
+
+ assert settings.always_use_https == "on"
+ assert settings.min_tls_version == "1.2"
+ assert settings.ssl_encryption_mode == "full"
+ assert settings.tls_1_3 == "on"
+
+ def test_strict_transport_security_model(self):
+ sts = StrictTransportSecurity(
+ enabled=True,
+ max_age=31536000,
+ include_subdomains=True,
+ preload=True,
+ nosniff=True,
+ )
+
+ assert sts.enabled is True
+ assert sts.max_age == 31536000
+ assert sts.include_subdomains is True
+ assert sts.preload is True
+ assert sts.nosniff is True
+
+ def test_strict_transport_security_defaults(self):
+ sts = StrictTransportSecurity()
+
+ assert sts.enabled is False
+ assert sts.max_age == 0
+ assert sts.include_subdomains is False
+ assert sts.preload is False
+ assert sts.nosniff is False
diff --git a/tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py b/tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py
new file mode 100644
index 0000000000..01aa66a274
--- /dev/null
+++ b/tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py
@@ -0,0 +1,185 @@
+from unittest import mock
+
+from prowler.providers.cloudflare.services.zones.zones_service import (
+ CloudflareZone,
+ CloudflareZoneSettings,
+)
+from tests.providers.cloudflare.cloudflare_fixtures import (
+ ZONE_ID,
+ ZONE_NAME,
+ set_mocked_cloudflare_provider,
+)
+
+
+class Test_zones_ssl_strict:
+ def test_no_zones(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import (
+ zones_ssl_strict,
+ )
+
+ check = zones_ssl_strict()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_zone_ssl_strict_mode(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ ssl_encryption_mode="strict",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import (
+ zones_ssl_strict,
+ )
+
+ check = zones_ssl_strict()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == ZONE_ID
+ assert result[0].resource_name == ZONE_NAME
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"SSL/TLS encryption mode is set to Full (Strict) for zone {ZONE_NAME}."
+ )
+
+ def test_zone_ssl_full_mode(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ ssl_encryption_mode="full",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import (
+ zones_ssl_strict,
+ )
+
+ check = zones_ssl_strict()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"SSL/TLS encryption mode is set to Full for zone {ZONE_NAME}, which is not Full (Strict)."
+ )
+
+ def test_zone_ssl_flexible_mode(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ ssl_encryption_mode="flexible",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import (
+ zones_ssl_strict,
+ )
+
+ check = zones_ssl_strict()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"SSL/TLS encryption mode is set to Flexible for zone {ZONE_NAME}, which is not Full (Strict)."
+ )
+
+ def test_zone_ssl_off_mode(self):
+ zones_client = mock.MagicMock
+ zones_client.zones = {
+ ZONE_ID: CloudflareZone(
+ id=ZONE_ID,
+ name=ZONE_NAME,
+ status="active",
+ paused=False,
+ settings=CloudflareZoneSettings(
+ ssl_encryption_mode="off",
+ ),
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_cloudflare_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client",
+ new=zones_client,
+ ),
+ ):
+ from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import (
+ zones_ssl_strict,
+ )
+
+ check = zones_ssl_strict()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"SSL/TLS encryption mode is set to Off for zone {ZONE_NAME}, which is not Full (Strict)."
+ )