Compare commits

..

1 Commits

Author SHA1 Message Date
Toni de la Fuente a534e50df4 feat(cloudflare): add Cloudflare provider with 13 security checks
Add complete Cloudflare provider integration to Prowler with comprehensive
security checks covering SSL/TLS, DNS, and firewall configurations.

Features:
- Cloudflare provider with API Token and API Key authentication
- 13 security checks across 3 services (SSL/TLS, DNS, Firewall)
- Support for zone-specific and account-wide scanning
- Full CLI integration with --api-token, --api-key, --api-email, --zone-id flags
- Mutelist support for suppressing findings
- Complete output support (CSV, JSON-OCSF, HTML)

Security Checks:
SSL/TLS (8 checks):
- ssl_mode_full_strict: Ensure SSL/TLS mode is Full (strict)
- ssl_tls_minimum_version: Ensure minimum TLS version is 1.2+
- ssl_tls_1_3_enabled: Ensure TLS 1.3 is enabled
- ssl_hsts_enabled: Ensure HSTS with recommended max-age
- ssl_hsts_include_subdomains: Ensure HSTS includes subdomains
- ssl_always_use_https: Ensure Always Use HTTPS is enabled
- ssl_automatic_https_rewrites_enabled: Ensure automatic HTTPS rewrites
- ssl_opportunistic_encryption_enabled: Ensure opportunistic encryption

Firewall (4 checks):
- firewall_waf_enabled: Ensure WAF is enabled
- firewall_security_level_medium_or_higher: Ensure security level >= medium
- firewall_browser_integrity_check_enabled: Ensure browser integrity check
- firewall_challenge_passage_configured: Ensure challenge passage configured

DNS (1 check):
- dns_dnssec_enabled: Ensure DNSSEC is enabled

Core Changes:
- Add CheckReportCloudflare model to prowler/lib/check/models.py
- Add Cloudflare provider initialization to prowler/providers/common/provider.py
- Add CloudflareOutputOptions to prowler/__main__.py
- Add Cloudflare output mapping to prowler/lib/outputs/finding.py
- Add Cloudflare entity type to prowler/lib/outputs/summary_table.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 18:06:44 +02:00
115 changed files with 2833 additions and 5318 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.14.0] (Prowler 5.13.0)
## [1.14.0] (Prowler UNRELEASED)
### Added
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
+4 -59
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -1164,18 +1164,6 @@ files = [
{file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
]
[[package]]
name = "circuitbreaker"
version = "2.1.3"
description = "Python Circuit Breaker pattern implementation"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"},
{file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"},
]
[[package]]
name = "click"
version = "8.2.1"
@@ -4058,29 +4046,6 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "oci"
version = "2.160.3"
description = "Oracle Cloud Infrastructure Python SDK"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"},
{file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"},
]
[package.dependencies]
certifi = "*"
circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""}
cryptography = ">=3.2.1,<46.0.0"
pyOpenSSL = ">=17.5.0,<25.0.0"
python-dateutil = ">=2.5.3,<3.0.0"
pytz = ">=2016.10"
[package.extras]
adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""]
[[package]]
name = "openai"
version = "1.101.0"
@@ -4669,7 +4634,6 @@ markdown = "3.9.0"
microsoft-kiota-abstractions = "1.9.2"
msgraph-sdk = "1.23.0"
numpy = "2.0.2"
oci = "2.160.3"
pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.5.0"
@@ -4686,8 +4650,8 @@ tzlocal = "5.3.1"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "v5.13"
resolved_reference = "b1856e42f0143a64e8cc26c7aa3c7643bd1083d3"
reference = "master"
resolved_reference = "a52697bfdfee83d14a49c11dcbe96888b5cd767e"
[[package]]
name = "psutil"
@@ -5172,25 +5136,6 @@ cffi = ">=1.4.1"
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]]
name = "pyopenssl"
version = "24.3.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"},
{file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"},
]
[package.dependencies]
cryptography = ">=41.0.5,<45"
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
[[package]]
name = "pyparsing"
version = "3.2.3"
@@ -6841,4 +6786,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "8fcb616e55530e7940019d3da33e955b026b9105e1216a3c5f39b411c015b6d7"
content-hash = "3c9164d668d37d6373eb5200bbe768232ead934d9312b9c68046b1df922789f3"
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.13",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -24,7 +24,7 @@ class Migration(migrations.Migration):
(
"name",
models.CharField(
max_length=100,
max_length=255,
validators=[django.core.validators.MinLengthValidator(3)],
),
),
-49
View File
@@ -2689,55 +2689,6 @@ class TestScanViewSet:
== "There is a problem with credentials."
)
@patch("api.v1.views.ScanViewSet._get_task_status")
@patch("api.v1.views.get_s3_client")
@patch("api.v1.views.env.str")
def test_threatscore_s3_wildcard(
self,
mock_env_str,
mock_get_s3_client,
mock_get_task_status,
authenticated_client,
scans_fixture,
):
"""
When the threatscore endpoint is called with an S3 output_location,
the view should list objects in S3 using wildcard pattern matching,
retrieve the matching PDF file, and return it with HTTP 200 and proper headers.
"""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
bucket = "test-bucket"
zip_key = "tenant-id/scan-id/prowler-output-foo.zip"
scan.output_location = f"s3://{bucket}/{zip_key}"
scan.save()
pdf_key = os.path.join(
os.path.dirname(zip_key),
"threatscore",
"prowler-output-123_threatscore_report.pdf",
)
mock_s3_client = Mock()
mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]}
mock_s3_client.get_object.return_value = {"Body": io.BytesIO(b"pdf-bytes")}
mock_env_str.return_value = bucket
mock_get_s3_client.return_value = mock_s3_client
mock_get_task_status.return_value = None
url = reverse("scan-threatscore", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "application/pdf"
assert response["Content-Disposition"].endswith(
'"prowler-output-123_threatscore_report.pdf"'
)
assert response.content == b"pdf-bytes"
mock_s3_client.list_objects_v2.assert_called_once()
mock_s3_client.get_object.assert_called_once_with(Bucket=bucket, Key=pdf_key)
def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch):
"""
When output_location is an S3 URL and the S3 client returns the file successfully,
+1 -13
View File
@@ -1,4 +1,3 @@
import fnmatch
import glob
import logging
import os
@@ -1776,18 +1775,7 @@ class ScanViewSet(BaseRLSViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
contents = resp.get("Contents", [])
keys = []
for obj in contents:
key = obj["Key"]
key_basename = os.path.basename(key)
if any(ch in suffix for ch in ("*", "?", "[")):
if fnmatch.fnmatch(key_basename, suffix):
keys.append(key)
elif key_basename == suffix:
keys.append(key)
elif key.endswith(suffix):
# Backward compatibility if suffix already includes directories
keys.append(key)
keys = [obj["Key"] for obj in contents if obj["Key"].endswith(suffix)]
if not keys:
return Response(
{
+27 -22
View File
@@ -20,10 +20,10 @@ from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import (
AWSWellArchitected,
)
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS
from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure
from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS
from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS
from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
@@ -183,21 +183,18 @@ def get_s3_client():
return s3_client
def _upload_to_s3(
tenant_id: str, scan_id: str, local_path: str, relative_key: str
) -> str | None:
def _upload_to_s3(tenant_id: str, zip_path: str, scan_id: str) -> str | None:
"""
Upload a local artifact to an S3 bucket under the tenant/scan prefix.
Upload the specified ZIP file to an S3 bucket.
If the S3 bucket environment variables are not configured,
the function returns None without performing an upload.
Args:
tenant_id (str): The tenant identifier used as the first segment of the S3 key.
scan_id (str): The scan identifier used as the second segment of the S3 key.
local_path (str): Filesystem path to the artifact to upload.
relative_key (str): Object key relative to `<tenant_id>/<scan_id>/`.
tenant_id (str): The tenant identifier, used as part of the S3 key prefix.
zip_path (str): The local file system path to the ZIP file to be uploaded.
scan_id (str): The scan identifier, used as part of the S3 key prefix.
Returns:
str | None: S3 URI of the uploaded artifact, or None if the upload is skipped.
str: The S3 URI of the uploaded file (e.g., "s3://<bucket>/<key>") if successful.
None: If the required environment variables for the S3 bucket are not set.
Raises:
botocore.exceptions.ClientError: If the upload attempt to S3 fails for any reason.
"""
@@ -205,19 +202,27 @@ def _upload_to_s3(
if not bucket:
return
if not relative_key:
return
if not os.path.isfile(local_path):
return
try:
s3 = get_s3_client()
s3_key = f"{tenant_id}/{scan_id}/{relative_key}"
s3.upload_file(Filename=local_path, Bucket=bucket, Key=s3_key)
# Upload the ZIP file (outputs) to the S3 bucket
zip_key = f"{tenant_id}/{scan_id}/{os.path.basename(zip_path)}"
s3.upload_file(
Filename=zip_path,
Bucket=bucket,
Key=zip_key,
)
return f"s3://{base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET}/{s3_key}"
# Upload the compliance directory to the S3 bucket
compliance_dir = os.path.join(os.path.dirname(zip_path), "compliance")
for filename in os.listdir(compliance_dir):
local_path = os.path.join(compliance_dir, filename)
if not os.path.isfile(local_path):
continue
file_key = f"{tenant_id}/{scan_id}/compliance/{filename}"
s3.upload_file(Filename=local_path, Bucket=bucket, Key=file_key)
return f"s3://{base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET}/{zip_key}"
except (ClientError, NoCredentialsError, ParamValidationError, ValueError) as e:
logger.error(f"S3 upload failed: {str(e)}")
+1 -6
View File
@@ -1317,12 +1317,7 @@ def generate_threatscore_report_job(
min_risk_level=4,
)
upload_uri = _upload_to_s3(
tenant_id,
scan_id,
pdf_path,
f"threatscore/{Path(pdf_path).name}",
)
upload_uri = _upload_to_s3(tenant_id, pdf_path, scan_id)
if upload_uri:
try:
rmtree(Path(pdf_path).parent, ignore_errors=True)
+1 -19
View File
@@ -1,4 +1,3 @@
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from shutil import rmtree
@@ -414,24 +413,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
writer._data.clear()
compressed = _compress_output_files(out_dir)
upload_uri = _upload_to_s3(
tenant_id,
scan_id,
compressed,
os.path.basename(compressed),
)
compliance_dir_path = Path(comp_dir).parent
if compliance_dir_path.exists():
for artifact_path in sorted(compliance_dir_path.iterdir()):
if artifact_path.is_file():
_upload_to_s3(
tenant_id,
scan_id,
str(artifact_path),
f"compliance/{artifact_path.name}",
)
upload_uri = _upload_to_s3(tenant_id, compressed, scan_id)
# S3 integrations (need output_directory)
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+8 -26
View File
@@ -72,26 +72,17 @@ class TestOutputs:
client_mock = MagicMock()
mock_get_client.return_value = client_mock
result = _upload_to_s3(
"tenant-id",
"scan-id",
str(zip_path),
"outputs.zip",
)
result = _upload_to_s3("tenant-id", str(zip_path), "scan-id")
expected_uri = "s3://test-bucket/tenant-id/scan-id/outputs.zip"
assert result == expected_uri
client_mock.upload_file.assert_called_once_with(
Filename=str(zip_path),
Bucket="test-bucket",
Key="tenant-id/scan-id/outputs.zip",
)
assert client_mock.upload_file.call_count == 2
@patch("tasks.jobs.export.get_s3_client")
@patch("tasks.jobs.export.base")
def test_upload_to_s3_missing_bucket(self, mock_base, mock_get_client):
mock_base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = ""
result = _upload_to_s3("tenant", "scan", "/tmp/fake.zip", "fake.zip")
result = _upload_to_s3("tenant", "/tmp/fake.zip", "scan")
assert result is None
@patch("tasks.jobs.export.get_s3_client")
@@ -110,15 +101,11 @@ class TestOutputs:
client_mock = MagicMock()
mock_get_client.return_value = client_mock
result = _upload_to_s3(
"tenant",
"scan",
str(compliance_dir / "subdir"),
"compliance/subdir",
)
result = _upload_to_s3("tenant", str(zip_path), "scan")
assert result is None
client_mock.upload_file.assert_not_called()
expected_uri = "s3://test-bucket/tenant/scan/results.zip"
assert result == expected_uri
client_mock.upload_file.assert_called_once()
@patch(
"tasks.jobs.export.get_s3_client",
@@ -139,12 +126,7 @@ class TestOutputs:
compliance_dir.mkdir()
(compliance_dir / "report.csv").write_text("csv")
_upload_to_s3(
"tenant",
"scan",
str(zip_path),
"zipfile.zip",
)
_upload_to_s3("tenant", str(zip_path), "scan")
mock_logger.assert_called()
@patch("tasks.jobs.export.rls_transaction")
@@ -85,12 +85,6 @@ class TestGenerateThreatscoreReport:
only_failed=True,
min_risk_level=4,
)
mock_upload.assert_called_once_with(
self.tenant_id,
self.scan_id,
"/tmp/threatscore_path_threatscore_report.pdf",
"threatscore/threatscore_path_threatscore_report.pdf",
)
mock_rmtree.assert_called_once_with(
Path("/tmp/threatscore_path_threatscore_report.pdf").parent,
ignore_errors=True,
File diff suppressed because it is too large Load Diff
+4 -300
View File
@@ -5,7 +5,8 @@ title: 'Prowler Services'
Here you can find how to create a new service, or to complement an existing one, for a [Prowler Provider](/developer-guide/provider).
<Note>
First ensure that the provider you want to add the service is already created. It can be checked [here](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). If the provider is not present, please refer to the [Provider](./provider.md) documentation to create it from scratch.
First ensure that the provider you want to add the service is already created. It can be checked [here](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). If the provider is not present, please refer to the [Provider](/developer-guide/provider) documentation to create it from scratch.
</Note>
## Introduction
@@ -200,11 +201,11 @@ class <Item>(BaseModel):
#### Service Attributes
_Optimized Data Storage with Python Dictionaries_
*Optimized Data Storage with Python Dictionaries*
Each group of resources within a service should be structured as a Python [dictionary](https://docs.python.org/3/tutorial/datastructures.html#dictionaries) to enable efficient lookups. The dictionary lookup operation has [O(1) complexity](https://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions), and lookups are constantly executed.
_Assigning Unique Identifiers_
*Assigning Unique Identifiers*
Each dictionary key must be a unique ID to identify the resource in a univocal way.
@@ -240,301 +241,6 @@ Provider-Specific Permissions Documentation:
- [M365](/user-guide/providers/microsoft365/authentication#required-permissions)
- [GitHub](/user-guide/providers/github/authentication)
## Service Architecture and Cross-Service Communication
### Core Principle: Service Isolation with Client Communication
Each service must contain **ONLY** the information unique to that specific service. When a check requires information from multiple services, it must use the **client objects** of other services rather than directly accessing their data structures.
This architecture ensures:
- **Loose coupling** between services
- **Clear separation of concerns**
- **Maintainable and testable code**
- **Consistent data access patterns**
### Cross-Service Communication Pattern
Instead of services directly accessing each other's internal data, checks should import and use client objects:
**❌ INCORRECT - Direct data access:**
```python
# DON'T DO THIS
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import cloudtrail_service
from prowler.providers.aws.services.s3.s3_service import s3_service
class cloudtrail_bucket_requires_mfa_delete(Check):
def execute(self):
# WRONG: Directly accessing service data
for trail in cloudtrail_service.trails.values():
for bucket in s3_service.buckets.values():
# Direct access violates separation of concerns
```
**✅ CORRECT - Client-based communication:**
```python
# DO THIS INSTEAD
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import cloudtrail_client
from prowler.providers.aws.services.s3.s3_client import s3_client
class cloudtrail_bucket_requires_mfa_delete(Check):
def execute(self):
# CORRECT: Using client objects for cross-service communication
for trail in cloudtrail_client.trails.values():
trail_bucket = trail.s3_bucket
for bucket in s3_client.buckets.values():
if trail_bucket == bucket.name:
# Use bucket properties through s3_client
if bucket.mfa_delete:
# Implementation logic
```
### Real-World Example: CloudTrail + S3 Integration
This example demonstrates how CloudTrail checks validate S3 bucket configurations:
```python
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import cloudtrail_client
from prowler.providers.aws.services.s3.s3_client import s3_client
class cloudtrail_bucket_requires_mfa_delete(Check):
def execute(self):
findings = []
if cloudtrail_client.trails is not None:
for trail in cloudtrail_client.trails.values():
if trail.is_logging:
trail_bucket_is_in_account = False
trail_bucket = trail.s3_bucket
# Cross-service communication: CloudTrail check uses S3 client
for bucket in s3_client.buckets.values():
if trail_bucket == bucket.name:
trail_bucket_is_in_account = True
if bucket.mfa_delete:
report.status = "PASS"
report.status_extended = f"Trail {trail.name} bucket ({trail_bucket}) has MFA delete enabled."
# Handle cross-account scenarios
if not trail_bucket_is_in_account:
report.status = "MANUAL"
report.status_extended = f"Trail {trail.name} bucket ({trail_bucket}) is a cross-account bucket or out of Prowler's audit scope, please check it manually."
findings.append(report)
return findings
```
**Key Benefits:**
- **CloudTrail service** only contains CloudTrail-specific data (trails, configurations)
- **S3 service** only contains S3-specific data (buckets, policies, ACLs)
- **Check logic** orchestrates between services using their public client interfaces
- **Cross-account detection** is handled gracefully when resources span accounts
### Service Consolidation Guidelines
**When to combine services in the same file:**
Implement multiple services as **separate classes in the same file** when two services are **practically the same** or one is a **direct extension** of another.
**Example: S3 and S3Control**
S3Control is an extension of S3 that provides account-level controls and access points. Both are implemented in `s3_service.py`:
```python
# File: prowler/providers/aws/services/s3/s3_service.py
class S3(AWSService):
"""Standard S3 service for bucket operations"""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.buckets = {}
self.regions_with_buckets = []
# S3-specific initialization
self._list_buckets(provider)
self._get_bucket_versioning()
# ... other S3-specific operations
class S3Control(AWSService):
"""S3Control service for account-level and access point operations"""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.account_public_access_block = None
self.access_points = {}
# S3Control-specific initialization
self._get_public_access_block()
self._list_access_points()
# ... other S3Control-specific operations
```
**Separate client files:**
```python
# File: prowler/providers/aws/services/s3/s3_client.py
from prowler.providers.aws.services.s3.s3_service import S3
s3_client = S3(Provider.get_global_provider())
# File: prowler/providers/aws/services/s3/s3control_client.py
from prowler.providers.aws.services.s3.s3_service import S3Control
s3control_client = S3Control(Provider.get_global_provider())
```
**When NOT to consolidate services:**
Keep services separate when they:
- **Operate on different resource types** (EC2 vs RDS)
- **Have different authentication mechanisms** (different API endpoints)
- **Serve different operational domains** (IAM vs CloudTrail)
- **Have different regional behaviors** (global vs regional services)
### Cross-Service Dependencies Guidelines
**1. Always use client imports:**
```python
# Correct pattern
from prowler.providers.aws.services.service_a.service_a_client import service_a_client
from prowler.providers.aws.services.service_b.service_b_client import service_b_client
```
**2. Handle missing resources gracefully:**
```python
# Handle cross-service scenarios
resource_found_in_account = False
for external_resource in other_service_client.resources.values():
if target_resource_id == external_resource.id:
resource_found_in_account = True
# Process found resource
break
if not resource_found_in_account:
# Handle cross-account or missing resource scenarios
report.status = "MANUAL"
report.status_extended = "Resource is cross-account or out of audit scope"
```
**3. Document cross-service dependencies:**
```python
class check_with_dependencies(Check):
"""
Check Description
Dependencies:
- service_a_client: For primary resource information
- service_b_client: For related resource validation
- service_c_client: For policy analysis
"""
```
## Regional Service Implementation
When implementing services for regional providers (like AWS, Azure, GCP), special considerations are needed to handle resource discovery across multiple geographic locations. This section provides a complete guide using AWS as the reference example.
### Regional vs Non-Regional Services
**Regional Services:** Require iteration across multiple geographic locations where resources may exist (e.g., EC2 instances, VPC, RDS databases).
**Non-Regional/Global Services:** Operate at a global or tenant level without regional concepts (e.g., IAM users, Route53 hosted zones).
### AWS Regional Implementation Example
AWS is the perfect example of a regional provider. Here's how Prowler handles AWS's regional architecture:
```python
# File: prowler/providers/aws/services/ec2/ec2_service.py
class EC2(AWSService):
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.instances = {}
self.security_groups = {}
# Regional resource discovery across all AWS regions
self.__threading_call__(self._describe_instances)
self.__threading_call__(self._describe_security_groups)
def _describe_instances(self, regional_client):
"""Discover EC2 instances in a specific region"""
try:
describe_instances_paginator = regional_client.get_paginator("describe_instances")
for page in describe_instances_paginator.paginate():
for reservation in page["Reservations"]:
for instance in reservation["Instances"]:
# Each instance includes its region
self.instances[instance["InstanceId"]] = Instance(
id=instance["InstanceId"],
region=regional_client.region,
state=instance["State"]["Name"],
# ... other properties
)
except Exception as error:
logger.error(f"Failed to describe instances in {regional_client.region}: {error}")
```
#### Regional Check Execution
```python
# File: prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.py
class ec2_instance_public_ip(Check):
def execute(self):
findings = []
# Automatically iterates across ALL AWS regions where instances exist
for instance in ec2_client.instances.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=instance)
report.region = instance.region # Critical: region attribution
report.resource_arn = f"arn:aws:ec2:{instance.region}:{instance.account_id}:instance/{instance.id}"
if instance.public_ip:
report.status = "FAIL"
report.status_extended = f"Instance {instance.id} in {instance.region} has public IP {instance.public_ip}"
else:
report.status = "PASS"
report.status_extended = f"Instance {instance.id} in {instance.region} does not have a public IP"
findings.append(report)
return findings
```
#### Key AWS Regional Features
**Region-Specific ARNs:**
```
arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0
arn:aws:s3:eu-west-1:123456789012:bucket/my-bucket
arn:aws:rds:ap-southeast-2:123456789012:db:my-database
```
**Parallel Processing:**
- Each region processed independently in separate threads
- Failed regions don't affect other regions
- User can filter specific regions: `-f us-east-1`
**Global vs Regional Services:**
- **Regional**: EC2, RDS, VPC (require region iteration)
- **Global**: IAM, Route53, CloudFront (single `us-east-1` call)
This architecture allows Prowler to efficiently scan AWS accounts with resources spread across multiple regions while maintaining performance and error isolation.
### Regional Service Best Practices
1. **Use Threading for Regional Discovery**: Leverage the `__threading_call__` method to parallelize resource discovery across regions
2. **Store Region Information**: Always include region metadata in resource objects for proper attribution
3. **Handle Regional Failures Gracefully**: Ensure that failures in one region don't affect others
4. **Optimize for Performance**: Use paginated calls and efficient data structures for large-scale resource discovery
5. **Support Region Filtering**: Allow users to limit scans to specific regions for focused audits
## Best Practices
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time.
@@ -546,5 +252,3 @@ This architecture allows Prowler to efficiently scan AWS accounts with resources
- Collect and store resource tags and additional attributes to support richer checks and reporting.
- Leverage shared utility helpers for session setup, identifier parsing, and other cross-cutting concerns to avoid code duplication. This kind of code is typically stored in a `lib` folder in the service folder.
- Keep code modular, maintainable, and well-documented for ease of extension and troubleshooting.
- **Each service should contain only information unique to that specific service** - use client objects for cross-service communication.
- **Handle cross-account and missing resources gracefully** when checks span multiple services.
+1 -2
View File
@@ -114,8 +114,7 @@
"group": "Tutorials",
"pages": [
"user-guide/tutorials/prowler-app-sso-entra",
"user-guide/tutorials/bulk-provider-provisioning",
"user-guide/tutorials/aws-organizations-bulk-provisioning"
"user-guide/tutorials/bulk-provider-provisioning"
]
}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

@@ -1,491 +0,0 @@
---
title: 'AWS Organizations Bulk Provisioning in Prowler'
---
Prowler offers an automated tool to discover and provision all AWS accounts within an AWS Organization. This streamlines onboarding for organizations managing multiple AWS accounts by automatically generating the configuration needed for bulk provisioning.
The tool, `aws_org_generator.py`, complements the [Bulk Provider Provisioning](./bulk-provider-provisioning) tool and is available in the Prowler repository at: [util/prowler-bulk-provisioning](https://github.com/prowler-cloud/prowler/tree/master/util/prowler-bulk-provisioning)
<Note>
Native support for bulk provisioning AWS Organizations and similar multi-account structures directly in the Prowler UI/API is on the official roadmap.
Track progress and vote for this feature at: [Bulk Provisioning in the UI/API for AWS Organizations](https://roadmap.prowler.com/p/builk-provisioning-in-the-uiapi-for-aws-organizations-and-alike)
</Note>
{/* TODO: Add screenshot of the tool in action */}
## Overview
The AWS Organizations Bulk Provisioning tool simplifies multi-account onboarding by:
* Automatically discovering all active accounts in an AWS Organization
* Generating YAML configuration files for bulk provisioning
* Supporting account filtering and custom role configurations
* Eliminating manual entry of account IDs and role ARNs
## Prerequisites
### Requirements
* Python 3.7 or higher
* AWS credentials with Organizations read access
* ProwlerRole (or custom role) deployed across all target accounts
* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
* For self-hosted Prowler App, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints)
* Learn how to create API keys: [Prowler App API Keys](../providers/prowler-app-api-keys)
### Deploying ProwlerRole Across AWS Organizations
Before using the AWS Organizations generator, deploy the ProwlerRole across all accounts in the organization using CloudFormation StackSets.
<Note>
**Follow the official documentation:**
[Deploying Prowler IAM Roles Across AWS Organizations](../providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations)
**Key points:**
* Use CloudFormation StackSets from the management account
* Deploy to all organizational units (OUs) or specific OUs
* Use an external ID for enhanced security
* Ensure the role has necessary permissions for Prowler scans
</Note>
### Installation
Clone the repository and install required dependencies:
```bash
git clone https://github.com/prowler-cloud/prowler.git
cd prowler/util/prowler-bulk-provisioning
pip install -r requirements-aws-org.txt
```
### AWS Credentials Setup
Configure AWS credentials with Organizations read access:
* **Management account credentials**, or
* **Delegated administrator account** with `organizations:ListAccounts` permission
Required IAM permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"organizations:ListAccounts",
"organizations:DescribeOrganization"
],
"Resource": "*"
}
]
}
```
### Prowler API Key Setup
Configure your Prowler API key:
```bash
export PROWLER_API_KEY="pk_example-api-key"
```
To create an API key:
1. Log in to Prowler Cloud or Prowler App
2. Click **Profile** → **Account**
3. Click **Create API Key**
4. Provide a descriptive name and optionally set an expiration date
5. Copy the generated API key (it will only be shown once)
For detailed instructions, see: [Prowler App API Keys](../providers/prowler-app-api-keys)
## Basic Usage
### Generate Configuration for All Accounts
To generate a YAML configuration file for all active accounts in the organization:
```bash
python aws_org_generator.py -o aws-accounts.yaml --external-id prowler-ext-id-2024
```
This command:
1. Lists all ACTIVE accounts in the organization
2. Generates YAML entries for each account
3. Saves the configuration to `aws-accounts.yaml`
**Output:**
```
Fetching accounts from AWS Organizations...
Found 47 active accounts in organization
Generated configuration for 47 accounts
Configuration written to: aws-accounts.yaml
Next steps:
1. Review the generated file: cat aws-accounts.yaml | head -n 20
2. Run bulk provisioning: python prowler_bulk_provisioning.py aws-accounts.yaml
```
### Review Generated Configuration
Review the generated YAML configuration:
```bash
head -n 20 aws-accounts.yaml
```
**Example output:**
```yaml
- provider: aws
uid: '111111111111'
alias: Production-Account
auth_method: role
credentials:
role_arn: arn:aws:iam::111111111111:role/ProwlerRole
external_id: prowler-ext-id-2024
- provider: aws
uid: '222222222222'
alias: Development-Account
auth_method: role
credentials:
role_arn: arn:aws:iam::222222222222:role/ProwlerRole
external_id: prowler-ext-id-2024
```
### Dry Run Mode
Test the configuration without writing a file:
```bash
python aws_org_generator.py \
--external-id prowler-ext-id-2024 \
--dry-run
```
## Advanced Configuration
### Using a Specific AWS Profile
Specify an AWS profile when multiple profiles are configured:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--profile org-management-admin \
--external-id prowler-ext-id-2024
```
### Excluding Specific Accounts
Exclude the management account or other accounts from provisioning:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--external-id prowler-ext-id-2024 \
--exclude 123456789012,210987654321
```
Common exclusion scenarios:
* Management account (requires different permissions)
* Break-glass accounts (emergency access)
* Suspended or archived accounts
### Including Only Specific Accounts
Generate configuration for specific accounts only:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--external-id prowler-ext-id-2024 \
--include 111111111111,222222222222,333333333333
```
### Custom Role Name
Specify a custom role name if not using the default `ProwlerRole`:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--role-name ProwlerExecutionRole \
--external-id prowler-ext-id-2024
```
### Custom Alias Format
Customize account aliases using template variables:
```bash
# Use account name and ID
python aws_org_generator.py \
-o aws-accounts.yaml \
--alias-format "{name}-{id}" \
--external-id prowler-ext-id-2024
# Use email prefix
python aws_org_generator.py \
-o aws-accounts.yaml \
--alias-format "{email}" \
--external-id prowler-ext-id-2024
```
Available template variables:
* `{name}` - Account name
* `{id}` - Account ID
* `{email}` - Account email
### Additional Role Assumption Options
Configure optional role assumption parameters:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--role-name ProwlerRole \
--external-id prowler-ext-id-2024 \
--session-name prowler-scan-session \
--duration-seconds 3600
```
## Complete Workflow Example
<Steps>
<Step title="Deploy ProwlerRole Using StackSets">
1. Log in to the AWS management account
2. Open CloudFormation → StackSets
3. Create a new StackSet using the [Prowler role template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml)
4. Deploy to all organizational units
5. Use a unique external ID (e.g., `prowler-org-2024-abc123`)
{/* TODO: Add screenshot of CloudFormation StackSets deployment */}
</Step>
<Step title="Generate YAML Configuration">
Configure AWS credentials and generate the YAML file:
```bash
# Using management account credentials
export AWS_PROFILE=org-management
# Generate configuration
python aws_org_generator.py \
-o aws-org-accounts.yaml \
--external-id prowler-org-2024-abc123 \
--exclude 123456789012
```
**Output:**
```
Fetching accounts from AWS Organizations...
Using AWS profile: org-management
Found 47 active accounts in organization
Generated configuration for 46 accounts
Configuration written to: aws-org-accounts.yaml
Next steps:
1. Review the generated file: cat aws-org-accounts.yaml | head -n 20
2. Run bulk provisioning: python prowler_bulk_provisioning.py aws-org-accounts.yaml
```
</Step>
<Step title="Review Generated Configuration">
Verify the generated YAML configuration:
```bash
# View first 20 lines
head -n 20 aws-org-accounts.yaml
# Check for unexpected accounts
grep "uid:" aws-org-accounts.yaml
# Verify role ARNs
grep "role_arn:" aws-org-accounts.yaml | head -5
# Count accounts
grep "provider: aws" aws-org-accounts.yaml | wc -l
```
</Step>
<Step title="Run Bulk Provisioning">
Provision all accounts to Prowler Cloud or Prowler App:
```bash
# Set Prowler API key
export PROWLER_API_KEY="pk_example-api-key"
# Run bulk provisioning with connection testing
python prowler_bulk_provisioning.py aws-org-accounts.yaml
```
**With custom options:**
```bash
python prowler_bulk_provisioning.py aws-org-accounts.yaml \
--concurrency 10 \
--timeout 120
```
**Successful output:**
```
[1] ✅ Created provider (id=db9a8985-f9ec-4dd8-b5a0-e05ab3880bed)
[1] ✅ Created secret (id=466f76c6-5878-4602-a4bc-13f9522c1fd2)
[1] ✅ Connection test: Connected
[2] ✅ Created provider (id=7a99f789-0cf5-4329-8279-2d443a962676)
[2] ✅ Created secret (id=c5702180-f7c4-40fd-be0e-f6433479b126)
[2] ✅ Connection test: Connected
Done. Success: 47 Failures: 0
```
{/* TODO: Add screenshot of successful bulk provisioning output */}
</Step>
</Steps>
## Command Reference
### Full Command-Line Options
```bash
python aws_org_generator.py \
-o OUTPUT_FILE \
--role-name ROLE_NAME \
--external-id EXTERNAL_ID \
--session-name SESSION_NAME \
--duration-seconds SECONDS \
--alias-format FORMAT \
--exclude ACCOUNT_IDS \
--include ACCOUNT_IDS \
--profile AWS_PROFILE \
--region AWS_REGION \
--dry-run
```
## Troubleshooting
### Error: "No AWS credentials found"
**Solution:** Configure AWS credentials using one of these methods:
```bash
# Method 1: AWS CLI configure
aws configure
# Method 2: Environment variables
export AWS_ACCESS_KEY_ID=your-key-id
export AWS_SECRET_ACCESS_KEY=your-secret-key
# Method 3: Use AWS profile
export AWS_PROFILE=org-management
```
### Error: "Access denied to AWS Organizations API"
**Cause:** Current credentials don't have permission to list organization accounts.
**Solution:**
* Ensure management account credentials are used
* Verify IAM permissions include `organizations:ListAccounts`
* Check IAM policies for Organizations access
### Error: "AWS Organizations is not enabled"
**Cause:** The account is not part of an organization.
**Solution:** This tool requires an AWS Organization. Create one in the AWS Organizations console or use standard bulk provisioning for standalone accounts.
### No Accounts Generated After Filters
**Cause:** All accounts were filtered out by `--exclude` or `--include` options.
**Solution:** Review filter options and verify account IDs are correct:
```bash
# List all accounts in organization
aws organizations list-accounts --query "Accounts[?Status=='ACTIVE'].[Id,Name]" --output table
```
### Connection Test Failures During Bulk Provisioning
**Cause:** ProwlerRole may not be deployed correctly or credentials are invalid.
**Solution:**
* Verify StackSet deployment status in CloudFormation
* Check role trust policy includes correct external ID
* Test role assumption manually:
```bash
aws sts assume-role \
--role-arn arn:aws:iam::123456789012:role/ProwlerRole \
--role-session-name test \
--external-id prowler-ext-id-2024
```
## Security Best Practices
### Use External ID
Always use an external ID when assuming cross-account roles:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--external-id $(uuidgen | tr '[:upper:]' '[:lower:]')
```
The external ID must match the one configured in the ProwlerRole trust policy across all accounts.
### Exclude Sensitive Accounts
Exclude accounts that shouldn't be scanned or require special handling:
```bash
python aws_org_generator.py \
-o aws-accounts.yaml \
--external-id prowler-ext-id \
--exclude 123456789012,111111111111 # management, break-glass accounts
```
### Review Generated Configuration
Always review the generated YAML before provisioning:
```bash
# Check for unexpected accounts
grep "uid:" aws-org-accounts.yaml
# Verify role ARNs
grep "role_arn:" aws-org-accounts.yaml | head -5
# Count accounts
grep "provider: aws" aws-org-accounts.yaml | wc -l
```
## Next Steps
<Columns cols={2}>
<Card title="Bulk Provider Provisioning" icon="terminal" href="/user-guide/tutorials/bulk-provider-provisioning">
Learn how to bulk provision providers in Prowler.
</Card>
<Card title="Prowler App" icon="pen-to-square" href="/user-guide/tutorials/prowler-app">
Detailed instructions on how to use Prowler.
</Card>
</Columns>
@@ -17,18 +17,14 @@ The Bulk Provider Provisioning tool automates the creation of cloud providers in
* Testing connections to verify successful authentication
* Processing multiple providers concurrently for efficiency
<Tip>
**Using AWS Organizations?** For organizations with many AWS accounts, use the automated [AWS Organizations Bulk Provisioning](./aws-organizations-bulk-provisioning) tool to automatically discover and generate configuration for all accounts in your organization.
</Tip>
## Prerequisites
### Requirements
* Python 3.7 or higher
* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
* Prowler API token (from Prowler Cloud or self-hosted Prowler App)
* For self-hosted Prowler App, remember to [point to your API base URL](#custom-api-endpoints)
* Learn how to create API keys: [Prowler App API Keys](../providers/prowler-app-api-keys)
* Authentication credentials for target cloud providers
### Installation
@@ -43,21 +39,28 @@ pip install -r requirements.txt
### Authentication Setup
Configure your Prowler API key:
Configure your Prowler API token:
```bash
export PROWLER_API_KEY="pk_example-api-key"
export PROWLER_API_TOKEN="your-prowler-api-token"
```
To create an API key:
To obtain an API token programmatically:
1. Log in to Prowler Cloud or Prowler App
2. Click **Profile** → **Account**
3. Click **Create API Key**
4. Provide a descriptive name and optionally set an expiration date
5. Copy the generated API key (it will only be shown once)
For detailed instructions, see: [Prowler App API Keys](../providers/prowler-app-api-keys)
```bash
export PROWLER_API_TOKEN=$(curl --location 'https://api.prowler.com/api/v1/tokens' \
--header 'Content-Type: application/vnd.api+json' \
--header 'Accept: application/vnd.api+json' \
--data-raw '{
"data": {
"type": "tokens",
"attributes": {
"email": "your@email.com",
"password": "your-password"
}
}
}' | jq -r .data.attributes.access)
```
## Configuration File Structure
@@ -337,11 +340,11 @@ Done. Success: 2 Failures: 0
## Troubleshooting
### Invalid API Key
### Invalid API Token
```
Error: 401 Unauthorized
Solution: Verify your PROWLER_API_KEY environment variable or --api-key parameter
Solution: Verify your PROWLER_API_TOKEN or --token parameter
```
### Network Timeouts
+1 -1
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
## [0.1.0] (Prowler 5.13.0)
## [0.1.0] (Prowler UNRELEASED)
### Added
- Initial release of Prowler MCP Server [(#8695)](https://github.com/prowler-cloud/prowler/pull/8695)
+1 -3
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.13.0] (Prowler v5.13.0)
## [v5.13.0] (Prowler UNRELEASED)
### Added
- Support for AdditionalURLs in outputs [(#8651)](https://github.com/prowler-cloud/prowler/pull/8651)
@@ -17,7 +17,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Oracle Cloud provider with CIS 3.0 benchmark [(#8893)](https://github.com/prowler-cloud/prowler/pull/8893)
- Support for Atlassian Document Format (ADF) in Jira integration [(#8878)](https://github.com/prowler-cloud/prowler/pull/8878)
- Add Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
- Improve Provider documentation guide [(#8430)](https://github.com/prowler-cloud/prowler/pull/8430)
- `cloudstorage_bucket_lifecycle_management_enabled` check for GCP provider [(#8936)](https://github.com/prowler-cloud/prowler/pull/8936)
### Changed
@@ -52,7 +51,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
- Add missing attributes for Mitre Attack AWS, Azure and GCP [(#8907)](https://github.com/prowler-cloud/prowler/pull/8907)
- Fix KeyError in CloudSQL and Monitoring services in GCP provider [(#8909)](https://github.com/prowler-cloud/prowler/pull/8909)
- Fix Value Errors in Entra service for M365 provider [(#8919)](https://github.com/prowler-cloud/prowler/pull/8919)
- Fix ResourceName in GCP provider [(#8928)](https://github.com/prowler-cloud/prowler/pull/8928)
- Fix KeyError in `elb_ssl_listeners_use_acm_certificate` check and handle None cluster version in `eks_cluster_uses_a_supported_version` check [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
- Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
+6 -1
View File
@@ -49,10 +49,10 @@ from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import (
AWSWellArchitected,
)
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS
from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure
from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS
from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS
from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
@@ -102,6 +102,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
@@ -336,6 +337,10 @@ def prowler():
output_options = OCIOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
elif provider == "cloudflare":
output_options = CloudflareOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
+31
View File
@@ -694,6 +694,37 @@ class CheckReportGithub(Check_Report):
)
@dataclass
class CheckReportCloudflare(Check_Report):
"""Contains the Cloudflare Check's finding information."""
resource_name: str
resource_id: str
zone_name: str
def __init__(
self,
metadata: Dict,
resource: Any,
resource_name: str = None,
resource_id: str = None,
zone_name: str = None,
) -> None:
"""Initialize the Cloudflare Check's finding information.
Args:
metadata: The metadata of the check.
resource: Basic information about the resource. Defaults to None.
resource_name: The name of the resource related with the finding.
resource_id: The id of the resource related with the finding.
zone_name: The zone name of the resource related with the finding.
"""
super().__init__(metadata, resource)
self.resource_name = resource_name or getattr(resource, "name", "")
self.resource_id = resource_id or getattr(resource, "id", "")
self.zone_name = zone_name or getattr(resource, "zone_name", "")
@dataclass
class CheckReportM365(Check_Report):
"""Contains the M365 Check's finding information."""
+15
View File
@@ -337,6 +337,21 @@ 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"] = provider.auth_method
output_data["account_uid"] = get_nested_attribute(
provider, "identity.account_id"
)
output_data["account_name"] = get_nested_attribute(
provider, "identity.account_name"
)
output_data["account_email"] = get_nested_attribute(
provider, "identity.account_email"
)
output_data["resource_name"] = check_output.resource_name
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.zone_name
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
+3
View File
@@ -67,6 +67,9 @@ def display_summary_table(
elif provider.type == "llm":
entity_type = "LLM"
audited_entities = provider.model
elif provider.type == "cloudflare":
entity_type = "Account"
audited_entities = provider.identity.account_name
elif provider.type == "oci":
entity_type = "Tenancy"
audited_entities = (
+178
View File
@@ -0,0 +1,178 @@
# Cloudflare Provider for Prowler
This directory contains the Cloudflare provider implementation for Prowler, enabling Cloud Security Posture Management (CSPM) for Cloudflare infrastructure.
## Overview
The Cloudflare provider allows Prowler to scan and assess the security posture of your Cloudflare zones, firewall rules, SSL/TLS settings, and other security configurations.
## Authentication
The Cloudflare provider supports two authentication methods:
### 1. API Token (Recommended)
Create an API token with the necessary permissions at https://dash.cloudflare.com/profile/api-tokens
```bash
export CLOUDFLARE_API_TOKEN="your-api-token"
prowler cloudflare
```
Or pass it directly:
```bash
prowler cloudflare --api-token "your-api-token"
```
### 2. API Key + Email
Use your Global API Key and email:
```bash
export CLOUDFLARE_API_KEY="your-api-key"
export CLOUDFLARE_API_EMAIL="your@email.com"
prowler cloudflare
```
Or pass them directly:
```bash
prowler cloudflare --api-key "your-api-key" --api-email "your@email.com"
```
## Scoping
You can scope your scan to specific accounts or zones:
```bash
# Scan specific zones
prowler cloudflare --zone-id zone_id_1 zone_id_2
# Scan specific accounts
prowler cloudflare --account-id account_id_1 account_id_2
```
## Available Services
The Cloudflare provider currently includes the following services:
- **firewall**: Firewall rules and Web Application Firewall (WAF) settings
- **ssl**: SSL/TLS configuration and certificate settings
## Security Checks
### Firewall Service
- `firewall_waf_enabled`: Ensures Web Application Firewall (WAF) is enabled for zones
### SSL Service
- `ssl_tls_minimum_version`: Ensures minimum TLS version is set to 1.2 or higher
- `ssl_always_use_https`: Ensures 'Always Use HTTPS' is enabled for automatic HTTP to HTTPS redirects
## Directory Structure
```
cloudflare/
├── cloudflare_provider.py # Main provider class
├── models.py # Cloudflare-specific models
├── exceptions/ # Cloudflare-specific exceptions
│ └── exceptions.py
├── lib/
│ ├── arguments/ # CLI argument definitions
│ ├── mutelist/ # Mutelist functionality
│ └── service/ # Base service class
└── services/ # Cloudflare services
├── firewall/ # Firewall service and checks
│ ├── firewall_service.py
│ ├── firewall_client.py
│ └── firewall_waf_enabled/
└── ssl/ # SSL/TLS service and checks
├── ssl_service.py
├── ssl_client.py
├── ssl_tls_minimum_version/
└── ssl_always_use_https/
```
## Usage Examples
### Basic Scan
```bash
prowler cloudflare
```
### Scan with API Token
```bash
prowler cloudflare --api-token "your-api-token"
```
### Scan Specific Zones
```bash
prowler cloudflare --zone-id zone_123 zone_456
```
### Run Specific Checks
```bash
prowler cloudflare -c ssl_tls_minimum_version ssl_always_use_https
```
### Generate JSON Output
```bash
prowler cloudflare -o json
```
## Required Permissions
For the API token, you need the following permissions:
- **Zone:Read** - To list and read zone information
- **Zone Settings:Read** - To read zone settings including SSL/TLS configurations
- **Firewall Services:Read** - To read firewall rules and WAF settings
- **User:Read** - To verify authentication
## Adding New Checks
To add a new security check:
1. Create a new directory under the appropriate service (e.g., `services/firewall/new_check_name/`)
2. Create the check file: `new_check_name.py`
3. Create the metadata file: `new_check_name.metadata.json`
4. Implement the check class inheriting from `Check`
5. Use `CheckReportCloudflare` for findings
Example check structure:
```python
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.service_name.service_client import service_client
class check_name(Check):
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for resource_id, resource in service_client.resources.items():
report = CheckReportCloudflare(metadata=self.metadata(), resource=resource)
# Implement your check logic here
findings.append(report)
return findings
```
## Contributing
When contributing new services or checks:
1. Follow the existing directory structure
2. Include comprehensive metadata for each check
3. Add appropriate error handling
4. Update this README with new services/checks
5. Test thoroughly with various Cloudflare configurations
## Support
For issues, questions, or contributions, please refer to the main Prowler repository.
@@ -0,0 +1,406 @@
import os
from os import environ
import requests
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.mutelist.mutelist import Mutelist
from prowler.lib.utils.utils import print_boxes
from prowler.providers.cloudflare.exceptions.exceptions import (
CloudflareEnvironmentVariableError,
CloudflareInvalidCredentialsError,
CloudflareSetUpIdentityError,
CloudflareSetUpSessionError,
)
from prowler.providers.cloudflare.lib.mutelist.mutelist import CloudflareMutelist
from prowler.providers.cloudflare.models import (
CloudflareIdentityInfo,
CloudflareSession,
)
from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
class CloudflareProvider(Provider):
"""
Cloudflare Provider class
This class is responsible for setting up the Cloudflare provider, including the session, identity,
audit configuration, fixer configuration, and mutelist.
Attributes:
_type (str): The type of the provider.
_auth_method (str): The authentication method used by the provider.
_session (CloudflareSession): The session object for the provider.
_identity (CloudflareIdentityInfo): The identity information for the provider.
_audit_config (dict): The audit configuration for the provider.
_fixer_config (dict): The fixer configuration for the provider.
_mutelist (Mutelist): The mutelist for the provider.
_account_ids (list): List of account IDs to scan.
_zone_ids (list): List of zone IDs to scan.
audit_metadata (Audit_Metadata): The audit metadata for the provider.
"""
_type: str = "cloudflare"
_auth_method: str = None
_session: CloudflareSession
_identity: CloudflareIdentityInfo
_audit_config: dict
_mutelist: Mutelist
_account_ids: list
_zone_ids: list
audit_metadata: Audit_Metadata
def __init__(
self,
# Authentication credentials
api_token: str = "",
api_key: str = "",
api_email: str = "",
# Provider configuration
config_path: str = None,
config_content: dict = None,
fixer_config: dict = {},
mutelist_path: str = None,
mutelist_content: dict = None,
account_ids: list = None,
zone_ids: list = None,
):
"""
Cloudflare Provider constructor
Args:
api_token (str): Cloudflare API Token.
api_key (str): Cloudflare API Key.
api_email (str): Cloudflare API Email (used with API Key).
config_path (str): Path to the audit configuration file.
config_content (dict): Audit configuration content.
fixer_config (dict): Fixer configuration content.
mutelist_path (str): Path to the mutelist file.
mutelist_content (dict): Mutelist content.
account_ids (list): List of account IDs to scan.
zone_ids (list): List of zone IDs to scan.
"""
logger.info("Instantiating Cloudflare Provider...")
# Set scoping parameters
self._account_ids = account_ids or []
self._zone_ids = zone_ids or []
self._session = CloudflareProvider.setup_session(api_token, api_key, api_email)
# Set the authentication method
if api_token:
self._auth_method = "API Token"
elif api_key and api_email:
self._auth_method = "API Key + Email"
elif environ.get("CLOUDFLARE_API_TOKEN", ""):
self._auth_method = "Environment Variable for API Token"
elif environ.get("CLOUDFLARE_API_KEY", "") and environ.get(
"CLOUDFLARE_API_EMAIL", ""
):
self._auth_method = "Environment Variables for API Key and Email"
self._identity = CloudflareProvider.setup_identity(self._session)
# Audit Config
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)
# Fixer Config
self._fixer_config = fixer_config
# Mutelist
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,
)
Provider.set_global_provider(self)
@property
def auth_method(self):
"""Returns the authentication method for the Cloudflare provider."""
return self._auth_method
@property
def session(self):
"""Returns the session object for the Cloudflare provider."""
return self._session
@property
def identity(self):
"""Returns the identity information for the Cloudflare provider."""
return self._identity
@property
def type(self):
"""Returns the type of the Cloudflare provider."""
return self._type
@property
def audit_config(self):
return self._audit_config
@property
def fixer_config(self):
return self._fixer_config
@property
def mutelist(self) -> CloudflareMutelist:
"""
mutelist method returns the provider's mutelist.
"""
return self._mutelist
@property
def account_ids(self) -> list:
"""
account_ids method returns the provider's account ID list for scoping.
"""
return self._account_ids
@property
def zone_ids(self) -> list:
"""
zone_ids method returns the provider's zone ID list for scoping.
"""
return self._zone_ids
@staticmethod
def setup_session(
api_token: str = None,
api_key: str = None,
api_email: str = None,
) -> CloudflareSession:
"""
Returns the Cloudflare session with authentication credentials.
Args:
api_token (str): Cloudflare API Token.
api_key (str): Cloudflare API Key.
api_email (str): Cloudflare API Email.
Returns:
CloudflareSession: Authenticated session credentials for API requests.
"""
session_api_token = ""
session_api_key = ""
session_api_email = ""
try:
# Ensure that at least one authentication method is selected
if api_token:
session_api_token = api_token
elif api_key and api_email:
session_api_key = api_key
session_api_email = api_email
else:
# Try API Token from environment variable
logger.info(
"Looking for CLOUDFLARE_API_TOKEN environment variable as user has not provided any credentials...."
)
session_api_token = environ.get("CLOUDFLARE_API_TOKEN", "")
if not session_api_token:
# Try API Key + Email from environment variables
logger.info(
"Looking for CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL environment variables...."
)
session_api_key = environ.get("CLOUDFLARE_API_KEY", "")
session_api_email = environ.get("CLOUDFLARE_API_EMAIL", "")
if not session_api_token and not (session_api_key and session_api_email):
raise CloudflareEnvironmentVariableError(
file=os.path.basename(__file__),
message="No authentication method selected and no environment variables were found.",
)
credentials = CloudflareSession(
api_token=session_api_token,
api_key=session_api_key,
api_email=session_api_email,
)
return credentials
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise CloudflareSetUpSessionError(
original_exception=error,
)
@staticmethod
def setup_identity(session: CloudflareSession) -> CloudflareIdentityInfo:
"""
Returns the Cloudflare identity information
Returns:
CloudflareIdentityInfo: An instance of CloudflareIdentityInfo containing the identity information.
"""
try:
# Setup headers for API requests
headers = CloudflareProvider._get_headers(session)
# Verify user endpoint to get account information
response = requests.get(
"https://api.cloudflare.com/client/v4/user", headers=headers, timeout=10
)
if response.status_code != 200:
raise CloudflareInvalidCredentialsError(
message=f"Failed to authenticate with Cloudflare API: {response.status_code} - {response.text}"
)
try:
user_data = response.json()
except Exception as json_error:
raise CloudflareInvalidCredentialsError(
message=f"Failed to parse Cloudflare API response: {json_error}. Response text: {response.text[:200]}"
)
if not user_data:
raise CloudflareInvalidCredentialsError(
message=f"Cloudflare API returned empty response. Status: {response.status_code}"
)
if not user_data.get("success", False):
error_messages = user_data.get("errors", [])
raise CloudflareInvalidCredentialsError(
message=f"Cloudflare API authentication failed: {error_messages}"
)
result = user_data.get("result")
if not result:
raise CloudflareInvalidCredentialsError(
message=f"Cloudflare API returned empty result. Full response: {user_data}"
)
identity = CloudflareIdentityInfo(
account_id=str(result.get("id", "")),
account_name=result.get("username") or result.get("email", "Unknown"),
account_email=result.get("email", ""),
)
return identity
except CloudflareInvalidCredentialsError:
raise
except Exception as error:
# Get line number safely
lineno = error.__traceback__.tb_lineno if error.__traceback__ else "unknown"
logger.critical(f"{error.__class__.__name__}[{lineno}]: {error}")
raise CloudflareSetUpIdentityError(
original_exception=error,
)
@staticmethod
def _get_headers(session: CloudflareSession) -> dict:
"""
Returns HTTP headers for Cloudflare API requests.
Args:
session (CloudflareSession): The Cloudflare session with authentication.
Returns:
dict: Headers dictionary with authentication credentials.
"""
headers = {"Content-Type": "application/json"}
if session.api_token:
headers["Authorization"] = f"Bearer {session.api_token}"
elif session.api_key and session.api_email:
headers["X-Auth-Key"] = session.api_key
headers["X-Auth-Email"] = session.api_email
return headers
def print_credentials(self):
"""
Prints the Cloudflare credentials.
Usage:
>>> self.print_credentials()
"""
report_lines = [
f"Cloudflare Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}",
f"Cloudflare Account Name: {Fore.YELLOW}{self.identity.account_name}{Style.RESET_ALL}",
f"Cloudflare Account Email: {Fore.YELLOW}{self.identity.account_email}{Style.RESET_ALL}",
f"Authentication Method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}",
]
report_title = (
f"{Style.BRIGHT}Using the Cloudflare credentials below:{Style.RESET_ALL}"
)
print_boxes(report_lines, report_title)
@staticmethod
def test_connection(
api_token: str = "",
api_key: str = "",
api_email: str = "",
raise_on_exception: bool = True,
) -> Connection:
"""Test connection to Cloudflare.
Test the connection to Cloudflare using the provided credentials.
Args:
api_token (str): Cloudflare API Token.
api_key (str): Cloudflare API Key.
api_email (str): Cloudflare API Email.
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
Returns:
Connection: Connection object with success status or error information.
Raises:
Exception: If failed to test the connection to Cloudflare.
CloudflareEnvironmentVariableError: If environment variables are missing.
CloudflareInvalidCredentialsError: If the provided credentials are invalid.
CloudflareSetUpSessionError: If there is an error setting up the session.
CloudflareSetUpIdentityError: If there is an error setting up the identity.
Examples:
>>> CloudflareProvider.test_connection(api_token="your-api-token")
Connection(is_connected=True)
>>> CloudflareProvider.test_connection(api_key="your-api-key", api_email="your@email.com")
Connection(is_connected=True)
"""
try:
# Set up the Cloudflare session
session = CloudflareProvider.setup_session(
api_token=api_token,
api_key=api_key,
api_email=api_email,
)
# Set up the identity to test the connection
CloudflareProvider.setup_identity(session)
return Connection(is_connected=True)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if raise_on_exception:
raise error
return Connection(error=error)
@@ -0,0 +1,13 @@
from prowler.providers.cloudflare.exceptions.exceptions import (
CloudflareEnvironmentVariableError,
CloudflareInvalidCredentialsError,
CloudflareSetUpIdentityError,
CloudflareSetUpSessionError,
)
__all__ = [
"CloudflareEnvironmentVariableError",
"CloudflareInvalidCredentialsError",
"CloudflareSetUpIdentityError",
"CloudflareSetUpSessionError",
]
@@ -0,0 +1,71 @@
from prowler.exceptions.exceptions import ProwlerException
class CloudflareException(ProwlerException):
"""Base class for Cloudflare Provider exceptions"""
CLOUDFLARE_ERROR_CODES = {
(1000, "CloudflareEnvironmentVariableError"): {
"message": "Cloudflare environment variables are not set correctly",
"remediation": "Ensure that CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL environment variables are set correctly.",
},
(1001, "CloudflareInvalidCredentialsError"): {
"message": "Cloudflare credentials are invalid",
"remediation": "Ensure that the provided Cloudflare API credentials are valid and have the necessary permissions.",
},
(1002, "CloudflareSetUpSessionError"): {
"message": "Error setting up Cloudflare session",
"remediation": "Check your Cloudflare API credentials and network connectivity.",
},
(1003, "CloudflareSetUpIdentityError"): {
"message": "Error setting up Cloudflare identity",
"remediation": "Ensure that your Cloudflare API credentials have the necessary permissions to retrieve account information.",
},
}
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 not error_info:
error_info = {
"message": "Unknown Cloudflare error",
"remediation": "Please check your configuration.",
}
if message:
error_info = error_info.copy()
error_info["message"] = message
super().__init__(
code=code,
source=provider,
file=file,
original_exception=original_exception,
error_info=error_info,
)
class CloudflareEnvironmentVariableError(CloudflareException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
1000, file=file, original_exception=original_exception, message=message
)
class CloudflareInvalidCredentialsError(CloudflareException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
1001, file=file, original_exception=original_exception, message=message
)
class CloudflareSetUpSessionError(CloudflareException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
1002, file=file, original_exception=original_exception, message=message
)
class CloudflareSetUpIdentityError(CloudflareException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
1003, file=file, original_exception=original_exception, message=message
)
@@ -0,0 +1,76 @@
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",
)
cloudflare_auth_subparser = cloudflare_parser.add_argument_group(
"Authentication Modes"
)
# Authentication Modes
cloudflare_auth_subparser.add_argument(
"--api-token",
nargs="?",
help="Cloudflare API Token for authentication",
default=None,
metavar="CLOUDFLARE_API_TOKEN",
)
cloudflare_auth_subparser.add_argument(
"--api-key",
nargs="?",
help="Cloudflare API Key for authentication (requires --api-email)",
default=None,
metavar="CLOUDFLARE_API_KEY",
)
cloudflare_auth_subparser.add_argument(
"--api-email",
nargs="?",
help="Cloudflare API Email for authentication (used with --api-key)",
default=None,
metavar="CLOUDFLARE_API_EMAIL",
)
cloudflare_scoping_subparser = cloudflare_parser.add_argument_group("Scan Scoping")
cloudflare_scoping_subparser.add_argument(
"--account-id",
"--account-ids",
nargs="*",
help="Cloudflare Account ID(s) to scan",
default=None,
metavar="ACCOUNT_ID",
)
cloudflare_scoping_subparser.add_argument(
"--zone-id",
"--zone-ids",
nargs="*",
help="Cloudflare Zone ID(s) to scan",
default=None,
metavar="ZONE_ID",
)
def validate_arguments(arguments):
"""
Validate Cloudflare provider arguments.
Returns:
tuple: (is_valid, error_message)
"""
# If API key is provided, email must also be provided
if arguments.api_key and not arguments.api_email:
return (
False,
"Cloudflare API Key requires API Email. Please provide --api-email",
)
if arguments.api_email and not arguments.api_key:
return (
False,
"Cloudflare API Email requires API Key. Please provide --api-key",
)
return (True, "")
@@ -0,0 +1,34 @@
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):
"""
CloudflareMutelist class extends the Mutelist class to provide Cloudflare-specific mutelist functionality.
This class is used to manage muted findings for Cloudflare resources.
"""
def is_finding_muted(
self,
finding: CheckReportCloudflare,
account_name: str,
) -> bool:
"""
Check if a finding is muted based on the mutelist configuration.
Args:
finding (CheckReportCloudflare): The finding to check
account_name (str): The Cloudflare account name
Returns:
bool: True if the finding is muted, False otherwise
"""
return self.is_muted(
account_name,
finding.check_metadata.CheckID,
"*", # Cloudflare doesn't have regions
finding.resource_name,
unroll_dict(unroll_tags(finding.resource_tags)),
)
@@ -0,0 +1,169 @@
import requests
from colorama import Fore, Style
from prowler.lib.logger import logger
class CloudflareService:
"""
Base class for Cloudflare services
This class provides common functionality for all Cloudflare services,
including API client setup and error handling.
"""
def __init__(self, service_name: str, provider):
"""
Initialize CloudflareService
Args:
service_name (str): Name of the service
provider: Cloudflare provider instance
"""
self.service = service_name
self.provider = provider
self.session = provider.session
self.api_base_url = "https://api.cloudflare.com/client/v4"
self.headers = self._get_headers()
def _get_headers(self) -> dict:
"""
Returns HTTP headers for Cloudflare API requests.
Returns:
dict: Headers dictionary with authentication credentials.
"""
headers = {"Content-Type": "application/json"}
if self.session.api_token:
headers["Authorization"] = f"Bearer {self.session.api_token}"
elif self.session.api_key and self.session.api_email:
headers["X-Auth-Key"] = self.session.api_key
headers["X-Auth-Email"] = self.session.api_email
return headers
def _api_request(
self, method: str, endpoint: str, params: dict = None, json_data: dict = None
) -> dict:
"""
Make an API request to Cloudflare
Args:
method (str): HTTP method (GET, POST, PUT, DELETE)
endpoint (str): API endpoint (e.g., "/accounts")
params (dict): Query parameters
json_data (dict): JSON data for POST/PUT requests
Returns:
dict: API response data
Raises:
Exception: If the API request fails
"""
url = f"{self.api_base_url}{endpoint}"
try:
response = requests.request(
method=method,
url=url,
headers=self.headers,
params=params,
json=json_data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if not data.get("success"):
errors = data.get("errors", [])
logger.error(
f"{Fore.RED}Cloudflare API Error:{Style.RESET_ALL} {errors}"
)
return {}
return data.get("result", {})
except requests.exceptions.RequestException as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return {}
def _api_request_paginated(
self, endpoint: str, params: dict = None, page_size: int = 50
) -> list:
"""
Make a paginated API request to Cloudflare
Args:
endpoint (str): API endpoint
params (dict): Query parameters
page_size (int): Number of results per page
Returns:
list: Combined results from all pages
"""
all_results = []
page = 1
if params is None:
params = {}
params["per_page"] = page_size
while True:
params["page"] = page
url = f"{self.api_base_url}{endpoint}"
try:
response = requests.get(
url, headers=self.headers, params=params, timeout=30
)
response.raise_for_status()
data = response.json()
if not data.get("success"):
break
result = data.get("result", [])
if not result:
break
all_results.extend(result)
# Check if there are more pages
result_info = data.get("result_info", {})
if page >= result_info.get("total_pages", 0):
break
page += 1
except requests.exceptions.RequestException as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
break
return all_results
def _handle_cloudflare_api_error(
self, error: Exception, action: str, resource: str = ""
):
"""
Handle Cloudflare API errors with consistent logging
Args:
error (Exception): The exception that occurred
action (str): Description of the action being performed
resource (str): The resource being accessed
"""
error_message = f"Error {action}"
if resource:
error_message += f" for {resource}"
error_message += f": {error}"
logger.error(
f"{Fore.RED}{error_message}{Style.RESET_ALL} ({error.__class__.__name__})"
)
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
from pydantic.v1 import BaseModel
from prowler.config.config import output_file_timestamp
from prowler.providers.common.models import ProviderOutputOptions
class CloudflareSession(BaseModel):
"""Cloudflare session model storing authentication credentials"""
api_token: Optional[str] = None
api_key: Optional[str] = None
api_email: Optional[str] = None
class CloudflareIdentityInfo(BaseModel):
"""Cloudflare account identity information"""
account_id: str
account_name: str
account_email: str
class CloudflareOutputOptions(ProviderOutputOptions):
"""Cloudflare-specific output options"""
def __init__(self, arguments, bulk_checks_metadata, identity):
# First call ProviderOutputOptions init
super().__init__(arguments, bulk_checks_metadata)
# Check if custom output filename was input, if not, set the default
if (
not hasattr(arguments, "output_filename")
or arguments.output_filename is None
):
self.output_filename = (
f"prowler-output-{identity.account_name}-{output_file_timestamp}"
)
else:
self.output_filename = arguments.output_filename
@@ -0,0 +1,3 @@
from .dns_service import DNS
dns_client = DNS
@@ -0,0 +1,4 @@
from prowler.providers.cloudflare.services.dns.dns_service import DNS
from prowler.providers.common.provider import Provider
dns_client = DNS(Provider.get_global_provider())
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "dns_dnssec_enabled",
"CheckTitle": "Ensure DNSSEC is enabled to prevent DNS spoofing",
"CheckType": [],
"ServiceName": "dns",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Zone",
"Description": "This check ensures that DNSSEC (DNS Security Extensions) is enabled for Cloudflare zones to prevent DNS spoofing attacks and ensure data integrity by cryptographically signing DNS records.",
"Risk": "Without DNSSEC enabled, attackers can perform DNS spoofing (cache poisoning) attacks, redirecting users to malicious sites and intercepting sensitive information.",
"RelatedUrl": "https://developers.cloudflare.com/dns/dnssec/",
"Remediation": {
"Code": {
"CLI": "cloudflare dns dnssec enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> DNS -> Settings -> DNSSEC -> Enable DNSSEC",
"Terraform": "resource \"cloudflare_zone_dnssec\" \"example\" {\n zone_id = var.zone_id\n}"
},
"Recommendation": {
"Text": "Enable DNSSEC for all Cloudflare zones to prevent DNS spoofing and ensure DNS data integrity. After enabling, add DS records to your domain registrar.",
"Url": "https://developers.cloudflare.com/dns/dnssec/"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "After enabling DNSSEC in Cloudflare, you must add the DS records to your domain registrar for DNSSEC to function properly."
}
@@ -0,0 +1,31 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.dns.dns_client import dns_client
class dns_dnssec_enabled(Check):
"""Check if DNSSEC is enabled to prevent DNS spoofing"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, dnssec_settings in dns_client.dnssec_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=dnssec_settings,
resource_name=dnssec_settings.zone_name,
resource_id=zone_id,
zone_name=dnssec_settings.zone_name,
)
if dnssec_settings.dnssec_enabled:
report.status = "PASS"
report.status_extended = f"Zone {dnssec_settings.zone_name} has DNSSEC enabled (status: {dnssec_settings.dnssec_status}), preventing DNS spoofing and ensuring data integrity."
else:
report.status = "FAIL"
report.status_extended = f"Zone {dnssec_settings.zone_name} does not have DNSSEC enabled (status: {dnssec_settings.dnssec_status}). Enable DNSSEC to prevent DNS spoofing and ensure data integrity."
findings.append(report)
return findings
@@ -0,0 +1,107 @@
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.cloudflare.lib.service.service import CloudflareService
class DNS(CloudflareService):
"""Cloudflare DNS service for managing DNS settings"""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.zones = self._list_zones()
self.dnssec_settings = self._get_dnssec_settings()
def _list_zones(self) -> dict:
"""
List all Cloudflare zones
Returns:
dict: Dictionary of zones keyed by zone ID
"""
logger.info("DNS - Listing Zones...")
zones = {}
try:
# If specific zone IDs are provided, use those
if self.provider.zone_ids:
for zone_id in self.provider.zone_ids:
zone_data = self._api_request("GET", f"/zones/{zone_id}")
if zone_data:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
)
else:
# List all zones
all_zones = self._api_request_paginated("/zones")
for zone_data in all_zones:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
)
logger.info(f"Found {len(zones)} zone(s) for DNS checks")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return zones
def _get_dnssec_settings(self) -> dict:
"""
Get DNSSEC settings for all zones
Returns:
dict: Dictionary of DNSSEC settings keyed by zone ID
"""
logger.info("DNS - Getting DNSSEC Settings...")
dnssec_settings = {}
try:
for zone_id, zone in self.zones.items():
# Get DNSSEC status
dnssec = self._api_request("GET", f"/zones/{zone_id}/dnssec")
dnssec_settings[zone_id] = DNSSECSettings(
zone_id=zone_id,
zone_name=zone.name,
dnssec_enabled=(
dnssec.get("status", "disabled") == "active"
if dnssec
else False
),
dnssec_status=(
dnssec.get("status", "disabled") if dnssec else "disabled"
),
)
logger.info(f"Retrieved DNSSEC settings for {len(dnssec_settings)} zone(s)")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return dnssec_settings
class Zone(BaseModel):
"""Model for Cloudflare Zone"""
id: str
name: str
account_id: str
class DNSSECSettings(BaseModel):
"""Model for Cloudflare DNSSEC Settings"""
zone_id: str
zone_name: str
dnssec_enabled: bool
dnssec_status: str
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "firewall_browser_integrity_check_enabled",
"CheckTitle": "Ensure Browser Integrity Check is enabled to filter malicious traffic",
"CheckType": [],
"ServiceName": "firewall",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that Browser Integrity Check is enabled for Cloudflare zones to filter malicious traffic based on HTTP header anomalies and known attack patterns.",
"Risk": "Without Browser Integrity Check enabled, malicious bots and automated tools with suspicious HTTP headers can access your site, increasing the risk of attacks.",
"RelatedUrl": "https://developers.cloudflare.com/waf/tools/browser-integrity-check/",
"Remediation": {
"Code": {
"CLI": "cloudflare firewall browser-check enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> Security -> Settings -> Browser Integrity Check -> Enable",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n browser_check = \"on\"\n }\n}"
},
"Recommendation": {
"Text": "Enable Browser Integrity Check for all Cloudflare zones to filter malicious traffic based on HTTP header anomalies.",
"Url": "https://developers.cloudflare.com/waf/tools/browser-integrity-check/"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Browser Integrity Check may occasionally block legitimate traffic from older browsers or automated tools. Monitor and adjust if needed."
}
@@ -0,0 +1,33 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.firewall.firewall_client import (
firewall_client,
)
class firewall_browser_integrity_check_enabled(Check):
"""Check if Browser Integrity Check is enabled to filter malicious traffic"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, security_settings in firewall_client.security_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=security_settings,
resource_name=security_settings.zone_name,
resource_id=zone_id,
zone_name=security_settings.zone_name,
)
if security_settings.browser_integrity_check:
report.status = "PASS"
report.status_extended = f"Zone {security_settings.zone_name} has Browser Integrity Check enabled, filtering malicious traffic based on HTTP header anomalies."
else:
report.status = "FAIL"
report.status_extended = f"Zone {security_settings.zone_name} does not have Browser Integrity Check enabled. Enable it to filter malicious traffic based on HTTP header anomalies."
findings.append(report)
return findings
@@ -0,0 +1,30 @@
{
"Provider": "cloudflare",
"CheckID": "firewall_challenge_passage_configured",
"CheckTitle": "Ensure Challenge Passage is configured appropriately",
"CheckType": [],
"ServiceName": "firewall",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "Zone",
"Description": "This check ensures that Challenge Passage (challenge TTL) is configured to an appropriate value (recommended: 1 hour / 3600 seconds) to reduce friction for verified visitors while maintaining a security window.",
"Risk": "Setting Challenge Passage too short causes excessive challenges for legitimate users, degrading experience. Setting it too long may allow attackers more time to exploit compromised sessions.",
"RelatedUrl": "https://developers.cloudflare.com/waf/tools/challenge-passage/",
"Remediation": {
"Code": {
"CLI": "cloudflare firewall challenge-ttl set --zone-id <zone_id> --ttl 3600",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> Security -> Settings -> Challenge Passage -> Set to 1 hour",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n challenge_ttl = 3600\n }\n}"
},
"Recommendation": {
"Text": "Set Challenge Passage to 1 hour (3600 seconds) for all Cloudflare zones to balance security with user experience.",
"Url": "https://developers.cloudflare.com/waf/tools/challenge-passage/"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Challenge Passage determines how long a visitor who passes a challenge can access the site without being challenged again."
}
@@ -0,0 +1,35 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.firewall.firewall_client import (
firewall_client,
)
class firewall_challenge_passage_configured(Check):
"""Check if Challenge Passage is configured appropriately"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
# Recommended challenge TTL is 1 hour (3600 seconds) to balance security and user experience
recommended_ttl = 3600
for zone_id, security_settings in firewall_client.security_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=security_settings,
resource_name=security_settings.zone_name,
resource_id=zone_id,
zone_name=security_settings.zone_name,
)
if security_settings.challenge_ttl == recommended_ttl:
report.status = "PASS"
report.status_extended = f"Zone {security_settings.zone_name} has Challenge Passage set to {security_settings.challenge_ttl} seconds (recommended: {recommended_ttl}), balancing security with user experience."
else:
report.status = "FAIL"
report.status_extended = f"Zone {security_settings.zone_name} has Challenge Passage set to {security_settings.challenge_ttl} seconds. Recommended: {recommended_ttl} seconds (1 hour) to reduce friction for verified visitors while maintaining security."
findings.append(report)
return findings
@@ -0,0 +1,4 @@
from prowler.providers.cloudflare.services.firewall.firewall_service import Firewall
from prowler.providers.common.provider import Provider
firewall_client = Firewall(Provider.get_global_provider())
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "firewall_security_level_medium_or_higher",
"CheckTitle": "Ensure Security Level is set to Medium or higher",
"CheckType": [],
"ServiceName": "firewall",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that Security Level is set to Medium or higher for Cloudflare zones to balance protection with user accessibility by filtering suspicious traffic.",
"Risk": "Setting Security Level too low (off, essentially off, or low) may allow malicious traffic to reach your origin server, increasing the risk of attacks.",
"RelatedUrl": "https://developers.cloudflare.com/waf/tools/security-level/",
"Remediation": {
"Code": {
"CLI": "cloudflare firewall security-level set --zone-id <zone_id> --level medium",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> Security -> Settings -> Security Level -> Set to Medium or higher",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n security_level = \"medium\"\n }\n}"
},
"Recommendation": {
"Text": "Set Security Level to Medium for all Cloudflare zones. Adjust to High or Under Attack during active attacks.",
"Url": "https://developers.cloudflare.com/waf/tools/security-level/"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Security Level can be temporarily increased to High or Under Attack during active attacks, but Medium is recommended for normal operation."
}
@@ -0,0 +1,35 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.firewall.firewall_client import (
firewall_client,
)
class firewall_security_level_medium_or_higher(Check):
"""Check if Security Level is set to Medium or higher"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
# Security levels in order: off, essentially_off, low, medium, high, under_attack
acceptable_levels = ["medium", "high", "under_attack"]
for zone_id, security_settings in firewall_client.security_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=security_settings,
resource_name=security_settings.zone_name,
resource_id=zone_id,
zone_name=security_settings.zone_name,
)
if security_settings.security_level in acceptable_levels:
report.status = "PASS"
report.status_extended = f"Zone {security_settings.zone_name} has Security Level set to '{security_settings.security_level}', providing adequate protection."
else:
report.status = "FAIL"
report.status_extended = f"Zone {security_settings.zone_name} has Security Level set to '{security_settings.security_level}'. Recommended: 'medium' or higher to balance protection with user accessibility."
findings.append(report)
return findings
@@ -0,0 +1,191 @@
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.cloudflare.lib.service.service import CloudflareService
class Firewall(CloudflareService):
"""Cloudflare Firewall service for managing firewall rules and WAF settings"""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.zones = self._list_zones()
self.firewall_rules = self._list_firewall_rules()
self.security_settings = self._get_security_settings()
def _list_zones(self) -> dict:
"""
List all Cloudflare zones
Returns:
dict: Dictionary of zones keyed by zone ID
"""
logger.info("Firewall - Listing Zones...")
zones = {}
try:
# If specific zone IDs are provided, use those
if self.provider.zone_ids:
for zone_id in self.provider.zone_ids:
zone_data = self._api_request("GET", f"/zones/{zone_id}")
if zone_data:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
status=zone_data.get("status", ""),
plan=zone_data.get("plan", {}).get("name", ""),
)
else:
# List all zones
all_zones = self._api_request_paginated("/zones")
for zone_data in all_zones:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
status=zone_data.get("status", ""),
plan=zone_data.get("plan", {}).get("name", ""),
)
logger.info(f"Found {len(zones)} zone(s)")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return zones
def _list_firewall_rules(self) -> dict:
"""
List firewall rules for all zones
Returns:
dict: Dictionary of firewall rules keyed by rule ID
"""
logger.info("Firewall - Listing Firewall Rules...")
firewall_rules = {}
try:
for zone_id, zone in self.zones.items():
# Get firewall rules for the zone
rules_data = self._api_request_paginated(
f"/zones/{zone_id}/firewall/rules"
)
for rule in rules_data:
firewall_rules[rule["id"]] = FirewallRule(
id=rule["id"],
zone_id=zone_id,
zone_name=zone.name,
paused=rule.get("paused", False),
description=rule.get("description", ""),
action=rule.get("action", ""),
priority=rule.get("priority", 0),
filter_id=rule.get("filter", {}).get("id", ""),
)
# Get WAF settings for the zone
waf_settings = self._api_request(
"GET", f"/zones/{zone_id}/firewall/waf/packages"
)
if waf_settings:
zone.waf_enabled = True
logger.info(f"Found {len(firewall_rules)} firewall rule(s)")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return firewall_rules
def _get_security_settings(self) -> dict:
"""
Get security settings for all zones
Returns:
dict: Dictionary of security settings keyed by zone ID
"""
logger.info("Firewall - Getting Security Settings...")
security_settings = {}
try:
for zone_id, zone in self.zones.items():
# Get security level
security_level = self._api_request(
"GET", f"/zones/{zone_id}/settings/security_level"
)
# Get browser integrity check
browser_check = self._api_request(
"GET", f"/zones/{zone_id}/settings/browser_check"
)
# Get challenge passage
challenge_ttl = self._api_request(
"GET", f"/zones/{zone_id}/settings/challenge_ttl"
)
security_settings[zone_id] = SecuritySettings(
zone_id=zone_id,
zone_name=zone.name,
security_level=(
security_level.get("value", "") if security_level else ""
),
browser_integrity_check=(
browser_check.get("value", "off") == "on"
if browser_check
else False
),
challenge_ttl=(
challenge_ttl.get("value", 0) if challenge_ttl else 0
),
)
logger.info(
f"Retrieved security settings for {len(security_settings)} zone(s)"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return security_settings
class Zone(BaseModel):
"""Model for Cloudflare Zone"""
id: str
name: str
account_id: str
status: str
plan: str
waf_enabled: bool = False
class FirewallRule(BaseModel):
"""Model for Cloudflare Firewall Rule"""
id: str
zone_id: str
zone_name: str
paused: bool
description: str
action: str
priority: int
filter_id: str
class SecuritySettings(BaseModel):
"""Model for Cloudflare Security Settings"""
zone_id: str
zone_name: str
security_level: str
browser_integrity_check: bool
challenge_ttl: int
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "firewall_waf_enabled",
"CheckTitle": "Ensure Web Application Firewall (WAF) is enabled",
"CheckType": [],
"ServiceName": "firewall",
"SubServiceName": "",
"ResourceIdTemplate": "zone_id",
"Severity": "high",
"ResourceType": "Zone",
"Description": "This check ensures that Web Application Firewall (WAF) is enabled for Cloudflare zones to protect against common web application attacks such as SQL injection, cross-site scripting (XSS), and other OWASP Top 10 vulnerabilities.",
"Risk": "Without WAF enabled, web applications are vulnerable to common attacks that could lead to data breaches, service disruptions, or unauthorized access.",
"RelatedUrl": "https://developers.cloudflare.com/waf/",
"Remediation": {
"Code": {
"CLI": "cloudflare firewall waf enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> Security -> WAF -> Enable",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable Web Application Firewall (WAF) for all Cloudflare zones to protect against common web application attacks.",
"Url": "https://developers.cloudflare.com/waf/managed-rules/"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "WAF is available on Pro, Business, and Enterprise plans. Free plans have limited WAF capabilities."
}
@@ -0,0 +1,36 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.firewall.firewall_client import (
firewall_client,
)
class firewall_waf_enabled(Check):
"""Check if Web Application Firewall (WAF) is enabled for Cloudflare zones
This class verifies whether each Cloudflare zone has WAF enabled to protect
against common web application attacks.
"""
def execute(self) -> List[CheckReportCloudflare]:
"""Execute the Cloudflare WAF enabled check
Iterates over all zones and checks if WAF is enabled.
Returns:
List[CheckReportCloudflare]: A list of reports for each zone
"""
findings = []
for zone_id, zone in firewall_client.zones.items():
report = CheckReportCloudflare(metadata=self.metadata(), resource=zone)
report.status = "FAIL"
report.status_extended = f"Zone {zone.name} does not have WAF enabled."
if zone.waf_enabled:
report.status = "PASS"
report.status_extended = f"Zone {zone.name} has WAF enabled."
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_always_use_https",
"CheckTitle": "Ensure 'Always Use HTTPS' is enabled",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "zone_id",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that 'Always Use HTTPS' is enabled for Cloudflare zones to automatically redirect all HTTP requests to HTTPS, ensuring all traffic is encrypted.",
"Risk": "Without 'Always Use HTTPS' enabled, visitors may access the website over unencrypted HTTP connections, exposing sensitive data to interception and man-in-the-middle attacks.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/",
"Remediation": {
"Code": {
"CLI": "curl -X PATCH \"https://api.cloudflare.com/v4/zones/<zone_id>/settings/always_use_https\" -H \"Authorization: Bearer <api_token>\" -H \"Content-Type: application/json\" -d '{\"value\":\"on\"}'",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Always Use HTTPS -> On",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n always_use_https = \"on\"\n }\n}"
},
"Recommendation": {
"Text": "Enable 'Always Use HTTPS' for all Cloudflare zones to ensure all traffic is encrypted and secure.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This setting redirects all HTTP requests to HTTPS using a 301 permanent redirect."
}
@@ -0,0 +1,42 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_always_use_https(Check):
"""Check if Cloudflare zones have 'Always Use HTTPS' enabled
This class verifies that each Cloudflare zone has 'Always Use HTTPS' enabled
to automatically redirect HTTP requests to HTTPS.
"""
def execute(self) -> List[CheckReportCloudflare]:
"""Execute the Cloudflare Always Use HTTPS check
Iterates over all SSL settings and checks if Always Use HTTPS is enabled.
Returns:
List[CheckReportCloudflare]: A list of reports for each zone
"""
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
zone = ssl_client.zones.get(zone_id)
if not zone:
continue
report = CheckReportCloudflare(
metadata=self.metadata(), resource=ssl_settings
)
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have 'Always Use HTTPS' enabled."
if ssl_settings.always_use_https:
report.status = "PASS"
report.status_extended = (
f"Zone {ssl_settings.zone_name} has 'Always Use HTTPS' enabled."
)
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_automatic_https_rewrites_enabled",
"CheckTitle": "Ensure Automatic HTTPS Rewrites is enabled to resolve mixed content issues",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that Automatic HTTPS Rewrites is enabled for Cloudflare zones to automatically rewrite insecure HTTP links to secure HTTPS links, resolving mixed content issues and enhancing site security.",
"Risk": "Without Automatic HTTPS Rewrites, pages may contain mixed content (HTTP resources loaded over HTTPS pages), which browsers block or warn about, degrading user experience and security.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/automatic-https-rewrites/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl automatic-https-rewrites enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Enable Automatic HTTPS Rewrites",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n automatic_https_rewrites = \"on\"\n }\n}"
},
"Recommendation": {
"Text": "Enable Automatic HTTPS Rewrites for all Cloudflare zones to prevent mixed content warnings and ensure all resources load securely.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/automatic-https-rewrites/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This feature works best when combined with Always Use HTTPS to ensure the entire site is served over HTTPS."
}
@@ -0,0 +1,31 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_automatic_https_rewrites_enabled(Check):
"""Check if Automatic HTTPS Rewrites is enabled to resolve mixed content issues"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
if ssl_settings.automatic_https_rewrites:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has Automatic HTTPS Rewrites enabled, resolving mixed content issues and enhancing site security."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have Automatic HTTPS Rewrites enabled. Enable it to automatically rewrite HTTP links to HTTPS and prevent mixed content warnings."
findings.append(report)
return findings
@@ -0,0 +1,4 @@
from prowler.providers.cloudflare.services.ssl.ssl_service import SSL
from prowler.providers.common.provider import Provider
ssl_client = SSL(Provider.get_global_provider())
@@ -0,0 +1,33 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_hsts_enabled",
"CheckTitle": "Ensure HSTS (HTTP Strict Transport Security) is enabled with recommended max-age",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Zone",
"Description": "This check ensures that HSTS (HTTP Strict Transport Security) is enabled for Cloudflare zones with a recommended max-age of at least 6 months (15768000 seconds) to prevent SSL stripping and man-in-the-middle attacks.",
"Risk": "Without HSTS enabled, browsers may initially connect over HTTP, making the connection vulnerable to SSL stripping attacks where an attacker downgrades the connection to unencrypted HTTP.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl hsts enable --zone-id <zone_id> --max-age 31536000",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Enable HSTS",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n security_header {\n enabled = true\n max_age = 31536000\n include_subdomains = true\n preload = true\n }\n }\n}"
},
"Recommendation": {
"Text": "Enable HSTS for all Cloudflare zones with a max-age of at least 6 months (15768000 seconds) to prevent SSL stripping attacks.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/"
}
},
"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."
}
@@ -0,0 +1,37 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_hsts_enabled(Check):
"""Check if HSTS (HTTP Strict Transport Security) is enabled with recommended max-age"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
# Recommended minimum max-age is 6 months (15768000 seconds)
recommended_max_age = 15768000
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
if ssl_settings.hsts_enabled:
if ssl_settings.hsts_max_age >= recommended_max_age:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has HSTS enabled with max-age of {ssl_settings.hsts_max_age} seconds (>= {recommended_max_age} recommended)."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} has HSTS enabled but max-age is {ssl_settings.hsts_max_age} seconds (< {recommended_max_age} recommended). Increase max-age for better security."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have HSTS enabled. Enable HSTS to prevent SSL stripping and man-in-the-middle attacks."
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_hsts_include_subdomains",
"CheckTitle": "Ensure HSTS includes subdomains for comprehensive protection",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that HSTS (HTTP Strict Transport Security) is configured with the includeSubDomains directive to apply HSTS policy uniformly across the entire domain including all subdomains.",
"Risk": "Without includeSubDomains directive, subdomains may be vulnerable to SSL stripping attacks even if the main domain has HSTS enabled.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl hsts enable --zone-id <zone_id> --include-subdomains",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> HSTS -> Enable 'Include subdomains'",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n security_header {\n enabled = true\n include_subdomains = true\n max_age = 31536000\n }\n }\n}"
},
"Recommendation": {
"Text": "Enable HSTS with includeSubDomains directive for all Cloudflare zones to ensure all subdomains are protected.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Ensure all subdomains are accessible via HTTPS before enabling includeSubDomains to avoid accessibility issues."
}
@@ -0,0 +1,34 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_hsts_include_subdomains(Check):
"""Check if HSTS includes subdomains for comprehensive protection"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
if ssl_settings.hsts_enabled and ssl_settings.hsts_include_subdomains:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has HSTS enabled with includeSubDomains directive, protecting all subdomains."
elif ssl_settings.hsts_enabled and not ssl_settings.hsts_include_subdomains:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} has HSTS enabled but does not include subdomains. Enable includeSubDomains to protect all subdomains."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have HSTS enabled. Enable HSTS with includeSubDomains directive."
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_mode_full_strict",
"CheckTitle": "Ensure SSL/TLS mode is set to Full (strict) for end-to-end encryption",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Zone",
"Description": "This check ensures that SSL/TLS mode is set to Full (strict) for Cloudflare zones to ensure end-to-end encryption with certificate validation between Cloudflare and origin servers.",
"Risk": "Using flexible or off SSL modes can expose traffic between Cloudflare and origin servers to interception. Full (strict) mode ensures encrypted connections and validates origin server certificates.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl mode update --zone-id <zone_id> --mode full",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Overview -> Set to 'Full (strict)'",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n ssl = \"full\"\n }\n}"
},
"Recommendation": {
"Text": "Set SSL/TLS mode to Full (strict) for all Cloudflare zones to ensure end-to-end encryption with proper certificate validation.",
"Url": "https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Full (strict) mode requires a valid SSL certificate on the origin server. Ensure your origin has a trusted certificate before enabling."
}
@@ -0,0 +1,31 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_mode_full_strict(Check):
"""Check if SSL/TLS mode is set to Full (strict) for end-to-end encryption"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
# SSL mode should be "full" or "strict" for end-to-end encryption
if ssl_settings.ssl_mode in ["full", "strict"]:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has SSL/TLS mode set to '{ssl_settings.ssl_mode}' ensuring end-to-end encryption."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} has SSL/TLS mode set to '{ssl_settings.ssl_mode}'. Recommended: 'full' or 'strict' for end-to-end encryption with certificate validation."
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_opportunistic_encryption_enabled",
"CheckTitle": "Ensure Opportunistic Encryption is enabled for HTTP/2 benefits",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "Zone",
"Description": "This check ensures that Opportunistic Encryption is enabled for Cloudflare zones to provide HTTP/2 benefits over encrypted connections, even for visitors using HTTP.",
"Risk": "Without Opportunistic Encryption, HTTP visitors cannot benefit from HTTP/2 performance improvements such as multiplexing and server push.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/opportunistic-encryption/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl opportunistic-encryption enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Enable Opportunistic Encryption",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n opportunistic_encryption = \"on\"\n }\n}"
},
"Recommendation": {
"Text": "Enable Opportunistic Encryption for all Cloudflare zones to provide HTTP/2 benefits to all visitors.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/opportunistic-encryption/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Opportunistic Encryption allows HTTP/2 over TLS for HTTP visitors, providing performance benefits without requiring HTTPS."
}
@@ -0,0 +1,31 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_opportunistic_encryption_enabled(Check):
"""Check if Opportunistic Encryption is enabled for HTTP/2 benefits"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
if ssl_settings.opportunistic_encryption:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has Opportunistic Encryption enabled, providing HTTP/2 benefits over encrypted connections."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have Opportunistic Encryption enabled. Enable it to provide HTTP/2 benefits over encrypted connections."
findings.append(report)
return findings
@@ -0,0 +1,181 @@
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.cloudflare.lib.service.service import CloudflareService
class SSL(CloudflareService):
"""Cloudflare SSL/TLS service for managing SSL settings"""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.zones = self._list_zones()
self.ssl_settings = self._get_ssl_settings()
def _list_zones(self) -> dict:
"""
List all Cloudflare zones
Returns:
dict: Dictionary of zones keyed by zone ID
"""
logger.info("SSL - Listing Zones...")
zones = {}
try:
# If specific zone IDs are provided, use those
if self.provider.zone_ids:
for zone_id in self.provider.zone_ids:
zone_data = self._api_request("GET", f"/zones/{zone_id}")
if zone_data:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
)
else:
# List all zones
all_zones = self._api_request_paginated("/zones")
for zone_data in all_zones:
zones[zone_data["id"]] = Zone(
id=zone_data["id"],
name=zone_data["name"],
account_id=zone_data.get("account", {}).get("id", ""),
)
logger.info(f"Found {len(zones)} zone(s) for SSL checks")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return zones
def _get_ssl_settings(self) -> dict:
"""
Get SSL/TLS settings for all zones
Returns:
dict: Dictionary of SSL settings keyed by zone ID
"""
logger.info("SSL - Getting SSL/TLS Settings...")
ssl_settings = {}
try:
for zone_id, zone in self.zones.items():
# Get SSL/TLS mode
ssl_mode = self._api_request("GET", f"/zones/{zone_id}/settings/ssl")
# Get minimum TLS version
min_tls = self._api_request(
"GET", f"/zones/{zone_id}/settings/min_tls_version"
)
# Get TLS 1.3 setting
tls_1_3 = self._api_request("GET", f"/zones/{zone_id}/settings/tls_1_3")
# Get automatic HTTPS rewrites
auto_https = self._api_request(
"GET", f"/zones/{zone_id}/settings/automatic_https_rewrites"
)
# Get always use HTTPS
always_https = self._api_request(
"GET", f"/zones/{zone_id}/settings/always_use_https"
)
# Get opportunistic encryption
opportunistic = self._api_request(
"GET", f"/zones/{zone_id}/settings/opportunistic_encryption"
)
# Get HSTS settings
hsts = self._api_request(
"GET", f"/zones/{zone_id}/settings/security_header"
)
ssl_settings[zone_id] = SSLSettings(
zone_id=zone_id,
zone_name=zone.name,
ssl_mode=ssl_mode.get("value", "") if ssl_mode else "",
min_tls_version=(min_tls.get("value", "") if min_tls else "1.0"),
tls_1_3_enabled=(
tls_1_3.get("value", "off") == "on" if tls_1_3 else False
),
automatic_https_rewrites=(
auto_https.get("value", "off") == "on" if auto_https else False
),
always_use_https=(
always_https.get("value", "off") == "on"
if always_https
else False
),
opportunistic_encryption=(
opportunistic.get("value", "off") == "on"
if opportunistic
else False
),
hsts_enabled=(
hsts.get("value", {})
.get("strict_transport_security", {})
.get("enabled", False)
if hsts
else False
),
hsts_max_age=(
hsts.get("value", {})
.get("strict_transport_security", {})
.get("max_age", 0)
if hsts
else 0
),
hsts_include_subdomains=(
hsts.get("value", {})
.get("strict_transport_security", {})
.get("include_subdomains", False)
if hsts
else False
),
hsts_preload=(
hsts.get("value", {})
.get("strict_transport_security", {})
.get("preload", False)
if hsts
else False
),
)
logger.info(f"Retrieved SSL settings for {len(ssl_settings)} zone(s)")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return ssl_settings
class Zone(BaseModel):
"""Model for Cloudflare Zone"""
id: str
name: str
account_id: str
class SSLSettings(BaseModel):
"""Model for Cloudflare SSL/TLS Settings"""
zone_id: str
zone_name: str
ssl_mode: str
min_tls_version: str
tls_1_3_enabled: bool
automatic_https_rewrites: bool
always_use_https: bool
opportunistic_encryption: bool
hsts_enabled: bool
hsts_max_age: int
hsts_include_subdomains: bool
hsts_preload: bool
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_tls_1_3_enabled",
"CheckTitle": "Ensure TLS 1.3 is enabled for enhanced security and performance",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Zone",
"Description": "This check ensures that TLS 1.3 is enabled for Cloudflare zones to activate the latest TLS protocol, which streamlines the TLS handshake, enhances security, and reduces connection time.",
"Risk": "Without TLS 1.3 enabled, connections use older TLS versions which have longer handshake times and may be vulnerable to known attacks.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/tls-13/",
"Remediation": {
"Code": {
"CLI": "cloudflare ssl tls-1-3 enable --zone-id <zone_id>",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Enable TLS 1.3",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n tls_1_3 = \"on\"\n }\n}"
},
"Recommendation": {
"Text": "Enable TLS 1.3 for all Cloudflare zones to take advantage of improved security and performance.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/tls-13/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "TLS 1.3 is supported by modern browsers and provides significant security and performance improvements."
}
@@ -0,0 +1,31 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_tls_1_3_enabled(Check):
"""Check if TLS 1.3 is enabled for enhanced security and performance"""
def execute(self) -> List[CheckReportCloudflare]:
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
report = CheckReportCloudflare(
metadata=self.metadata(),
resource=ssl_settings,
resource_name=ssl_settings.zone_name,
resource_id=zone_id,
zone_name=ssl_settings.zone_name,
)
if ssl_settings.tls_1_3_enabled:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has TLS 1.3 enabled, providing enhanced security and reduced connection time."
else:
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} does not have TLS 1.3 enabled. Enable TLS 1.3 for improved security and performance."
findings.append(report)
return findings
@@ -0,0 +1,32 @@
{
"Provider": "cloudflare",
"CheckID": "ssl_tls_minimum_version",
"CheckTitle": "Ensure minimum TLS version is set to 1.2 or higher",
"CheckType": [],
"ServiceName": "ssl",
"SubServiceName": "",
"ResourceIdTemplate": "zone_id",
"Severity": "high",
"ResourceType": "Zone",
"Description": "This check ensures that Cloudflare zones enforce a minimum TLS version of 1.2 or higher. TLS 1.0 and 1.1 are deprecated and have known security vulnerabilities.",
"Risk": "Using outdated TLS versions (1.0 and 1.1) exposes connections to known security vulnerabilities and does not meet modern security standards. This can lead to man-in-the-middle attacks and data interception.",
"RelatedUrl": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/",
"Remediation": {
"Code": {
"CLI": "curl -X PATCH \"https://api.cloudflare.com/v4/zones/<zone_id>/settings/min_tls_version\" -H \"Authorization: Bearer <api_token>\" -H \"Content-Type: application/json\" -d '{\"value\":\"1.2\"}'",
"NativeIaC": "",
"Other": "https://dash.cloudflare.com/ -> Select Zone -> SSL/TLS -> Edge Certificates -> Minimum TLS Version -> Set to 1.2 or higher",
"Terraform": "resource \"cloudflare_zone_settings_override\" \"example\" {\n zone_id = var.zone_id\n settings {\n min_tls_version = \"1.2\"\n }\n}"
},
"Recommendation": {
"Text": "Set the minimum TLS version to 1.2 or 1.3 for all Cloudflare zones to ensure secure encrypted connections.",
"Url": "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "TLS 1.0 and 1.1 were deprecated by major browsers in 2020. TLS 1.2 is the current recommended minimum version."
}
@@ -0,0 +1,41 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportCloudflare
from prowler.providers.cloudflare.services.ssl.ssl_client import ssl_client
class ssl_tls_minimum_version(Check):
"""Check if Cloudflare zones have minimum TLS version set to 1.2 or higher
This class verifies that each Cloudflare zone enforces a minimum TLS version
of 1.2 or higher to ensure secure connections.
"""
def execute(self) -> List[CheckReportCloudflare]:
"""Execute the Cloudflare minimum TLS version check
Iterates over all SSL settings and checks the minimum TLS version.
Returns:
List[CheckReportCloudflare]: A list of reports for each zone
"""
findings = []
for zone_id, ssl_settings in ssl_client.ssl_settings.items():
zone = ssl_client.zones.get(zone_id)
if not zone:
continue
report = CheckReportCloudflare(
metadata=self.metadata(), resource=ssl_settings
)
report.status = "FAIL"
report.status_extended = f"Zone {ssl_settings.zone_name} has minimum TLS version set to {ssl_settings.min_tls_version}, which is below the recommended 1.2."
# Check if minimum TLS version is 1.2 or higher
if ssl_settings.min_tls_version in ["1.2", "1.3"]:
report.status = "PASS"
report.status_extended = f"Zone {ssl_settings.zone_name} has minimum TLS version set to {ssl_settings.min_tls_version}."
findings.append(report)
return findings
+11
View File
@@ -302,6 +302,17 @@ class Provider(ABC):
fixer_config=fixer_config,
use_instance_principal=arguments.use_instance_principal,
)
elif "cloudflare" in provider_class_name.lower():
provider_class(
api_token=arguments.api_token,
api_key=arguments.api_key,
api_email=arguments.api_email,
account_ids=arguments.account_id,
zone_ids=arguments.zone_id,
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
except TypeError as error:
logger.critical(
@@ -2,6 +2,7 @@ from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
AdminRoles,
AuthenticationStrength,
ConditionalAccessPolicyState,
)
@@ -46,25 +47,7 @@ class entra_admin_users_phishing_resistant_mfa_enabled(Check):
if (
policy.grant_controls.authentication_strength is not None
and policy.grant_controls.authentication_strength
!= "Multifactor authentication"
and policy.grant_controls.authentication_strength != "Passwordless MFA"
and policy.grant_controls.authentication_strength
!= "Phishing-resistant MFA"
):
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "MANUAL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' has a custom authentication strength, review it is Phishing-resistant MFA."
continue
if (
policy.grant_controls.authentication_strength is not None
and policy.grant_controls.authentication_strength
== "Phishing-resistant MFA"
== AuthenticationStrength.PHISHING_RESISTANT_MFA
):
report = CheckReportM365(
metadata=self.metadata(),
@@ -253,7 +253,9 @@ class Entra(M365Service):
)
),
authentication_strength=(
policy.grant_controls.authentication_strength.display_name
AuthenticationStrength(
policy.grant_controls.authentication_strength.display_name
)
if policy.grant_controls is not None
and policy.grant_controls.authentication_strength
is not None
@@ -453,7 +455,6 @@ class ConditionalAccessPolicyState(Enum):
class UserAction(Enum):
REGISTER_SECURITY_INFO = "urn:user:registersecurityinfo"
REGISTER_DEVICE = "urn:user:registerdevice"
class ApplicationsConditions(BaseModel):
@@ -522,19 +523,11 @@ class SessionControls(BaseModel):
class ConditionalAccessGrantControl(Enum):
"""
Built-in grant controls for Conditional Access policies.
Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessgrantcontrols
"""
MFA = "mfa"
BLOCK = "block"
DOMAIN_JOINED_DEVICE = "domainJoinedDevice"
PASSWORD_CHANGE = "passwordChange"
COMPLIANT_DEVICE = "compliantDevice"
APPROVED_APPLICATION = "approvedApplication"
COMPLIANT_APPLICATION = "compliantApplication"
TERMS_OF_USE = "termsOfUse"
class GrantControlOperator(Enum):
@@ -542,10 +535,16 @@ class GrantControlOperator(Enum):
OR = "OR"
class AuthenticationStrength(Enum):
MFA = "Multifactor authentication"
PASSWORDLESS_MFA = "Passwordless MFA"
PHISHING_RESISTANT_MFA = "Phishing-resistant MFA"
class GrantControls(BaseModel):
built_in_controls: List[ConditionalAccessGrantControl]
operator: GrantControlOperator
authentication_strength: Optional[str]
authentication_strength: Optional[AuthenticationStrength]
class ConditionalAccessPolicy(BaseModel):
@@ -3,6 +3,7 @@ from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
AuthenticationStrength,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
Conditions,
@@ -113,7 +114,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
operator=GrantControlOperator.AND,
authentication_strength="Phishing-resistant MFA",
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
@@ -205,7 +206,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
operator=GrantControlOperator.AND,
authentication_strength="Phishing-resistant MFA",
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
@@ -300,7 +301,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
operator=GrantControlOperator.AND,
authentication_strength="Phishing-resistant MFA",
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
@@ -7,6 +7,7 @@ from prowler.providers.m365.services.entra.entra_service import (
AdminConsentPolicy,
AdminRoles,
ApplicationsConditions,
AuthenticationStrength,
AuthorizationPolicy,
AuthPolicyRoles,
ConditionalAccessGrantControl,
@@ -74,7 +75,7 @@ async def mock_entra_get_conditional_access_policies(_):
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
],
operator=GrantControlOperator.OR,
authentication_strength="Phishing-resistant MFA",
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
@@ -225,7 +226,7 @@ class Test_Entra_Service:
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
],
operator=GrantControlOperator.OR,
authentication_strength="Phishing-resistant MFA",
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
+1 -1
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.13.0] (Prowler v5.13.0)
## [1.13.0] (Prowler UNRELEASED)
### 🚀 Added
@@ -1,134 +0,0 @@
"use client";
import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
import { DonutChart } from "@/components/graphs/donut-chart";
import { DonutDataPoint } from "@/components/graphs/types";
import {
BaseCard,
CardContent,
CardHeader,
CardTitle,
ResourceStatsCard,
StatsContainer,
} from "@/components/shadcn";
import { CardVariant } from "@/components/shadcn/card/resource-stats-card/resource-stats-card-content";
interface CheckFindingsProps {
failFindingsData: {
total: number;
new: number;
muted: number;
};
passFindingsData: {
total: number;
new: number;
muted: number;
};
}
export const CheckFindings = ({
failFindingsData,
passFindingsData,
}: CheckFindingsProps) => {
// Calculate total findings
const totalFindings = failFindingsData.total + passFindingsData.total;
// Calculate percentages
const failPercentage = Math.round(
(failFindingsData.total / totalFindings) * 100,
);
const passPercentage = Math.round(
(passFindingsData.total / totalFindings) * 100,
);
// Calculate change percentages (new findings as percentage change)
const failChange =
failFindingsData.total > 0
? Math.round((failFindingsData.new / failFindingsData.total) * 100)
: 0;
const passChange =
passFindingsData.total > 0
? Math.round((passFindingsData.new / passFindingsData.total) * 100)
: 0;
// Mock data for DonutChart
const donutData: DonutDataPoint[] = [
{
name: "Fail Findings",
value: failFindingsData.total,
color: "#f43f5e", // Rose-500
percentage: Number(failPercentage),
change: Number(failChange),
},
{
name: "Pass Findings",
value: passFindingsData.total,
color: "#4ade80", // Green-400
percentage: Number(passPercentage),
change: Number(passChange),
},
];
return (
<BaseCard>
{/* Header */}
<CardHeader>
<CardTitle>Check Findings</CardTitle>
</CardHeader>
{/* DonutChart Content */}
<CardContent className="space-y-4">
<div className="mx-auto max-h-[200px] max-w-[200px]">
<DonutChart
data={donutData}
showLegend={false}
innerRadius={66}
outerRadius={86}
centerLabel={{
value: totalFindings.toLocaleString(),
label: "Total Findings",
}}
/>
</div>
{/* Footer with ResourceStatsCards */}
<StatsContainer>
<ResourceStatsCard
containerless
badge={{
icon: TriangleAlert,
count: failFindingsData.total,
variant: CardVariant.fail,
}}
label="Fail Findings"
stats={[
{ icon: Bell, label: `${failFindingsData.new} New` },
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
]}
className="flex-1"
/>
<div className="flex items-center justify-center px-[46px]">
<div className="h-full w-px bg-slate-300 dark:bg-[rgba(39,39,42,1)]" />
</div>
<ResourceStatsCard
containerless
badge={{
icon: ShieldCheck,
count: passFindingsData.total,
variant: CardVariant.pass,
}}
label="Pass Findings"
stats={[
{ icon: Bell, label: `${passFindingsData.new} New` },
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
]}
className="flex-1"
/>
</StatsContainer>
</CardContent>
</BaseCard>
);
};
-87
View File
@@ -1,87 +0,0 @@
import { Suspense } from "react";
import { getFindingsByStatus } from "@/actions/overview/overview";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { CheckFindings } from "./components/check-findings";
const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
export default async function NewOverviewPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
return (
<ContentLayout title="New Overview" icon="lucide:square-chart-gantt">
<div className="flex min-h-[60vh] items-center justify-center p-6">
<Suspense
fallback={
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Loading...</p>
</div>
}
>
<SSRCheckFindings searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
const SSRCheckFindings = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load findings data</p>
</div>
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
return (
<CheckFindings
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
/>
);
};
+44 -40
View File
@@ -21,39 +21,44 @@ interface DonutChartProps {
}
const CustomTooltip = ({ active, payload }: any) => {
if (!active || !payload || !payload.length) return null;
const entry = payload[0];
const name = entry.name;
const percentage = entry.payload?.percentage;
const color = entry.color || entry.payload?.color;
const change = entry.payload?.change;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
<div className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
/>
<span className="text-sm font-semibold text-slate-600 dark:text-zinc-300">
{percentage}%
</span>
<span>{name}</span>
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-zinc-300">
{change !== undefined && (
<>
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div
className="rounded-lg border p-3 shadow-lg"
style={{
backgroundColor: "var(--chart-background)",
borderColor: "var(--chart-border-emphasis)",
}}
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: data.color }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--chart-text-primary)" }}
>
{data.percentage}% {data.name}
</span>
</div>
{data.change !== undefined && (
<p
className="mt-2 text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
<span className="font-bold">
{change > 0 ? "+" : ""}
{change}%
</span>
<span> Since Last Scan</span>
</>
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since last scan
</p>
)}
</p>
</div>
);
</div>
);
}
return null;
};
const CustomLegend = ({ payload }: any) => {
@@ -67,8 +72,8 @@ const CustomLegend = ({ payload }: any) => {
export function DonutChart({
data,
innerRadius = 68,
outerRadius = 86,
innerRadius = 80,
outerRadius = 120,
showLegend = true,
centerLabel,
}: DonutChartProps) {
@@ -103,7 +108,7 @@ export function DonutChart({
}));
return (
<>
<div>
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[350px]"
@@ -117,7 +122,7 @@ export function DonutChart({
innerRadius={innerRadius}
outerRadius={outerRadius}
strokeWidth={0}
paddingAngle={0}
paddingAngle={2}
>
{chartData.map((entry, index) => {
const opacity =
@@ -152,9 +157,9 @@ export function DonutChart({
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="text-3xl font-bold text-black dark:text-white"
className="text-3xl font-bold"
style={{
fill: "currentColor",
fill: "var(--chart-text-primary)",
}}
>
{formattedValue}
@@ -162,9 +167,8 @@ export function DonutChart({
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="text-black dark:text-white"
style={{
fill: "currentColor",
fill: "var(--chart-text-secondary)",
}}
>
{centerLabel.label}
@@ -179,6 +183,6 @@ export function DonutChart({
</PieChart>
</ChartContainer>
{showLegend && <CustomLegend payload={legendPayload} />}
</>
</div>
);
}
+45 -15
View File
@@ -29,7 +29,7 @@ export function ChartTooltip({
return (
<div
className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800"
className="min-w-[200px] rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
@@ -45,12 +45,15 @@ export function ChartTooltip({
style={{ backgroundColor: color }}
/>
)}
<p className="text-sm font-semibold text-slate-900 dark:text-white">
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{label || data.name}
</p>
</div>
<p className="mt-1 text-xs text-slate-900 dark:text-white">
<p className="mt-1 text-xs" style={{ color: CHART_COLORS.textPrimary }}>
{typeof data.value === "number"
? data.value.toLocaleString()
: data.value}
@@ -59,8 +62,11 @@ export function ChartTooltip({
{data.newFindings !== undefined && data.newFindings > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
<span
className="text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
{data.newFindings} New Findings
</span>
</div>
@@ -68,8 +74,11 @@ export function ChartTooltip({
{data.new !== undefined && data.new > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
<span
className="text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
{data.new} New
</span>
</div>
@@ -77,15 +86,21 @@ export function ChartTooltip({
{data.muted !== undefined && data.muted > 0 && (
<div className="mt-1 flex items-center gap-2">
<VolumeX size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<VolumeX size={14} style={{ color: CHART_COLORS.textSecondary }} />
<span
className="text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
{data.muted} Muted
</span>
</div>
)}
{data.change !== undefined && (
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
<p
className="mt-1 text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{data.change}%
@@ -110,8 +125,17 @@ export function MultiSeriesChartTooltip({
}
return (
<div className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
<p className="mb-2 text-sm font-semibold text-slate-900 dark:text-white">
<div
className="min-w-[200px] rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="mb-2 text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{label}
</p>
@@ -121,14 +145,20 @@ export function MultiSeriesChartTooltip({
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-slate-900 dark:text-white">
<span className="text-xs" style={{ color: CHART_COLORS.textPrimary }}>
{entry.name}:
</span>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
<span
className="text-xs font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{entry.value}
</span>
{entry.payload[`${entry.dataKey}_change`] && (
<span className="text-xs text-slate-600 dark:text-slate-400">
<span
className="text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
{entry.payload[`${entry.dataKey}_change`]}%)
</span>
+53
View File
@@ -0,0 +1,53 @@
import { tv } from "tailwind-variants";
export const title = tv({
base: "tracking-tight inline font-semibold",
variants: {
color: {
violet: "from-[#FF1CF7] to-[#b249f8]",
yellow: "from-[#FF705B] to-[#FFB457]",
blue: "from-[#5EA2EF] to-[#0072F5]",
cyan: "from-[#00b7fa] to-[#01cfea]",
green: "from-[#6FEE8D] to-[#17c964]",
pink: "from-[#FF72E1] to-[#F54C7A]",
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
},
size: {
sm: "text-3xl lg:text-4xl",
md: "text-[2.3rem] lg:text-5xl leading-9",
lg: "text-4xl lg:text-6xl",
},
fullWidth: {
true: "w-full block",
},
},
defaultVariants: {
size: "md",
},
compoundVariants: [
{
color: [
"violet",
"yellow",
"blue",
"cyan",
"green",
"pink",
"foreground",
],
class: "bg-clip-text text-transparent bg-linear-to-b",
},
],
});
export const subtitle = tv({
base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full",
variants: {
fullWidth: {
true: "w-full!",
},
},
defaultVariants: {
fullWidth: true,
},
});
+5 -10
View File
@@ -4,18 +4,13 @@ This directory contains all shadcn/ui based components for the Prowler applicati
## Directory Structure
Example of a custom component:
```
shadcn/
├── card/
│ ├── base-card/
│ ├── base-card.tsx
│ ├── card/
│ ├── card.tsx
│ └── resource-stats-card/
│ ├── resource-stats-card.tsx
│ ├── resource-stats-card.example.tsx
├── card.tsx # shadcn Card component
├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
│ ├── resource-stats-card.tsx
│ ├── resource-stats-card.example.tsx
└── index.ts
├── index.ts # Barrel exports
└── README.md
```
@@ -1,3 +1,5 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
@@ -18,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
@@ -30,10 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"my-2 text-[18px] leading-none text-slate-900 dark:text-white",
className,
)}
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
@@ -1,36 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Card } from "../card";
const baseCardVariants = cva("", {
variants: {
variant: {
default:
"border-slate-200 bg-white dark:border-zinc-900 dark:bg-stone-950",
},
},
defaultVariants: {
variant: "default",
},
});
interface BaseCardProps
extends React.ComponentProps<typeof Card>,
VariantProps<typeof baseCardVariants> {}
const BaseCard = ({ className, variant, ...props }: BaseCardProps) => {
return (
<Card
className={cn(
baseCardVariants({ variant }),
"gap-2 px-[18px] pt-3 pb-4",
className,
)}
{...props}
/>
);
};
export { BaseCard };
@@ -1,25 +0,0 @@
import { cn } from "@/lib/utils";
interface StatsContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
const StatsContainer = ({
className,
children,
...props
}: StatsContainerProps) => {
return (
<div
className={cn(
"flex rounded-xl border border-slate-200 bg-white px-[19px] py-[9px] dark:border-[rgba(38,38,38,0.7)] dark:bg-[rgba(23,23,23,0.5)] dark:backdrop-blur-[46px]",
className,
)}
{...props}
>
{children}
</div>
);
};
export { StatsContainer };

Some files were not shown because too many files have changed in this diff Show More