feat(cloudflare): Add Cloudflare provider with zones service and critical security checks (#9423)

This commit is contained in:
Hugo Pereira Brito
2026-01-13 11:09:54 +01:00
committed by GitHub
parent 463fc32fca
commit b0eea61468
61 changed files with 2944 additions and 10 deletions

View File

@@ -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": [

View File

@@ -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) |
<Warning>
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.
</Warning>
## 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"
```
<Warning>
Never commit API tokens to version control or share them in plain text. Use environment variables or a secrets manager.
</Warning>
## 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"
```
<Note>
The email must be the same email address used to log into your Cloudflare account.
</Note>
## 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
<Warning>
**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.
</Warning>
## 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
```

View File

@@ -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

41
poetry.lock generated
View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,18 @@
### Account, Check and/or Region can be * to apply for all the cases.
### Account == <Cloudflare Account ID>
### Region == <Cloudflare Zone ID> (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"

View File

@@ -53,6 +53,7 @@ class Provider(str, Enum):
AWS = "aws"
GCP = "gcp"
AZURE = "azure"
CLOUDFLARE = "cloudflare"
KUBERNETES = "kubernetes"
M365 = "m365"
GITHUB = "github"

View File

@@ -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

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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"],

View File

@@ -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"""
<li class="list-group-item">
<b>Accounts:</b> {accounts}
</li>"""
# Build credentials items (only non-None values)
credentials_items = ""
# Authentication method
if provider.session.api_token:
credentials_items += """
<li class="list-group-item">
<b>Authentication:</b> API Token
</li>"""
elif provider.session.api_key and provider.session.api_email:
credentials_items += """
<li class="list-group-item">
<b>Authentication:</b> API Key + Email
</li>"""
# Email (from identity or session)
email = getattr(provider.identity, "email", None) or getattr(
provider.session, "api_email", None
)
if email:
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
Cloudflare Assessment Summary
</div>
<ul class="list-group list-group-flush">{assessment_items}
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
Cloudflare Credentials
</div>
<ul class="list-group list-group-flush">{credentials_items}
</ul>
</div>
</div>"""
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:
"""

View File

@@ -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:

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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
)

View File

@@ -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).",
)

View File

@@ -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)),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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\" \"<example_resource_name>\" {\n zone_id = \"<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": ""
}

View File

@@ -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

View File

@@ -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 = \"<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."
}

View File

@@ -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

View File

@@ -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 = \"<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": ""
}

View File

@@ -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

View File

@@ -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 = \"<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": ""
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 = \"<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": ""
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -0,0 +1,9 @@
Mutelist:
Accounts:
"test-account-id":
Checks:
"zones_dnssec_enabled":
Regions:
- "*"
Resources:
- "test-zone-id"

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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)."
)